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:
parent
af1fa49974
commit
150a95996a
17 changed files with 402 additions and 5 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
{
|
||||
|
|
5
app/views/rule/actions/_action.html.erb
Normal file
5
app/views/rule/actions/_action.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<%= turbo_frame_tag dom_id(action) do %>
|
||||
<div>
|
||||
<%= action.action_type %>
|
||||
</div>
|
||||
<% end %>
|
7
app/views/rule/conditions/_condition.html.erb
Normal file
7
app/views/rule/conditions/_condition.html.erb
Normal 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>
|
27
app/views/rule/conditions/_condition_group.html.erb
Normal file
27
app/views/rule/conditions/_condition_group.html.erb
Normal 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 %>
|
7
app/views/rule/conditions/new.html.erb
Normal file
7
app/views/rule/conditions/new.html.erb
Normal 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 %>
|
|
@ -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 %>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
152
test/controllers/rules_controller_test.rb
Normal file
152
test/controllers/rules_controller_test.rb
Normal 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
4
test/fixtures/rule/actions.yml
vendored
Normal 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
5
test/fixtures/rule/conditions.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
one:
|
||||
rule: one
|
||||
condition_type: transaction_name
|
||||
operator: like
|
||||
value: "starbucks"
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue