1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Improve rules - add name, allow sorting, improve UI (#2177)

* Add ability to name a rule

* Add sorting by name and date,

* Improve rule page and form design

* Small header tweak

* Improve sorting click areas by including icon

* Fix brakeman

* Use icon helper instead of lucide_icon helper

* Fix double headers with new DialogComponent

* Use updated_at for sorting instead of created_at

* Use copy-plus icon for compound rules

* Remove icons and change IF/THEN/FOR font in edit form

* Use text-secondary on disabled rules

* First pass at redesigning the sorting menu

* New rule list

* Borders instead of shadows

* Apply proper text color to TO in edit form

* Improve dark mode with proper background color classes

* Use border-secondary

* Add touch: true to conditions and actions of a rule, so updated_at works as expected

* Fix db schema

* Change sort direction to be a LinkComponent outside of the form for better sort behavior

* Clean up dropdown design to match figma

* Match tags/categories design

* Fix name text color, add bg-divider background for dividers

* Fix family subscription tests (thanks zach!)
This commit is contained in:
Alex Hatzenbuhler 2025-05-13 14:53:13 -05:00 committed by GitHub
parent 050d5ebaad
commit bebe7b40d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 176 additions and 79 deletions

View file

@ -78,10 +78,18 @@
} }
} }
@utility bg-divider {
@apply bg-alpha-black-100;
@variant theme-dark {
@apply bg-alpha-white-100;
}
}
@utility bg-overlay { @utility bg-overlay {
background-color: --alpha(var(--color-gray-100) / 50%); background-color: --alpha(var(--color-gray-100) / 50%);
@variant theme-dark { @variant theme-dark {
background-color: var(--color-alpha-black-900); background-color: var(--color-alpha-black-900);
} }
} }

View file

