diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css index 1c7bc56a..fd5f673f 100644 --- a/app/assets/tailwind/maybe-design-system/background-utils.css +++ b/app/assets/tailwind/maybe-design-system/background-utils.css @@ -78,10 +78,18 @@ } } +@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%); @variant theme-dark { background-color: var(--color-alpha-black-900); } -} \ 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/models/rule.rb b/app/models/rule.rb index ec15d64b..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 @@ -99,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 %> diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index cdee4783..387423f2 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -1,6 +1,6 @@ <%# locals: (rule:) %> -<%= styled_form_with model: rule, class: "space-y-4", +<%= styled_form_with model: rule, class: "space-y-6", data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %> <%= f.hidden_field :resource_type, value: rule.resource_type %> @@ -10,7 +10,19 @@ <% end %>
-

If <%= rule.resource_type %>

+
+ <%= icon "tag", size: "sm" %> +

Rule name (optional)

+
+
+ <%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %> +
+
+ +
+
+

IF

+
<%# Condition Group template, used by Stimulus controller to add new conditions dynamically %> -
<%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> - <%= render ButtonComponent.new(text: "Add condition group", icon: "boxes", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %> + <%= render ButtonComponent.new(text: "Add condition group", icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
-

Then

+
+

THEN

+
<%# Action template, used by Stimulus controller to add new actions dynamically %> - +
+ <%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
-

Apply this

+
+

FOR

+
-
+
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %> - <%= f.label :effective_date_enabled_false, "To all past and future #{rule.resource_type}s", class: "text-sm text-primary" %> + <%= f.label :effective_date_enabled_false, "All past and future #{rule.resource_type}s", class: "text-sm text-primary" %>
diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index 5ef51440..9307eb80 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -1,47 +1,64 @@ <%# locals: (rule:) %> - -
+
+ <% if rule.name.present? %> +

<%= rule.name %>

+ <% end %> <% if rule.conditions.any? %> -

+

+
+ IF +
+

+ + <% 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 %> +

+
+ <% end %> +
+
+ THEN +
+

- <%= rule.primary_condition_title %> + <% 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.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 %>

- <% 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 %> -

+
+
+
+ FOR +
+

+ + <% 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 %> + +

+
-
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %> <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %> <% end %> - <%= render MenuComponent.new do |menu| %> <% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %> <% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %> diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index 987a7fa9..28be9496 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -1,6 +1,13 @@ <%= render DialogComponent.new(reload_on_close: true) do |dialog| %> - <% dialog.with_header(title: "Confirm changes") %> - + <% + title = if @rule.name.present? + "Confirm changes to \"#{@rule.name}\"" + else + "Confirm changes" + end + %> + <% dialog.with_header(title: title) %> + <% dialog.with_body do %>

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

-
<% if @rules.any? %> <%= render MenuComponent.new do |menu| %> @@ -13,7 +12,6 @@ confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %> <% end %> <% end %> - <%= render LinkComponent.new( text: "New rule", variant: "primary", @@ -23,7 +21,6 @@ ) %>
- <% if self_hosted? %>
<%= icon("circle-alert", size: "sm") %> @@ -32,19 +29,43 @@

<% end %> -
- <% if @rules.any? %> -
-
-

Rules

- · -

<%= @rules.count %>

+
+
+
+

Rules

+ · +

<%= @rules.count %>

+
+
+ Sort by: + <%= form_with url: rules_path, method: :get, local: true, class: "flex items-center", data: { controller: "auto-submit-form" } do |form| %> + <%= form.select :sort_by, + options_for_select([["Name", "name"], ["Updated At", "updated_at"]], @sort_by), + {}, + class: "min-w-[120px] bg-transparent rounded border-none cursor-pointer text-primary uppercase text-xs w-auto", + data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %> + <%= form.hidden_field :direction, value: @direction %> + <% end %> + <%= render LinkComponent.new( + href: rules_path(direction: @direction == "asc" ? "desc" : "asc", sort_by: @sort_by), + variant: "icon", + icon: "arrow-up-down", + size: :sm, + title: "Toggle sort direction" + ) %> +
- -
- <%= render @rules %> +
+
+ <% @rules.each_with_index do |rule, idx| %> + <%= render "rule", rule: rule%> + <% unless idx == @rules.size - 1 %> +
+ <% end %> + <% end %> +
<% else %> diff --git a/db/migrate/20250429021255_add_name_to_rules.rb b/db/migrate/20250429021255_add_name_to_rules.rb new file mode 100644 index 00000000..f69e1140 --- /dev/null +++ b/db/migrate/20250429021255_add_name_to_rules.rb @@ -0,0 +1,5 @@ +class AddNameToRules < ActiveRecord::Migration[7.2] + def change + add_column :rules, :name, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index c9ba49e5..9cfb546c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -498,6 +498,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do t.boolean "active", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" t.index ["family_id"], name: "index_rules_on_family_id" end diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb index 1e791632..0b406dca 100644 --- a/test/controllers/subscriptions_controller_test.rb +++ b/test/controllers/subscriptions_controller_test.rb @@ -32,8 +32,6 @@ class SubscriptionsControllerTest < ActionDispatch::IntegrationTest end test "users who have already trialed cannot create a new subscription" do - @family.start_trial_subscription! - assert_no_difference "Subscription.count" do post subscription_path end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb index 3986335c..389aaaf0 100644 --- a/test/models/subscription_test.rb +++ b/test/models/subscription_test.rb @@ -2,7 +2,7 @@ require "test_helper" class SubscriptionTest < ActiveSupport::TestCase setup do - @family = families(:empty) + @family = Family.create!(name: "Test Family") end test "can create subscription without stripe details if trial" do