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

Rule form with compound conditions and tests

This commit is contained in:
Zach Gollwitzer 2025-04-04 11:24:42 -04:00
parent af1fa49974
commit 150a95996a
17 changed files with 402 additions and 5 deletions

View file

@ -10,18 +10,29 @@ class RulesController < ApplicationController
end
def new
@rule = Current.family.rules.new(resource_type: params[:resource_type] || "transaction")
end
def create
Current.family.rules.create!(rule_params)
redirect_to rules_path
rescue => e
puts e.inspect
puts e.backtrace
end
def edit
@rule = Current.family.rules.find(params[:id])
end
def update
@rule.update!(rule_params)
redirect_to rules_path
end
def destroy
@rule.destroy
redirect_to rules_path
end
private
@ -31,6 +42,15 @@ class RulesController < ApplicationController
end
def rule_params
params.require(:rule).permit(:effective_date, :active)
params.require(:rule).permit(
:resource_type, :effective_date, :active,
conditions_attributes: [
:id, :condition_type, :operator, :value,
sub_conditions_attributes: [ :id, :condition_type, :operator, :value ]
],
actions_attributes: [
:id, :action_type, :value
]
)
end
end

View file

@ -5,7 +5,88 @@ class Rule < ApplicationRecord
has_many :conditions, dependent: :destroy
has_many :actions, dependent: :destroy
accepts_nested_attributes_for :conditions, allow_destroy: true
accepts_nested_attributes_for :actions, allow_destroy: true
validates :resource_type, presence: true
validate :no_nested_compound_conditions
class << self
# def transaction_template
# new(
# resource_type: "transaction",
# conditions: [
# Condition.new(
# condition_type: "transaction_name",
# operator: "=",
# value: nil
# )
# ]
# )
# end
def transaction_template
new(
resource_type: "transaction",
conditions: [
Condition.new(
condition_type: "transaction_name",
operator: "=",
value: nil
),
Condition.new(
condition_type: "compound",
operator: "or",
value: nil,
sub_conditions: [
Condition.new(
condition_type: "transaction_name",
operator: "like",
value: nil
),
Condition.new(
condition_type: "transaction_name",
operator: "like",
value: nil
),
Condition.new(
condition_type: "compound",
operator: "and",
value: nil,
sub_conditions: [
Condition.new(
condition_type: "transaction_amount",
operator: ">",
value: nil
),
Condition.new(
condition_type: "transaction_amount",
operator: "<",
value: nil
)
]
)
]
),
Condition.new(
condition_type: "transaction_name",
operator: "=",
value: nil
)
],
actions: [
Action.new(
action_type: "set_category",
value: nil
)
]
)
end
end
def operators_for(condition_type)
conditions_registry.get_config(condition_type).operators
end
def conditions_registry
case resource_type
@ -62,4 +143,16 @@ class Rule < ApplicationRecord
raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}"
end
end
def no_nested_compound_conditions
return true if conditions.none? { |condition| condition.compound? }
conditions.each do |condition|
if condition.compound?
if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? }
errors.add(:base, "Compound conditions cannot be nested")
end
end
end
end
end

View file

@ -18,6 +18,12 @@ class Rule::Action::TransactionRegistry
end
end
def options
definitions.map do |action_type, data|
[ data[:label], action_type ]
end
end
private
ActionConfig = Data.define(:label, :options, :builder)

View file

@ -4,6 +4,8 @@ module Rule::Condition::Compoundable
included do
belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions
has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
accepts_nested_attributes_for :sub_conditions, allow_destroy: true
end
# We don't store rule_id on sub_conditions, so "walk up" to the parent rule

View file