@ -4,7 +4,14 @@ class RulesController < ApplicationController
before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ] before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ]
def index 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" render layout: "settings"
end end
@ -64,7 +71,7 @@ class RulesController < ApplicationController
def rule_params def rule_params
params.require(:rule).permit( params.require(:rule).permit(
:resource_type, :effective_date, :active, :resource_type, :effective_date, :active, :name,
conditions_attributes: [ conditions_attributes: [
:id, :condition_type, :operator, :value, :_destroy, :id, :condition_type, :operator, :value, :_destroy,
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ] sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]

View file

@ -8,7 +8,10 @@ class Rule < ApplicationRecord
accepts_nested_attributes_for :conditions, allow_destroy: true accepts_nested_attributes_for :conditions, allow_destroy: true
accepts_nested_attributes_for :actions, allow_destroy: true accepts_nested_attributes_for :actions, allow_destroy: true
before_validation :normalize_name
validates :resource_type, presence: true validates :resource_type, presence: true
validates :name, length: { minimum: 1 }, allow_nil: true
validate :no_nested_compound_conditions validate :no_nested_compound_conditions
# Every rule must have at least 1 action # Every rule must have at least 1 action
@ -99,4 +102,8 @@ class Rule < ApplicationRecord
end end
end end
end end
def normalize_name
self.name = nil if name.is_a?(String) && name.strip.empty?
end
end end

View file

@ -1,5 +1,5 @@
class Rule::Action < ApplicationRecord class Rule::Action < ApplicationRecord
belongs_to :rule belongs_to :rule, touch: true
validates :action_type, presence: true validates :action_type, presence: true

View file

@ -1,5 +1,5 @@
class Rule::Condition < ApplicationRecord 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 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 has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent

View file

@ -16,7 +16,7 @@
<%# Initial rendering based on rule.action_executors.first from the rule form. %> <%# 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. %> <%# 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. %> <%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %>
<span class="font-medium uppercase text-xs">to</span> <span class="font-medium text-primary uppercase text-xs">to</span>
<%= form.select :value, action.options || [], {} %> <%= form.select :value, action.options || [], {} %>
<% end %> <% end %>
</div> </div>
@ -29,12 +29,12 @@
<%# Templates for different input types - these will be cloned and used by the Stimulus controller %> <%# Templates for different input types - these will be cloned and used by the Stimulus controller %>
<template data-rule--actions-target="selectTemplate"> <template data-rule--actions-target="selectTemplate">
<span class="font-medium uppercase text-xs">to</span> <span class="font-medium text-primary uppercase text-xs">to</span>
<%= form.select :value, [], {} %> <%= form.select :value, [], {} %>
</template> </template>
<template data-rule--actions-target="textTemplate"> <template data-rule--actions-target="textTemplate">
<span class="font-medium uppercase text-xs">to</span> <span class="font-medium text-primary uppercase text-xs">to</span>
<%= form.text_field :value, placeholder: "Enter a value" %> <%= form.text_field :value, placeholder: "Enter a value" %>
</template> </template>

View file

@ -1,6 +1,6 @@
<%# locals: (rule:) %> <%# 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| %> data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
<%= f.hidden_field :resource_type, value: rule.resource_type %> <%= f.hidden_field :resource_type, value: rule.resource_type %>
@ -10,7 +10,19 @@
<% end %> <% end %>
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-sm font-medium text-primary">If <%= rule.resource_type %></h3> <div class="flex items-center gap-1 text-secondary">
<%= icon "tag", size: "sm" %>
<h3 class="text-sm font-medium text-primary">Rule name (optional)</h3>
</div>
<div class="ml-6">
<%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center gap-1 text-secondary">
<h3 class="text-sm font-medium text-primary">IF</h3>
</div>
<%# Condition Group template, used by Stimulus controller to add new conditions dynamically %> <%# Condition Group template, used by Stimulus controller to add new conditions dynamically %>
<template data-rules-target="conditionGroupTemplate"> <template data-rules-target="conditionGroupTemplate">
@ -26,24 +38,27 @@
<% end %> <% end %>
</template> </template>
<ul data-rules-target="conditionsList" class="space-y-3 mb-4"> <div class="ml-6 space-y-4">
<%= f.fields_for :conditions do |cf| %> <ul data-rules-target="conditionsList" class="space-y-3">
<% if cf.object.compound? %> <%= f.fields_for :conditions do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %> <% if cf.object.compound? %>
<% else %> <%= render "rule/conditions/condition_group", form: cf %>
<%= render "rule/conditions/condition", form: cf %> <% else %>
<%= render "rule/conditions/condition", form: cf %>
<% end %>
<% end %> <% end %>
<% end %> </ul>
</ul>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> <%= 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" }) %>
</div> </div>
</section> </section>
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-sm font-medium text-primary">Then</h3> <div class="flex items-center gap-1 text-secondary">
<h3 class="text-sm font-medium text-primary">THEN</h3>
</div>
<%# Action template, used by Stimulus controller to add new actions dynamically %> <%# Action template, used by Stimulus controller to add new actions dynamically %>
<template data-rules-target="actionTemplate"> <template data-rules-target="actionTemplate">
@ -52,22 +67,25 @@
<% end %> <% end %>
</template> </template>
<ul data-rules-target="actionsList" class="space-y-3"> <div class="ml-6 space-y-4">
<%= f.fields_for :actions do |af| %> <ul data-rules-target="actionsList" class="space-y-3">
<%= render "rule/actions/action", form: af %> <%= f.fields_for :actions do |af| %>
<% end %> <%= render "rule/actions/action", form: af %>
</ul> <% end %>
</ul>
<%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %> <%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
</section> </section>
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-sm font-medium text-primary">Apply this</h3> <div class="flex items-center gap-1 text-secondary">
<h3 class="text-sm font-medium text-primary">FOR</h3>
</div>
<div class="space-y-2"> <div class="ml-6 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %> <%= 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" %>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View file

@ -1,47 +1,64 @@
<%# locals: (rule:) %> <%# locals: (rule:) %>
<div class="flex justify-between items-center p-4 <%= rule.active? ? 'text-primary' : 'text-secondary' %>">
<div class="flex justify-between items-center gap-4 bg-white shadow-border-xs rounded-md p-4">
<div class="text-sm space-y-1.5"> <div class="text-sm space-y-1.5">
<% if rule.name.present? %>
<h3 class="font-medium text-md"><%= rule.name %></h3>
<% end %>
<% if rule.conditions.any? %> <% if rule.conditions.any? %>
<p class="flex items-center flex-wrap gap-1.5"> <div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
<span class="font-mono text-xs">IF</span>
</div>
<p class="flex items-center flex-wrap gap-1.5 m-0">
<span class="px-2 py-1 border border-secondary rounded-full">
<% 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 %>
</span>
<% if rule.conditions.count > 1 %>
and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
<% end %>
</p>
</div>
<% end %>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
<span class="font-mono text-xs">THEN</span>
</div>
<p class="flex items-center flex-wrap gap-1.5 m-0">
<span class="px-2 py-1 border border-secondary rounded-full"> <span class="px-2 py-1 border border-secondary rounded-full">
<%= 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 %>
</span> </span>
<% if rule.actions.count > 1 %>
<% if rule.conditions.count > 1 %> and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
<% end %> <% end %>
</p> </p>
<% end %> </div>
<div class="flex items-center gap-2 mt-1">
<p class="flex items-center flex-wrap gap-1.5"> <div class="flex items-center gap-1 text-secondary w-16 shrink-0">
<span class="px-2 py-1 border border-alpha-black-200 rounded-full"> <span class="font-mono text-xs">FOR</span>
<% if rule.actions.first.value && rule.actions.first.options %> </div>
<%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %> <p class="flex items-center flex-wrap gap-1.5 m-0">
<% else %> <span class="px-2 py-1 border border-secondary rounded-full">
<%= rule.actions.first.executor.label %> <% if rule.effective_date.nil? %>
<% end %> All past and future <%= rule.resource_type.pluralize %>
</span> <% else %>
<%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime('%b %-d, %Y') %>
<% if rule.actions.count > 1 %> <% end %>
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %> </span>
<% end %> </p>
</p> </div>
<p class="flex items-center flex-wrap gap-1.5">
<% 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 %>
</p>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %> <%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %> <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
<% end %> <% end %>
<%= render MenuComponent.new do |menu| %> <%= 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: "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" }) %> <% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>

