diff --git a/app/javascript/controllers/rule/conditions_controller.js b/app/javascript/controllers/rule/conditions_controller.js index 1a20d00da..2999ca0d9 100644 --- a/app/javascript/controllers/rule/conditions_controller.js +++ b/app/javascript/controllers/rule/conditions_controller.js @@ -11,6 +11,11 @@ export default class extends Controller { "subConditionsList", ]; + connect() { + // Hide value field on initial load if operator is "is_null" + this.#toggleValueFieldVisibility(); + } + addSubCondition() { const html = this.subConditionTemplateTarget.innerHTML.replaceAll( "IDX_CHILD_PLACEHOLDER", @@ -52,6 +57,11 @@ export default class extends Controller { } this.#updateOperatorsField(conditionFilter); + this.#toggleValueFieldVisibility(); + } + + handleOperatorChange(e) { + this.#toggleValueFieldVisibility(); } get valueInputEl() { @@ -112,4 +122,18 @@ export default class extends Controller { #uniqueKey() { return Date.now(); } + + #toggleValueFieldVisibility() { + const operator = this.operatorSelectTarget.value; + + if (operator === "is_null") { + this.filterValueTarget.classList.add("hidden"); + // Clear the value since it's not needed + if (this.valueInputEl) { + this.valueInputEl.value = ""; + } + } else { + this.filterValueTarget.classList.remove("hidden"); + } + } } diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index b10d30ca8..ab5138cba 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -6,7 +6,7 @@ class Rule::Condition < ApplicationRecord validates :condition_type, presence: true validates :operator, presence: true - validates :value, presence: true, unless: -> { compound? } + validates :value, presence: true, unless: -> { compound? || operator == "is_null" } accepts_nested_attributes_for :sub_conditions, allow_destroy: true diff --git a/app/models/rule/condition_filter.rb b/app/models/rule/condition_filter.rb index 86d40ea20..6fa463a50 100644 --- a/app/models/rule/condition_filter.rb +++ b/app/models/rule/condition_filter.rb @@ -4,9 +4,9 @@ class Rule::ConditionFilter TYPES = [ "text", "number", "select" ] OPERATORS_MAP = { - "text" => [ [ "Contains", "like" ], [ "Equal to", "=" ] ], + "text" => [ [ "Contains", "like" ], [ "Equal to", "=" ], [ "Is empty", "is_null" ] ], "number" => [ [ "Greater than", ">" ], [ "Greater or equal to", ">=" ], [ "Less than", "<" ], [ "Less than or equal to", "<=" ], [ "Is equal to", "=" ] ], - "select" => [ [ "Equal to", "=" ] ] + "select" => [ [ "Equal to", "=" ], [ "Is empty", "is_null" ] ] } def initialize(rule) @@ -67,19 +67,28 @@ class Rule::ConditionFilter end def build_sanitized_where_condition(field, operator, value) - sanitized_value = operator == "like" ? "%#{ActiveRecord::Base.sanitize_sql_like(value)}%" : value + if operator == "is_null" + ActiveRecord::Base.sanitize_sql_for_conditions( + "#{field} #{sanitize_operator(operator)}" + ) + else + sanitized_value = operator == "like" ? "%#{ActiveRecord::Base.sanitize_sql_like(value)}%" : value - ActiveRecord::Base.sanitize_sql_for_conditions([ - "#{field} #{sanitize_operator(operator)} ?", - sanitized_value - ]) + ActiveRecord::Base.sanitize_sql_for_conditions([ + "#{field} #{sanitize_operator(operator)} ?", + sanitized_value + ]) + end end def sanitize_operator(operator) raise UnsupportedOperatorError, "Unsupported operator: #{operator} for type: #{type}" unless operators.map(&:last).include?(operator) - if operator == "like" + case operator + when "like" "ILIKE" + when "is_null" + "IS NULL" else operator end diff --git a/app/models/rule/condition_filter/transaction_category.rb b/app/models/rule/condition_filter/transaction_category.rb new file mode 100644 index 000000000..71f87bbdf --- /dev/null +++ b/app/models/rule/condition_filter/transaction_category.rb @@ -0,0 +1,18 @@ +class Rule::ConditionFilter::TransactionCategory < Rule::ConditionFilter + def type + "select" + end + + def options + family.categories.alphabetically.pluck(:name, :id) + end + + def prepare(scope) + scope.left_joins(:category) + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("categories.id", operator, value) + scope.where(expression) + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index d00f2b1ea..115a747fd 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -7,7 +7,8 @@ class Rule::Registry::TransactionResource < Rule::Registry [ Rule::ConditionFilter::TransactionName.new(rule), Rule::ConditionFilter::TransactionAmount.new(rule), - Rule::ConditionFilter::TransactionMerchant.new(rule) + Rule::ConditionFilter::TransactionMerchant.new(rule), + Rule::ConditionFilter::TransactionCategory.new(rule) ] end diff --git a/app/views/rule/conditions/_condition.html.erb b/app/views/rule/conditions/_condition.html.erb index 60c38eab2..84eff713d 100644 --- a/app/views/rule/conditions/_condition.html.erb +++ b/app/views/rule/conditions/_condition.html.erb @@ -20,7 +20,7 @@ <%= 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-36" }, data: { rule__conditions_target: "operatorSelect" } %> + <%= form.select :operator, condition.operators, { container_class: "w-fit min-w-36" }, data: { rule__conditions_target: "operatorSelect", action: "rule--conditions#handleOperatorChange" } %>
<% if condition.filter.type == "select" %> diff --git a/test/models/rule/condition_test.rb b/test/models/rule/condition_test.rb index 3010673d9..2fbfb375d 100644 --- a/test/models/rule/condition_test.rb +++ b/test/models/rule/condition_test.rb @@ -125,4 +125,61 @@ class Rule::ConditionTest < ActiveSupport::TestCase filtered = parent_condition.apply(scope) assert_equal 2, filtered.count end + + test "applies transaction_category condition" do + scope = @rule_scope + + # Set category for one transaction + @account.transactions.first.update!(category: @grocery_category) + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_category", + operator: "=", + value: @grocery_category.id + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + assert_equal 1, filtered.count + assert_equal @grocery_category.id, filtered.first.category_id + end + + test "applies is_null condition for transaction_category" do + scope = @rule_scope + + # Set category for one transaction + @account.transactions.first.update!(category: @grocery_category) + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_category", + operator: "is_null", + value: nil + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + assert_equal 4, filtered.count + assert filtered.all? { |t| t.category_id.nil? } + end + + test "applies is_null condition for transaction_merchant" do + scope = @rule_scope + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_merchant", + operator: "is_null", + value: nil + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + assert_equal 3, filtered.count + assert filtered.all? { |t| t.merchant_id.nil? } + end end