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:
parent
f7f59e0250
commit
d10ae248a1
12 changed files with 303 additions and 73 deletions
|
@ -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
|
||||
|
|
160
app/javascript/controllers/rule_controller.js
Normal file
160
app/javascript/controllers/rule_controller.js
Normal 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)}`;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Add table
Add a link
Reference in a new issue