diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css index fad493b0..f7244692 100644 --- a/app/assets/tailwind/maybe-design-system/background-utils.css +++ b/app/assets/tailwind/maybe-design-system/background-utils.css @@ -78,6 +78,14 @@ } } +@utility bg-divider { + @apply bg-alpha-black-100; + + @variant theme-dark { + @apply bg-alpha-white-100; + } +} + @utility bg-overlay { background-color: --alpha(var(--color-gray-100) / 50%); @@ -88,4 +96,4 @@ @utility bg-loader { @apply bg-surface-inset animate-pulse; -} \ No newline at end of file +} diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 318ed60d..a63f30ee 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -4,7 +4,14 @@ class RulesController < ApplicationController before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ] def index - @rules = Current.family.rules.order(created_at: :desc) + @sort_by = params[:sort_by] || "name" + @direction = params[:direction] || "asc" + + allowed_columns = [ "name", "updated_at" ] + @sort_by = "name" unless allowed_columns.include?(@sort_by) + @direction = "asc" unless [ "asc", "desc" ].include?(@direction) + + @rules = Current.family.rules.order(@sort_by => @direction) render layout: "settings" end @@ -64,7 +71,7 @@ class RulesController < ApplicationController def rule_params params.require(:rule).permit( - :resource_type, :effective_date, :active, + :resource_type, :effective_date, :active, :name, conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy, sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ] diff --git a/app/javascript/controllers/rule/conditions_controller.js b/app/javascript/controllers/rule/conditions_controller.js index 1cffa119..d0c12941 100644 --- a/app/javascript/controllers/rule/conditions_controller.js +++ b/app/javascript/controllers/rule/conditions_controller.js @@ -21,12 +21,23 @@ export default class extends Controller { } remove(e) { + // Find the parent rules controller before removing the condition + const rulesEl = this.element.closest('[data-controller~="rules"]'); + if (e.params.destroy) { this.destroyFieldTarget.value = true; this.element.classList.add("hidden"); } else { this.element.remove(); } + + // Update the prefixes of all conditions from the parent rules controller + if (rulesEl) { + const rulesController = this.application.getControllerForElementAndIdentifier(rulesEl, "rules"); + if (rulesController && typeof rulesController.updateConditionPrefixes === "function") { + rulesController.updateConditionPrefixes(); + } + } } handleConditionTypeChange(e) { diff --git a/app/javascript/controllers/rules_controller.js b/app/javascript/controllers/rules_controller.js index 0db0e67a..3618acc6 100644 --- a/app/javascript/controllers/rules_controller.js +++ b/app/javascript/controllers/rules_controller.js @@ -11,11 +11,17 @@ export default class extends Controller { "effectiveDateInput", ]; + connect() { + // Update condition prefixes on first connection (form render on edit) + this.updateConditionPrefixes(); + } + addConditionGroup() { this.#appendTemplate( this.conditionGroupTemplateTarget, this.conditionsListTarget, ); + this.updateConditionPrefixes(); } addCondition() { @@ -23,6 +29,7 @@ export default class extends Controller { this.conditionTemplateTarget, this.conditionsListTarget, ); + this.updateConditionPrefixes(); } addAction() { @@ -45,4 +52,27 @@ export default class extends Controller { #uniqueKey() { return Date.now(); } + + // Updates the prefix visibility of all conditions and condition groups + // This is also called by the rule/conditions_controller when a subcondition is removed + updateConditionPrefixes() { + const conditions = Array.from(this.conditionsListTarget.children); + let conditionIndex = 0; + + conditions.forEach((condition) => { + // Only process visible conditions, this prevents conditions that are marked for removal and hidden + // from being added to the index. This is important when editing a rule. + if (!condition.classList.contains('hidden')) { + const prefixEl = condition.querySelector('[data-condition-prefix]'); + if (prefixEl) { + if (conditionIndex === 0) { + prefixEl.classList.add('hidden'); + } else { + prefixEl.classList.remove('hidden'); + } + conditionIndex++; + } + } + }); + } } diff --git a/app/models/rule.rb b/app/models/rule.rb index db8a99ae..b0d405c2 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -8,7 +8,10 @@ class Rule < ApplicationRecord accepts_nested_attributes_for :conditions, allow_destroy: true accepts_nested_attributes_for :actions, allow_destroy: true + before_validation :normalize_name + validates :resource_type, presence: true + validates :name, length: { minimum: 1 }, allow_nil: true validate :no_nested_compound_conditions # Every rule must have at least 1 action @@ -46,6 +49,18 @@ class Rule < ApplicationRecord RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks) end + def primary_condition_title + return "No conditions" if conditions.none? + + first_condition = conditions.first + if first_condition.compound? && first_condition.sub_conditions.any? + first_sub_condition = first_condition.sub_conditions.first + "If #{first_sub_condition.filter.label.downcase} #{first_sub_condition.operator} #{first_sub_condition.value_display}" + else + "If #{first_condition.filter.label.downcase} #{first_condition.operator} #{first_condition.value_display}" + end + end + private def matching_resources_scope scope = registry.resource_scope @@ -87,4 +102,8 @@ class Rule < ApplicationRecord end end end + + def normalize_name + self.name = nil if name.is_a?(String) && name.strip.empty? + end end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index 3316c415..5945503e 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -1,5 +1,5 @@ class Rule::Action < ApplicationRecord - belongs_to :rule + belongs_to :rule, touch: true validates :action_type, presence: true diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index 13b622b5..b10d30ca 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -1,5 +1,5 @@ class Rule::Condition < ApplicationRecord - belongs_to :rule, optional: -> { where.not(parent_id: nil) } + belongs_to :rule, touch: true, optional: -> { where.not(parent_id: nil) } 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 diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb index f81eff1b..0f3ebeac 100644 --- a/app/views/rule/actions/_action.html.erb +++ b/app/views/rule/actions/_action.html.erb @@ -16,7 +16,7 @@ <%# Initial rendering based on rule.action_executors.first from the rule form. %> <%# This is currently always SetTransactionCategory from transaction_resource.rb, which is a select type. %> <%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %> - to + to <%= form.select :value, action.options || [], {} %> <% end %> @@ -29,12 +29,12 @@ <%# Templates for different input types - these will be cloned and used by the Stimulus controller %> - to + to <%= form.select :value, [], {} %> - to + to <%= form.text_field :value, placeholder: "Enter a value" %> diff --git a/app/views/rule/conditions/_condition.html.erb b/app/views/rule/conditions/_condition.html.erb index b79978a1..60c38eab 100644 --- a/app/views/rule/conditions/_condition.html.erb +++ b/app/views/rule/conditions/_condition.html.erb @@ -4,8 +4,11 @@ <% rule = condition.rule %>
match
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>of the following conditions
@@ -21,16 +20,16 @@ <%= icon( "trash-2", - size: "sm", as_button: true, - data: { action: "element-removal#remove" } + size: "sm", + data: { action: "rule--conditions#remove" } ) %>
-
- <% if rule.conditions.first.compound? %>
- If <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>
+
+
+ <% if rule.conditions.first.compound? %>
+ <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>
+ <% else %>
+ <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>
+ <% end %>
+
+ <% if rule.conditions.count > 1 %>
+ and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
+ <% end %>
+
+
+ <% if rule.actions.first.value && rule.actions.first.options %>
+ <%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %>
<% else %>
- If <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>
+ <%= rule.actions.first.executor.label %>
<% end %>
-
- <% if rule.conditions.count > 1 %>
- and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
+ <% if rule.actions.count > 1 %>
+ and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
<% end %>
-
- <% if rule.actions.first.value && rule.actions.first.options %>
- <%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %>
- <% else %>
- <%= rule.actions.first.executor.label %>
- <% end %>
-
-
- <% if rule.actions.count > 1 %>
- and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
- <% end %>
-
- <% if rule.effective_date.nil? %>
- To all past and future <%= rule.resource_type.pluralize %>
- <% else %>
- To all <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date %>
- <% end %>
-
+
+ <% if rule.effective_date.nil? %>
+ All past and future <%= rule.resource_type.pluralize %>
+ <% else %>
+ <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime('%b %-d, %Y') %>
+ <% end %>
+
+
You are about to apply this rule to
diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb
index 6693ac5e..f73edc90 100644
--- a/app/views/rules/edit.html.erb
+++ b/app/views/rules/edit.html.erb
@@ -1,7 +1,15 @@
<%= link_to "Back to rules", rules_path %>
<%= render DialogComponent.new do |dialog| %>
- <% dialog.with_header(title: "Edit #{@rule.resource_type} rule") %>
+ <%
+ title = if @rule.name.present?
+ "Edit #{@rule.resource_type} rule \"#{@rule.name}\""
+ else
+ "Edit #{@rule.resource_type} rule"
+ end
+ %>
+ <% dialog.with_header(title: title) %>
+
<% dialog.with_body do %>
<%= render "rules/form", rule: @rule %>
<% end %>
diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb
index 2c3040a1..d549e0f8 100644
--- a/app/views/rules/index.html.erb
+++ b/app/views/rules/index.html.erb
@@ -1,6 +1,5 @@
Rules
-
Rules
- · -<%= @rules.count %>
+Rules
+ · +<%= @rules.count %>
+