View file

@ -1,6 +1,13 @@
<%= render DialogComponent.new(reload_on_close: true) do |dialog| %> <%= 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 %> <% dialog.with_body do %>
<p class="text-secondary text-sm mb-4"> <p class="text-secondary text-sm mb-4">
You are about to apply this rule to You are about to apply this rule to

View file

@ -1,7 +1,15 @@
<%= link_to "Back to rules", rules_path %> <%= link_to "Back to rules", rules_path %>
<%= render DialogComponent.new do |dialog| %> <%= 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 %> <% dialog.with_body do %>
<%= render "rules/form", rule: @rule %> <%= render "rules/form", rule: @rule %>
<% end %> <% end %>

View file

@ -1,6 +1,5 @@
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">Rules</h1> <h1 class="text-primary text-xl font-medium">Rules</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<% if @rules.any? %> <% if @rules.any? %>
<%= render MenuComponent.new do |menu| %> <%= render MenuComponent.new do |menu| %>
@ -13,7 +12,6 @@
confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %> confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %>
<% end %> <% end %>
<% end %> <% end %>
<%= render LinkComponent.new( <%= render LinkComponent.new(
text: "New rule", text: "New rule",
variant: "primary", variant: "primary",
@ -23,7 +21,6 @@
) %> ) %>
</div> </div>
</header> </header>
<% if self_hosted? %> <% if self_hosted? %>
<div class="flex items-center gap-2 mb-2 py-4"> <div class="flex items-center gap-2 mb-2 py-4">
<%= icon("circle-alert", size: "sm") %> <%= icon("circle-alert", size: "sm") %>
@ -32,19 +29,43 @@
</p> </p>
</div> </div>
<% end %> <% end %>
<div class="bg-container shadow-border-xs rounded-xl p-4"> <div class="bg-container shadow-border-xs rounded-xl p-4">
<% if @rules.any? %> <% if @rules.any? %>
<div class="rounded-xl bg-gray-25 space-y-1"> <div class="bg-container-inset rounded-xl">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase"> <div class="flex justify-between px-4 py-2 text-xs uppercase">
<p>Rules</p> <div class="flex items-center gap-1.5 font-medium text-secondary">
<span class="text-subdued">&middot;</span> <p>Rules</p>
<p><%= @rules.count %></p> <span class="text-subdued">&middot;</span>
<p><%= @rules.count %></p>
</div>
<div class="flex items-center gap-1">
<span class="text-secondary">Sort by:</span>
<%= 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"
) %>
</div>
</div> </div>
<div class="p-1">
<div class="space-y-1 p-1"> <div class="flex flex-col bg-container rounded-xl shadow-border-xs first_child:rounded-t-xl last_child:rounded-b-xl">
<%= render @rules %> <% @rules.each_with_index do |rule, idx| %>
<%= render "rule", rule: rule%>
<% unless idx == @rules.size - 1 %>
<div class="h-px bg-divider ml-4 mr-6"></div>
<% end %>
<% end %>
</div>
</div> </div>
</div> </div>
<% else %> <% else %>

View file

@ -0,0 +1,5 @@
class AddNameToRules < ActiveRecord::Migration[7.2]
def change
add_column :rules, :name, :string
end
end

1
db/schema.rb generated
View file

@ -498,6 +498,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
t.boolean "active", default: false, null: false t.boolean "active", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "name"
t.index ["family_id"], name: "index_rules_on_family_id" t.index ["family_id"], name: "index_rules_on_family_id"
end end

View file

@ -32,8 +32,6 @@ class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
end end
test "users who have already trialed cannot create a new subscription" do test "users who have already trialed cannot create a new subscription" do
@family.start_trial_subscription!
assert_no_difference "Subscription.count" do assert_no_difference "Subscription.count" do
post subscription_path post subscription_path
end end

View file

@ -2,7 +2,7 @@ require "test_helper"
class SubscriptionTest < ActiveSupport::TestCase class SubscriptionTest < ActiveSupport::TestCase
setup do setup do
@family = families(:empty) @family = Family.create!(name: "Test Family")
end end
test "can create subscription without stripe details if trial" do test "can create subscription without stripe details if trial" do