1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

Complete dynamic rule form, split Stimulus controllers by resource

This commit is contained in:
Zach Gollwitzer 2025-04-10 16:42:00 -04:00
parent 3b464b97aa
commit eabacb2d01
14 changed files with 362 additions and 239 deletions

View file

@ -348,7 +348,7 @@
@layer components { @layer components {
/* Buttons */ /* Buttons */
.btn { .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; @apply transition-all duration-300;
} }
@ -365,7 +365,7 @@
} }
.btn--ghost { .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 { .btn--outline-destructive {

View file

@ -9,13 +9,21 @@ class RulesController < ApplicationController
end end
def new def new
actions = [] @rule = Current.family.rules.build(
resource_type: params[:resource_type] || "transaction",
if params[:action_type] conditions: [
actions << Rule::Action.new(action_type: params[:action_type], value: params[:action_value]) Rule::Condition.new(
end condition_type: params[:condition_type] || "transaction_amount",
value: params[:condition_value]
@rule = Current.family.rules.build(resource_type: params[:resource_type] || "transaction", actions: actions) )
],
actions: [
Rule::Action.new(
action_type: params[:action_type] || "set_transaction_category",
value: params[:action_value]
)
]
)
end end
def create def create

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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 `<option value="${operator}">${operator}</option>`;
})
.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));
}
}

View file

@ -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();
}
}

View file

@ -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 has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
validates :condition_type, presence: true validates :condition_type, presence: true
validates :operator, presence: true
validates :value, presence: true, unless: -> { compound? }
accepts_nested_attributes_for :sub_conditions, allow_destroy: true accepts_nested_attributes_for :sub_conditions, allow_destroy: true

View file

@ -2,21 +2,24 @@
<% action = form.object %> <% action = form.object %>
<% rule = action.rule %> <% rule = action.rule %>
<% needs_value = action.executor.type == "select" %>
<li data-rule-target="action"> <li data-controller="rule--actions" data-rule--actions-action-executors-value="<%= rule.action_executors.to_json %>" class="flex items-center gap-3">
<%= form.hidden_field :_destroy, value: false, data: { rule_target: "destroyField" } %> <%= form.hidden_field :_destroy, value: false, data: { rule__actions_target: "destroyField" } %>
<%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule#handleActionTypeChange" } %>
<% if action.executor.type == "select" %> <div class="grow flex gap-2 items-center h-full">
<span>to</span> <%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
<%= form.select :value, action.options, {}, data: { rule_target: "valueField" } %>
<% else %> <%= tag.div class: class_names("flex items-center gap-2", "hidden" => !needs_value),
<%= form.hidden_field :value, data: { rule_target: "valueField" } %> data: { rule__actions_target: "actionValue" } do %>
<% end %> <span class="font-medium uppercase text-xs">to</span>
<%= form.select :value, action.options, {}, disabled: !needs_value %>
<% end %>
</div>
<button type="button" <button type="button"
data-action="rule#removeAction" data-action="rule--actions#remove"
data-rule-destroy-param="<%= action.persisted? %>"> data-rule--actions-destroy-param="<%= action.persisted? %>">
Remove <%= icon("trash-2", color: "gray", size: "sm") %>
</button> </button>
</li> </li>

View file

@ -1,22 +1,34 @@
<%# locals: (form:) %> <%# locals: (form:, show_prefix: true) %>
<% condition = form.object %> <% condition = form.object %>
<% rule = condition.rule %> <% rule = condition.rule %>
<li data-rule-target="condition"> <li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
<%= form.hidden_field :_destroy, value: false, data: { rule_target: "destroyField" } %> <% if form.index.to_i > 0 && show_prefix %>
<%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: "rule#handleConditionTypeChange" } %> <div class="pl-4">
<%= form.select :operator, condition.operators, {}, data: { rule_target: "operatorField" } %> <span class="font-medium uppercase text-xs">and</span>
</div>
<% if condition.filter.type == "select" %>
<%= form.select :value, condition.options, {}, data: { rule_target: "valueField" } %>
<% else %>
<%= form.text_field :value, data: { rule_target: "valueField" } %>
<% end %> <% end %>
<div class="grow flex gap-2 items-center h-full">
<%= form.hidden_field :_destroy, value: false, data: { rule__conditions_target: "destroyField" } %>
<%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: "rule--conditions#handleConditionTypeChange" } %>
<%= form.select :operator, condition.operators, { container_class: "w-fit min-w-16" }, data: { rule__conditions_target: "operatorSelect" } %>
<div data-rule--conditions-target="filterValue">
<% if condition.filter.type == "select" %>
<%= form.select :value, condition.options, {} %>
<% else %>
<%= form.text_field :value, placeholder: "Enter a value" %>
<% end %>
</div>
</div>
<button type="button" <button type="button"
data-action="rule#removeCondition" data-action="rule--conditions#remove"
data-rule-destroy-param="<%= condition.persisted? %>"> data-rule--conditions-destroy-param="<%= condition.persisted? %>">
Remove <%= icon("trash-2", color: "gray", size: "sm") %>
</button> </button>
</li> </li>