@ -12,6 +12,12 @@ class Rule::Condition::TransactionRegistry
ConditionConfig.new(**config)
end
def options
definitions.map do |condition_type, data|
[ data[:label], condition_type ]
end
end
def as_json
definitions.map do |condition_type, data|
{

View file

@ -0,0 +1,5 @@
<%= turbo_frame_tag dom_id(action) do %>
<div>
<%= action.action_type %>
</div>
<% end %>

View file

@ -0,0 +1,7 @@
<%# locals: (form:) %>
<div>
<%= form.select :condition_type, @rule.conditions_registry.options %>
<%= form.select :operator, @rule.operators_for(form.object.condition_type) %>
<%= form.text_field :value %>
</div>

View file

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

View file

@ -0,0 +1,7 @@
<%= turbo_frame_tag "new_condition" do %>
<%= fields_for @rule do |f| %>
<%= f.fields_for :conditions do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %>
<% end %>
<% end %>
<% end %>

View file

@ -1 +1,45 @@
<p>Placeholder: rules#new</p>
<%= 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 %>
<%= form_with model: @rule, class: "space-y-8" do |f|%>
<h2>New <%= @rule.resource_type %> rule</h2>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Conditions</h3>
<hr>
<ul>
<span>When</span>
<%= f.fields_for :conditions do |cf| %>
<li>
<%= render "rule/conditions/condition_group", form: cf %>
</li>
<% end %>
</ul>
</section>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Actions</h3>
<hr>
<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>
<%= f.submit %>
<% end %>
</div>
<% end %>

View file

@ -1,6 +1,6 @@
<%# locals: (content:, classes:) -%>
<%= turbo_frame_tag "modal" do %>
<dialog class="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-visible <%= classes %>" data-controller="modal" data-action="mousedown->modal#clickOutside">
<div class="flex flex-col">
<%= content %>
</div>

View file

@ -144,8 +144,8 @@ Rails.application.routes.draw do
end
resources :rules do
resources :triggers, only: %i[create update destroy]
resources :actions, only: %i[create update destroy]
resources :triggers, only: :new
resources :actions, only: :new
end
# Convenience routes for polymorphic paths

View file

@ -0,0 +1,152 @@
require "test_helper"
class RulesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
end
test "should get new" do
get new_rule_url(resource_type: "transaction")
assert_response :success
end
test "should get edit" do
get edit_rule_url(rules(:one))
assert_response :success
end
# "Set all transactions with a name like 'starbucks' and an amount between 20 and 40 to the 'food and drink' category"
test "creates rule with nested conditions" do
post rules_url, params: {
rule: {
active: true,
effective_date: 30.days.ago.to_date,
resource_type: "transaction",
conditions_attributes: [
{
condition_type: "transaction_name",
operator: "like",
value: "starbucks"
},
{
condition_type: "compound",
operator: "and",
sub_conditions_attributes: [
{
condition_type: "transaction_amount",
operator: ">",
value: 20
},
{
condition_type: "transaction_amount",
operator: "<",
value: 40
}
]
}
],
actions_attributes: [
{
action_type: "set_transaction_category",
value: categories(:food_and_drink).id
}
]
}
}
rule = @user.family.rules.order("created_at DESC").first
# Rule
assert_equal "transaction", rule.resource_type
assert rule.active
assert_equal 30.days.ago.to_date, rule.effective_date
# Conditions assertions
assert_equal 2, rule.conditions.count
compound_condition = rule.conditions.find { |condition| condition.condition_type == "compound" }
assert_equal "compound", compound_condition.condition_type
assert_equal 2, compound_condition.sub_conditions.count
# Actions assertions
assert_equal 1, rule.actions.count
assert_equal "set_transaction_category", rule.actions.first.action_type
assert_equal categories(:food_and_drink).id, rule.actions.first.value
end
test "can update rule" do
rule = rules(:one)
assert_no_difference [ "Rule.count", "Rule::Condition.count", "Rule::Action.count" ] do
patch rule_url(rule), params: {
rule: {
active: false,
conditions_attributes: [
{
id: rule.conditions.first.id,
value: "new_value"
}
],
actions_attributes: [
{
id: rule.actions.first.id,
value: "new_value"
}
]
}
}
end
rule.reload
assert_not rule.active
assert_equal "new_value", rule.conditions.first.value
assert_equal "new_value", rule.actions.first.value
assert_redirected_to rules_url
end
test "can destroy conditions and actions while editing" do
rule = rules(:one)
assert_equal 1, rule.conditions.count
assert_equal 1, rule.actions.count
patch rule_url(rule), params: {
rule: {
conditions_attributes: [
{ id: rule.conditions.first.id, _destroy: true },
{
condition_type: "transaction_name",
operator: "like",
value: "new_condition"
},
{
condition_type: "transaction_amount",
operator: ">",
value: 100
}
],
actions_attributes: [
{ id: rule.actions.first.id, _destroy: true }
]
}
}
assert_redirected_to rules_url
rule.reload
assert_equal 2, rule.conditions.count
assert_equal 1, rule.actions.count
end
test "can destroy rule" do
rule = rules(:one)
assert_difference [ "Rule.count", "Rule::Condition.count", "Rule::Action.count" ], -1 do
delete rule_url(rule)
end
assert_redirected_to rules_url
end
end

4
test/fixtures/rule/actions.yml vendored Normal file
View file

@ -0,0 +1,4 @@
one:
rule: one
action_type: set_transaction_category
value: "some_category_id"

5
test/fixtures/rule/conditions.yml vendored Normal file
View file

@ -0,0 +1,5 @@
one:
rule: one
condition_type: transaction_name
operator: like
value: "starbucks"

View file

@ -54,4 +54,23 @@ class RuleTest < ActiveSupport::TestCase
assert_nil transaction_entry1.account_transaction.category
assert_equal @groceries_category, transaction_entry2.account_transaction.category
end
# Artificial limitation put in place to prevent users from creating overly complex rules
# Rules should be shallow and wide
test "no nested compound conditions" do
rule = Rule.new(
family: @family,
resource_type: "transaction",
conditions: [
Rule::Condition.new(condition_type: "compound", operator: "and", sub_conditions: [
Rule::Condition.new(condition_type: "compound", operator: "and", sub_conditions: [
Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Starbucks")
])
])
]
)
assert_not rule.valid?
assert_equal [ "Compound conditions cannot be nested" ], rule.errors.full_messages
end
end