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

Rules form builder and Stimulus controller

This commit is contained in:
Zach Gollwitzer 2025-04-07 17:47:03 -04:00
parent f7f59e0250
commit d10ae248a1
12 changed files with 303 additions and 73 deletions

View file

@ -10,15 +10,24 @@ class RulesController < ApplicationController
end
def new
@rule = Current.family.rules.new(resource_type: params[:resource_type] || "transaction")
@rule = Current.family.rules.build(
resource_type: params[:resource_type] || "transaction",
conditions: [
Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "test")
],
actions: [
Rule::Action.new(action_type: "set_transaction_category", value: Current.family.categories.first.id)
]
)
@template_condition = Rule::Condition.new(rule: @rule, condition_type: "transaction_name")
@template_action = Rule::Action.new(rule: @rule, action_type: "set_transaction_category")
end
def create
puts rule_params.inspect
Current.family.rules.create!(rule_params)
redirect_to rules_path
rescue => e
puts e.inspect
puts e.backtrace
end
def edit

View file

@ -0,0 +1,160 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="rule"
export default class extends Controller {
static values = {
conditionsRegistry: Array,
actionsRegistry: Array,
};
static targets = [
"newConditionTemplate",
"newActionTemplate",
"conditionsList",
"condition",
"actionsList",
"action",
"destroyInput",
];
initialize() {
console.log(this.conditionsRegistryValue);
console.log(this.actionsRegistryValue);
}
addCondition() {
const html = this.newConditionTemplateTarget.innerHTML.replaceAll(
"IDX_PLACEHOLDER",
this.#uniqueKey(),
);
this.conditionsListTarget.insertAdjacentHTML("beforeend", html);
}
handleConditionTypeChange(e) {
const definition = this.conditionsRegistryValue.find((def) => {
return def.condition_type === e.target.value;
});
const conditionEl = this.conditionTargets.find((t) => {
return t.contains(e.target);
});
const operatorSelectEl = conditionEl.querySelector(
"select[data-id='operator-select']",
);
operatorSelectEl.innerHTML = definition.operators
.map((operator) => {
return `<option value="${operator}">${operator}</option>`;
})
.join("");
const valueInputEl = conditionEl.querySelector("[data-id='value-input']");
if (definition.input_type === "select") {
// Select input
const selectEl = document.createElement("select");
// Set data-id, name, id
selectEl.setAttribute("data-id", "value-input");
selectEl.setAttribute("name", valueInputEl.name);
selectEl.setAttribute("id", valueInputEl.id);
// Populate options
definition.options.forEach((option) => {
const optionEl = document.createElement("option");
optionEl.value = option[1];
optionEl.textContent = option[0];
selectEl.appendChild(optionEl);
});
valueInputEl.replaceWith(selectEl);
} else {
// Text input
const inputEl = document.createElement("input");
inputEl.setAttribute("data-id", "value-input");
inputEl.setAttribute("name", valueInputEl.name);
inputEl.setAttribute("id", valueInputEl.id);
inputEl.setAttribute("type", definition.input_type);
valueInputEl.replaceWith(inputEl);
}
}
addAction() {
const html = this.newActionTemplateTarget.innerHTML.replaceAll(
"IDX_PLACEHOLDER",
this.#uniqueKey(),
);
this.actionsListTarget.insertAdjacentHTML("beforeend", html);
}
handleActionTypeChange(e) {
const definition = this.actionsRegistryValue.find((def) => {
return def.action_type === e.target.value;
});
const actionEl = this.actionTargets.find((t) => {
return t.contains(e.target);
});
const valueInputEl = actionEl.querySelector("[data-id='value-input']");
if (definition.input_type === "select") {
const selectEl = document.createElement("select");
selectEl.setAttribute("data-id", "value-input");
selectEl.setAttribute("name", valueInputEl.name);
selectEl.setAttribute("id", valueInputEl.id);
definition.options.forEach((option) => {
const optionEl = document.createElement("option");
optionEl.value = option[1];
optionEl.textContent = option[0];
selectEl.appendChild(optionEl);
});
valueInputEl.replaceWith(selectEl);
} else {
valueInputEl.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();
}
}
#destroyRuleItem(itemEl) {
const destroyInputEl = this.destroyInputTargets.find((el) => {
return itemEl.contains(el);
});
itemEl.classList.add("hidden");
destroyInputEl.value = true;
}
#uniqueKey() {
return `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
}
}

View file

@ -97,6 +97,10 @@ class Rule < ApplicationRecord
end
end
def available_conditions
conditions_registry.options
end
def actions_registry
case resource_type
when "transaction"
@ -106,6 +110,10 @@ class Rule < ApplicationRecord
end
end
def available_actions
actions_registry.options
end
def apply
scope = resource_scope

View file

@ -11,6 +11,10 @@ class Rule::Action < ApplicationRecord
config.builder.call(resource_scope, value)
end
def options
registry.get_config(action_type).options
end
def registry
@registry ||= rule.actions_registry
end

View file

@ -12,8 +12,10 @@ class Rule::Action::TransactionRegistry
def as_json
definitions.map do |action_type, data|
{
input_type: data[:input_type],
label: data[:label],
action_type: action_type
action_type: action_type,
options: data[:options]
}
end
end
@ -25,11 +27,12 @@ class Rule::Action::TransactionRegistry
end
private
ActionConfig = Data.define(:label, :options, :builder)
ActionConfig = Data.define(:input_type, :label, :options, :builder)
def definitions
{
set_transaction_category: {
input_type: "select",
label: "Set category",
options: family.categories.pluck(:name, :id),
builder: ->(transaction_scope, value) {
@ -38,6 +41,7 @@ class Rule::Action::TransactionRegistry
}
},
set_transaction_tags: {
input_type: "select",
label: "Set tags",
options: family.tags.pluck(:name, :id),
builder: ->(transaction_scope, value) {
@ -45,6 +49,7 @@ class Rule::Action::TransactionRegistry
}
},
set_transaction_frequency: {
input_type: "select",
label: "Set frequency",
options: [
[ "One-time", "one_time" ],
@ -55,12 +60,14 @@ class Rule::Action::TransactionRegistry
}
},
ai_enhance_transaction_name: {
input_type: nil,
label: "AI enhance name",
builder: ->(transaction_scope, value) {
# TODO
}
},
ai_categorize_transaction: {
input_type: nil,
label: "AI categorize",
builder: ->(transaction_scope, value) {
# TODO

View file

@ -22,6 +22,10 @@ class Rule::Condition < ApplicationRecord
config.preparer.call(scope)
end
def available_operators
config.operators
end
private
def config
config ||= rule.conditions_registry.get_config(condition_type)

View file

@ -21,6 +21,7 @@ class Rule::Condition::TransactionRegistry
def as_json
definitions.map do |condition_type, data|
{
input_type: data[:input_type],
label: data[:label],
condition_type: condition_type,
operators: data[:operators],
@ -30,11 +31,12 @@ class Rule::Condition::TransactionRegistry
end
private
ConditionConfig = Data.define(:label, :operators, :options, :preparer, :builder)
ConditionConfig = Data.define(:input_type, :label, :operators, :options, :preparer, :builder)
def definitions
{
transaction_name: {
input_type: "text",
label: "Name",
operators: [ "like", "=" ],
options: nil,
@ -45,6 +47,7 @@ class Rule::Condition::TransactionRegistry
}
},
transaction_amount: {
input_type: "number",
label: "Amount",
operators: [ ">", ">=", "<", "<=", "=" ],
options: nil,
@ -55,6 +58,7 @@ class Rule::Condition::TransactionRegistry
}
},
transaction_merchant: {
input_type: "select",
label: "Merchant",
operators: [ "=" ],
options: family.assigned_merchants.pluck(:name, :id),

View file

@ -1,5 +1,13 @@
<%= turbo_frame_tag dom_id(action) do %>
<div>
<%= action.action_type %>
</div>
<% end %>
<%# locals: (action:, form:, options:) %>
<li data-rule-target="action">
<%= form.hidden_field :_destroy, value: false, data: { rule_target: "destroyInput" } %>
<%= form.select :action_type, options, {}, data: { action: "rule#handleActionTypeChange" } %>
<span>to</span>
<%= form.select :value, action.options, {}, data: { id: "value-input" } %>
<button type="button"
data-action="rule#removeAction"
data-rule-destroy-param="<%= form.object.persisted? %>">
Remove
</button>
</li>

View file

@ -1,7 +1,13 @@
<%# locals: (form:) %>
<%# locals: (condition:, form:, options:) %>
<div>
<%= form.select :condition_type, @rule.conditions_registry.options %>
<%= form.select :operator, @rule.operators_for(form.object.condition_type) %>
<%= form.text_field :value %>
</div>
<li data-rule-target="condition">
<%= form.hidden_field :_destroy, value: false, data: { rule_target: "destroyInput" } %>
<%= form.select :condition_type, options, {}, data: { action: "rule#handleConditionTypeChange" } %>
<%= form.select :operator, condition.available_operators, {}, data: { id: "operator-select" } %>
<%= form.text_field :value, data: { id: "value-input" } %>
<button type="button"
data-action="rule#removeCondition"
data-rule-destroy-param="<%= form.object.persisted? %>">
Remove
</button>
</li>

View file

@ -1,27 +1,25 @@
<%# locals: (form:) %>
<%# locals: (condition:, form:, options:) %>
<% if form.object.compound? %>
<div>
<% if form.object.compound? %>
<li>
<% if condition.compound? %>
<% if condition.compound? %>
<p class="mb-3">and</p>
<% end %>
<div class="border border-gray-200 p-2">
<ul class="border border-gray-200 p-2">
<%= form.fields_for :sub_conditions do |scf| %>
<%= render "rule/conditions/condition_group", form: scf %>
<%= render "rule/conditions/condition_group", condition: scf.object, form: scf, options: options %>
<% end %>
</div>
</div>
<% else %>
<div>
<% unless form.index.zero? %>
<% if form.object.parent.present? %>
<span><%= form.object.parent.operator %></span>
</ul>
<% else %>
<% if form.index.is_a?(Integer) && form.index > 0 %>
<% if condition.parent.present? %>
<span><%= condition.parent.operator %></span>
<% else %>
<span>and</span>
<% end %>
<% end %>
<%= render "rule/conditions/condition", form: form %>
</div>
<% end %>
<%= render "rule/conditions/condition", condition: condition, form: form, options: options %>
<% end %>
</li>

View file

@ -1,3 +1,11 @@
<% content_for :page_title, "Rules" %>
<p>Placeholder: rules#index</p>
<div>
<ul>
<% @rules.each do |rule| %>
<li>
<%= rule.id %>
</li>
<% end %>
</ul>
</div>

View file

@ -1,45 +1,59 @@
<%= modal do %>
<div class="p-4 min-w-[600px]">
<%= fields_for Rule.new(resource_type: "transaction", family: Current.family) do |f| %>
<%= f.fields_for :conditions, Rule::Condition.new(condition_type: "transaction_name") do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %>
<% end %>
<% end %>
<div
class="p-4 min-w-[600px]"
data-controller="rule"
data-rule-conditions-registry-value="<%= @rule.conditions_registry.to_json %>"
data-rule-actions-registry-value="<%= @rule.actions_registry.to_json %>"
>
<%= form_with model: @rule, class: "space-y-8" do |f|%>
<h2>New <%= @rule.resource_type %> rule</h2>
<%= form_with model: @rule, class: "space-y-8" do |f|%>
<h2>New <%= @rule.resource_type %> rule</h2>
<%= f.hidden_field :resource_type, value: @rule.resource_type %>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Conditions</h3>
<hr>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Conditions</h3>
<hr>
<ul>
<span>When</span>
<template data-rule-target="newConditionTemplate">
<%= f.fields_for :conditions, @template_condition, child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition_group", condition: cf.object, form: cf, options: @rule.available_conditions %>
<% end %>
</template>
<%= f.fields_for :conditions do |cf| %>
<li>
<%= render "rule/conditions/condition_group", form: cf %>
</li>
<% end %>
</ul>
</section>
<ul data-rule-target="conditionsList">
<span>When</span>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Actions</h3>
<hr>
<%= f.fields_for :conditions do |cf| %>
<%= render "rule/conditions/condition_group", condition: cf.object, form: cf, options: @rule.available_conditions %>
<% end %>
</ul>
<ul>
<%= f.fields_for :actions do |af| %>
<li>
<%= af.select :action_type, @rule.actions_registry.options %>
to
<%= af.text_field :value %>
</li>
<% end %>
</ul>
</section>
<button type="button" data-action="rule#addCondition">Add condition</button>
</section>
<%= f.submit %>
<% end %>
</div>
<% end %>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Actions</h3>
<hr>
<template data-rule-target="newActionTemplate">
<%= f.fields_for :actions, @template_action, child_index: "IDX_PLACEHOLDER" do |af| %>
<%= render "rule/actions/action", action: af.object, form: af, options: @rule.available_actions %>
<% end %>
</template>
<ul data-rule-target="actionsList">
<%= f.fields_for :actions do |af| %>
<%= render "rule/actions/action", action: af.object, form: af, options: @rule.available_actions %>
<% end %>
</ul>
<button
type="button"
data-action="rule#addAction"
>
Add action
</button>
</section>
<%= f.submit %>
<% end %>
</div>