View file

@ -1,15 +1,44 @@
<%# locals: (form:) %> <%# locals: (form:) %>
<% condition = form.object %> <% condition = form.object %>
<% rule = condition.rule %>
<li> <li data-controller="rule--conditions element-removal" class="border border-alpha-black-100 rounded-md p-4 space-y-3">
<% if condition.compound? %>
<ul class="border border-gray-200 p-2"> <%= form.hidden_field :condition_type, value: "compound" %>
<%= form.fields_for :sub_conditions do |scf| %>
<%= render "rule/conditions/condition_group", form: scf %> <div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<% unless form.index == 0 %>
<div class="pl-2">
<span class="font-medium uppercase text-xs">and</span>
</div>
<% end %> <% end %>
</ul> <p class="text-sm text-secondary">match</p>
<% else %> <%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
<%= render "rule/conditions/condition", form: form %> <p class="text-sm text-secondary">of the following conditions</p>
<% end %> </div>
<button type="button" data-action="element-removal#remove">
<%= icon("trash-2", color: "gray", size: "sm") %>
</button>
</div>
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
<template data-rule--conditions-target="subConditionTemplate">
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
<%= render "rule/conditions/condition", form: scf %>
<% end %>
</template>
<ul data-rule--conditions-target="subConditionsList" class="space-y-3">
<%= form.fields_for :sub_conditions do |scf| %>
<%= render "rule/conditions/condition", form: scf, show_prefix: false %>
<% end %>
</ul>
<button type="button" class="btn btn--ghost" data-action="rule--conditions#addSubCondition">
<%= icon("plus", color: "gray", size: "sm") %>
<span>Add condition</span>
</button>
</li> </li>

View file

