diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index f7d90e4c..81839867 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -348,7 +348,7 @@ @layer components { /* Buttons */ .btn { - @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; + @apply inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; @apply transition-all duration-300; } @@ -365,7 +365,7 @@ } .btn--ghost { - @apply border border-transparent text-gray-900 hover:bg-gray-100; + @apply border border-transparent text-secondary hover:border hover:border-alpha-black-200 hover:bg-gray-50; } .btn--outline-destructive { diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 3ba7c7a7..d1b4ac0d 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -9,13 +9,21 @@ class RulesController < ApplicationController end def new - actions = [] - - if params[:action_type] - actions << Rule::Action.new(action_type: params[:action_type], value: params[:action_value]) - end - - @rule = Current.family.rules.build(resource_type: params[:resource_type] || "transaction", actions: actions) + @rule = Current.family.rules.build( + resource_type: params[:resource_type] || "transaction", + conditions: [ + Rule::Condition.new( + condition_type: params[:condition_type] || "transaction_amount", + value: params[:condition_value] + ) + ], + actions: [ + Rule::Action.new( + action_type: params[:action_type] || "set_transaction_category", + value: params[:action_value] + ) + ] + ) end def create diff --git a/app/javascript/controllers/rule/actions_controller.js b/app/javascript/controllers/rule/actions_controller.js new file mode 100644 index 00000000..815e027e --- /dev/null +++ b/app/javascript/controllers/rule/actions_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="rule--actions" +export default class extends Controller { + static values = { actionExecutors: Array }; + static targets = ["destroyField", "actionValue"]; + + remove(e) { + if (e.params.destroy) { + this.destroyFieldTarget.value = true; + } else { + this.element.remove(); + } + } + + handleActionTypeChange(e) { + const actionExecutor = this.actionExecutorsValue.find( + (executor) => executor.key === e.target.value, + ); + + if (actionExecutor.type === "select") { + this.#updateValueSelectFor(actionExecutor); + this.#showAndEnableValueSelect(); + } else { + this.#hideAndDisableValueSelect(); + } + } + + get valueSelectEl() { + return this.actionValueTarget.querySelector("select"); + } + + #showAndEnableValueSelect() { + this.actionValueTarget.classList.remove("hidden"); + this.valueSelectEl.disabled = false; + } + + #hideAndDisableValueSelect() { + this.actionValueTarget.classList.add("hidden"); + this.valueSelectEl.disabled = true; + } + + #updateValueSelectFor(actionExecutor) { + // Clear existing options + this.valueSelectEl.innerHTML = ""; + + // Add new options + for (const option of actionExecutor.options) { + const optionEl = document.createElement("option"); + optionEl.value = option[1]; + optionEl.textContent = option[0]; + this.valueSelectEl.appendChild(optionEl); + } + } +} diff --git a/app/javascript/controllers/rule/conditions_controller.js b/app/javascript/controllers/rule/conditions_controller.js new file mode 100644 index 00000000..a3ebdce9 --- /dev/null +++ b/app/javascript/controllers/rule/conditions_controller.js @@ -0,0 +1,100 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="rule--conditions" +export default class extends Controller { + static values = { conditionFilters: Array }; + static targets = [ + "destroyField", + "filterValue", + "operatorSelect", + "subConditionTemplate", + "subConditionsList", + ]; + + addSubCondition() { + const html = this.subConditionTemplateTarget.innerHTML.replaceAll( + "IDX_PLACEHOLDER", + this.#uniqueKey(), + ); + + this.subConditionsListTarget.insertAdjacentHTML("beforeend", html); + } + + remove(e) { + if (e.params.destroy) { + this.destroyFieldTarget.value = true; + } else { + this.element.remove(); + } + } + + handleConditionTypeChange(e) { + const conditionFilter = this.conditionFiltersValue.find( + (filter) => filter.key === e.target.value, + ); + + if (conditionFilter.type === "select") { + this.#buildSelectFor(conditionFilter); + } else { + this.#buildTextInputFor(conditionFilter); + } + + this.#updateOperatorsField(conditionFilter); + } + + get valueInputEl() { + const textInput = this.filterValueTarget.querySelector("input"); + const selectInput = this.filterValueTarget.querySelector("select"); + + return textInput || selectInput; + } + + #updateOperatorsField(conditionFilter) { + this.operatorSelectTarget.innerHTML = ""; + + for (const operator of conditionFilter.operators) { + const optionEl = document.createElement("option"); + optionEl.value = operator; + optionEl.textContent = operator; + this.operatorSelectTarget.appendChild(optionEl); + } + } + + #buildSelectFor(conditionFilter) { + const selectEl = this.#convertFormFieldTo("select", this.valueInputEl); + + for (const option of conditionFilter.options) { + const optionEl = document.createElement("option"); + optionEl.value = option[1]; + optionEl.textContent = option[0]; + selectEl.appendChild(optionEl); + } + + this.valueInputEl.replaceWith(selectEl); + } + + #buildTextInputFor(conditionFilter) { + const textInput = this.#convertFormFieldTo("input", this.valueInputEl); + textInput.placeholder = "Enter a value"; + textInput.type = conditionFilter.type; // "text" || "number" + + this.valueInputEl.replaceWith(textInput); + } + + #convertFormFieldTo(type, el) { + const priorClasses = el.classList; + const priorId = el.id; + const priorName = el.name; + + const newFormField = document.createElement(type); + newFormField.classList.add(...priorClasses); + newFormField.id = priorId; + newFormField.name = priorName; + + return newFormField; + } + + #uniqueKey() { + return Math.random().toString(36).substring(2, 15); + } +} diff --git a/app/javascript/controllers/rule_controller.js b/app/javascript/controllers/rule_controller.js deleted file mode 100644 index 79dd3cda..00000000 --- a/app/javascript/controllers/rule_controller.js +++ /dev/null @@ -1,169 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="rule" -export default class extends Controller { - static values = { - registry: Object, - }; - - static targets = [ - "newConditionTemplate", - "newActionTemplate", - "conditionsList", - "condition", - "actionsList", - "action", - "destroyField", - "operatorField", - "valueField", - ]; - - initialize() { - console.log(this.registryValue); - } - - addCondition() { - const html = this.newConditionTemplateTarget.innerHTML.replaceAll( - "IDX_PLACEHOLDER", - this.#uniqueKey(), - ); - - this.conditionsListTarget.insertAdjacentHTML("beforeend", html); - } - - handleConditionTypeChange(e) { - const definition = this.#getConditionFilterDefinition(e.target.value); - const conditionEl = this.#getEventConditionEl(e.target); - const valueFieldEl = this.#getFieldEl(this.valueFieldTargets, conditionEl); - - this.#updateOperatorsField(definition, conditionEl); - - if (definition.type === "select") { - const selectEl = this.#buildSelectInput(definition, valueFieldEl); - valueFieldEl.replaceWith(selectEl); - } else { - const inputEl = this.#buildTextInput(definition, valueFieldEl); - valueFieldEl.replaceWith(inputEl); - } - } - - addAction() { - const html = this.newActionTemplateTarget.innerHTML.replaceAll( - "IDX_PLACEHOLDER", - this.#uniqueKey(), - ); - - this.actionsListTarget.insertAdjacentHTML("beforeend", html); - } - - handleActionTypeChange(e) { - const definition = this.#getActionExecutorDefinition(e.target.value); - const actionEl = this.#getEventActionEl(e.target); - const valueFieldEl = this.#getFieldEl(this.valueFieldTargets, actionEl); - - if (definition.type === "select") { - const selectEl = this.#buildSelectInput(definition, valueFieldEl); - valueFieldEl.replaceWith(selectEl); - } else { - valueFieldEl.classList.add("hidden"); - } - } - - removeCondition(e) { - const conditionEl = this.conditionTargets.find((el) => { - return el.contains(e.target); - }); - - if (e.params.destroy) { - this.#destroyRuleItem(conditionEl); - } else { - conditionEl.remove(); - } - } - - removeAction(e) { - const actionEl = this.actionTargets.find((el) => { - return el.contains(e.target); - }); - - if (e.params.destroy) { - this.#destroyRuleItem(actionEl); - } else { - actionEl.remove(); - } - } - - #updateOperatorsField(definition, conditionEl) { - const operatorFieldEl = this.#getFieldEl( - this.operatorFieldTargets, - conditionEl, - ); - - operatorFieldEl.innerHTML = definition.operators - .map((operator) => { - return ``; - }) - .join(""); - } - - #buildTextInput(definition, fieldEl) { - const inputEl = document.createElement("input"); - inputEl.setAttribute("data-rule-target", "valueField"); - inputEl.setAttribute("name", fieldEl.name); - inputEl.setAttribute("id", fieldEl.id); - inputEl.setAttribute("type", definition.type); - - return inputEl; - } - - #buildSelectInput(definition, fieldEl) { - const selectEl = document.createElement("select"); - selectEl.setAttribute("data-rule-target", "valueField"); - selectEl.setAttribute("name", fieldEl.name); - selectEl.setAttribute("id", fieldEl.id); - - definition.options.forEach((option) => { - const optionEl = document.createElement("option"); - optionEl.textContent = option[0]; - optionEl.value = option[1]; - selectEl.appendChild(optionEl); - }); - - return selectEl; - } - - #destroyRuleItem(itemEl) { - const destroyFieldEl = this.#getFieldEl(this.destroyFieldTargets, itemEl); - - itemEl.classList.add("hidden"); - destroyFieldEl.value = true; - } - - #uniqueKey() { - return Date.now(); - } - - #getConditionFilterDefinition(key) { - return this.registryValue.filters.find((filter) => { - return filter.key === key; - }); - } - - #getActionExecutorDefinition(key) { - return this.registryValue.executors.find((executor) => { - return executor.key === key; - }); - } - - #getEventConditionEl(childEl) { - return this.conditionTargets.find((t) => t.contains(childEl)); - } - - #getEventActionEl(childEl) { - return this.actionTargets.find((t) => t.contains(childEl)); - } - - #getFieldEl(targets, containerEl) { - return targets.find((t) => containerEl.contains(t)); - } -} diff --git a/app/javascript/controllers/rules_controller.js b/app/javascript/controllers/rules_controller.js new file mode 100644 index 00000000..d92d0548 --- /dev/null +++ b/app/javascript/controllers/rules_controller.js @@ -0,0 +1,50 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="rules" +export default class extends Controller { + static targets = [ + "conditionTemplate", + "conditionGroupTemplate", + "actionTemplate", + "conditionsList", + "actionsList", + "effectiveDateInput", + ]; + + addConditionGroup() { + this.#appendTemplate( + this.conditionGroupTemplateTarget, + this.conditionsListTarget, + ); + } + + addCondition() { + this.#appendTemplate( + this.conditionTemplateTarget, + this.conditionsListTarget, + ); + } + + addAction() { + this.#appendTemplate(this.actionTemplateTarget, this.actionsListTarget); + } + + clearEffectiveDate() { + this.effectiveDateInputTarget.value = ""; + } + + enableEffectiveDate() {} + + #appendTemplate(templateEl, listEl) { + const html = templateEl.innerHTML.replaceAll( + "IDX_PLACEHOLDER", + this.#uniqueKey(), + ); + + listEl.insertAdjacentHTML("beforeend", html); + } + + #uniqueKey() { + return Date.now(); + } +} diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index e280e750..33935317 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -5,6 +5,8 @@ class Rule::Condition < ApplicationRecord has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent validates :condition_type, presence: true + validates :operator, presence: true + validates :value, presence: true, unless: -> { compound? } accepts_nested_attributes_for :sub_conditions, allow_destroy: true diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb index 165528f6..0af732c3 100644 --- a/app/views/rule/actions/_action.html.erb +++ b/app/views/rule/actions/_action.html.erb @@ -2,21 +2,24 @@ <% action = form.object %> <% rule = action.rule %> +<% needs_value = action.executor.type == "select" %> -
match
+ <%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %> +of the following conditions
+