@ -1,11 +1,7 @@
<%# locals: (rule:) %> <%# locals: (rule:) %>
<%= form_with model: rule, <%= styled_form_with model: rule, class: "space-y-4",
class: "p-4 space-y-8 min-w-[600px]", data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
data: {
controller: "rule",
rule_registry_value: rule.registry.to_json
} do |f| %>
<%= f.hidden_field :resource_type, value: rule.resource_type %> <%= f.hidden_field :resource_type, value: rule.resource_type %>
@ -14,37 +10,56 @@
<% end %> <% end %>
<section class="space-y-4"> <section class="space-y-4">
<h3 class="mb-4 font-bold">Conditions</h3> <h3 class="text-sm font-medium">If <%= rule.resource_type %></h3>
<hr>
<template data-rule-target="newConditionTemplate"> <%# Condition template, used by Stimulus controller to add new conditions dynamically %>
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |cf| %> <template data-rules-target="conditionGroupTemplate">
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %> <%= render "rule/conditions/condition_group", form: cf %>
<% end %> <% end %>
</template> </template>
<ul data-rule-target="conditionsList"> <%# Condition template, used by Stimulus controller to add new conditions dynamically %>
<span>When</span> <template data-rules-target="conditionTemplate">
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition", form: cf %>
<% end %>
</template>
<ul data-rules-target="conditionsList" class="space-y-3 mb-4">
<%= f.fields_for :conditions do |cf| %> <%= f.fields_for :conditions do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %> <% if cf.object.compound? %>
<%= render "rule/conditions/condition_group", form: cf %>
<% else %>
<%= render "rule/conditions/condition", form: cf %>
<% end %>
<% end %> <% end %>
</ul> </ul>
<button type="button" data-action="rule#addCondition">Add condition</button> <div class="flex items-center gap-2">
<button type="button" data-action="rules#addCondition" class="btn btn--ghost">
<%= icon("plus") %>
<span>Add condition</span>
</button>
<button type="button" data-action="rules#addConditionGroup" class="btn btn--ghost">
<%= icon("boxes") %>
<span>Add condition group</span>
</button>
</div>
</section> </section>
<section class="space-y-4"> <section class="space-y-4">
<h3 class="mb-4 font-bold">Actions</h3> <h3 class="text-sm font-medium">Then</h3>
<hr>
<template data-rule-target="newActionTemplate"> <%# Action template, used by Stimulus controller to add new actions dynamically %>
<template data-rules-target="actionTemplate">
<%= f.fields_for :actions, Rule::Action.new(rule: rule, action_type: rule.action_executors.first.key), child_index: "IDX_PLACEHOLDER" do |af| %> <%= f.fields_for :actions, Rule::Action.new(rule: rule, action_type: rule.action_executors.first.key), child_index: "IDX_PLACEHOLDER" do |af| %>
<%= render "rule/actions/action", form: af %> <%= render "rule/actions/action", form: af %>
<% end %> <% end %>
</template> </template>
<ul data-rule-target="actionsList"> <ul data-rules-target="actionsList" class="space-y-3">
<%= f.fields_for :actions do |af| %> <%= f.fields_for :actions do |af| %>
<%= render "rule/actions/action", form: af %> <%= render "rule/actions/action", form: af %>
<% end %> <% end %>
@ -52,10 +67,32 @@
<button <button
type="button" type="button"
data-action="rule#addAction"> data-action="rules#addAction"
Add action class="btn btn--ghost">
<%= icon("plus") %>
<span>Add action</span>
</button> </button>
</section> </section>
<section class="space-y-4">
<h3 class="text-sm font-medium">Apply this</h3>
<div class="space-y-2">
<div class="flex items-center gap-2">
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %>
<%= f.label :effective_date_enabled_false, "To all past and future #{rule.resource_type}s" %>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<%= f.radio_button :effective_date_enabled, true, checked: rule.effective_date.present? %>
<%= f.label :effective_date_enabled_true, "Starting from" %>
</div>
<%= f.date_field :effective_date, container_class: "w-fit", data: { rules_target: "effectiveDateInput" } %>
</div>
</div>
</section>
<%= f.submit %> <%= f.submit %>
<% end %> <% end %>

View file

@ -1,7 +1,5 @@
<%= link_to "Back to rules", rules_path %> <%= link_to "Back to rules", rules_path %>
<%= modal do %> <%= modal_form_wrapper title: "Edit #{@rule.resource_type} rule" do %>
<h2>Edit <%= @rule.resource_type %> rule</h2>
<%= render "rules/form", rule: @rule %> <%= render "rules/form", rule: @rule %>
<% end %> <% end %>

View file

@ -1,7 +1,5 @@
<%= link_to "Back to rules", rules_path %> <%= link_to "Back to rules", rules_path %>
<%= modal do %> <%= modal_form_wrapper title: "New #{@rule.resource_type} rule" do %>
<h2>New <%= @rule.resource_type %> rule</h2>
<%= render "rules/form", rule: @rule %> <%= render "rules/form", rule: @rule %>
<% end %> <% end %>

View file

@ -1,6 +1,6 @@
<%# locals: (content:, classes:) -%> <%# locals: (content:, classes:) -%>
<%= turbo_frame_tag "modal" do %> <%= turbo_frame_tag "modal" do %>
<dialog class="focus:outline-none m-auto bg-white shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit overflow-visible <%= classes %>" data-controller="modal" data-action="mousedown->modal#clickOutside"> <dialog class="focus:outline-none m-auto bg-white shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit overflow-auto <%= classes %>" data-controller="modal" data-action="mousedown->modal#clickOutside">
<div class="flex flex-col"> <div class="flex flex-col">
<%= content %> <%= content %>
</div> </div>