1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

CTA message for rule when user changes transaction category

This commit is contained in:
Zach Gollwitzer 2025-04-08 21:47:08 -04:00
parent fe8008e5ed
commit c5ef622849
24 changed files with 265 additions and 158 deletions

View file

@ -3,6 +3,47 @@ class Account::TransactionsController < ApplicationController
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
def update
if @entry.update(update_entry_params)
@entry.sync_account_later
if @entry.account_transaction.saved_change_to_category_id? && @entry.account_transaction.eligible_for_category_rule?
flash[:cta] = {
message: "Updated to #{@entry.account_transaction.category.name}",
description: "You can create a rule to automatically categorize transactions like this one",
accept_label: "Create rule",
accept_href: new_rule_path(resource_type: "transaction"),
accept_turbo_frame: "modal",
decline_label: "Dismiss"
}
else
flash[:notice] = "Transaction updated"
end
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
format.turbo_stream do
items = [
turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "account/transactions/header",
locals: { entry: @entry }
),
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
]
if flash[:cta].present?
items << turbo_stream.replace("cta", partial: "shared/notifications/cta", locals: { cta: flash[:cta] })
end
render turbo_stream: items
end
end
else
render :show, status: :unprocessable_entity
end
end
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)

View file

@ -8,6 +8,11 @@ class ApplicationController < ActionController::Base
before_action :set_default_chat
private
def stream_redirect_back_or_to(path)
redirect_target_url = request.referer || path
render turbo_stream: turbo_stream.action(:redirect, redirect_target_url)
end
def require_upgrade?
return false if self_hosted?
return false unless Current.session

View file

@ -2,6 +2,8 @@ module EntryableResource
extend ActiveSupport::Concern
included do
include StreamExtensions
before_action :set_entry, only: %i[show update destroy]
end
@ -35,9 +37,7 @@ module EntryableResource
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
redirect_target_url = request.referer || account_path(@entry.account)
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
end
else
render :new, status: :unprocessable_entity

View file

@ -0,0 +1,11 @@
module StreamExtensions
extend ActiveSupport::Concern
def stream_redirect_back_or_to(path, options = {})
flash[:notice] = options[:notice] if options[:notice].present?
flash[:alert] = options[:alert] if options[:alert].present?
redirect_target_url = request.referer || path
render turbo_stream: turbo_stream.action(:redirect, redirect_target_url)
end
end

View file

@ -1,41 +1,42 @@
class RulesController < ApplicationController
before_action :set_rule, only: [ :show, :edit, :update, :destroy ]
include StreamExtensions
before_action :set_rule, only: [ :edit, :update, :destroy ]
def index
@rules = Current.family.rules.order(created_at: :desc)
render layout: "settings"
end
def show
end
def new
@rule = Current.family.rules.build(
resource_type: params[:resource_type] || "transaction",
conditions: [
Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "test")
],
actions: [
Rule::Action.new(action_type: "set_transaction_category", value: Current.family.categories.first.id)
]
)
@template_condition = Rule::Condition.new(rule: @rule, condition_type: "transaction_name")
@template_action = Rule::Action.new(rule: @rule, action_type: "set_transaction_category")
@rule = Current.family.rules.build(resource_type: params[:resource_type] || "transaction")
end
def create
Current.family.rules.create!(rule_params)
redirect_to rules_path
@rule = Current.family.rules.build(rule_params)
if @rule.save
respond_to do |format|
format.html { redirect_back_or_to rules_path, notice: "Rule created" }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule created" }
end
else
render :new, status: :unprocessable_entity
end
end
def edit
@rule = Current.family.rules.find(params[:id])
end
def update
@rule.update!(rule_params)
redirect_to rules_path
if @rule.update(rule_params)
respond_to do |format|
format.html { redirect_back_or_to rules_path, notice: "Rule updated" }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" }
end
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@ -44,7 +45,6 @@ class RulesController < ApplicationController
end
private
def set_rule
@rule = Current.family.rules.find(params[:id])
end
@ -53,11 +53,11 @@ class RulesController < ApplicationController
params.require(:rule).permit(
:resource_type, :effective_date, :active,
conditions_attributes: [
:id, :condition_type, :operator, :value,
sub_conditions_attributes: [ :id, :condition_type, :operator, :value ]
:id, :condition_type, :operator, :value, :_destroy,
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]
],
actions_attributes: [
:id, :action_type, :value
:id, :action_type, :value, :_destroy
]
)
end

View file

@ -140,7 +140,7 @@ export default class extends Controller {
}
#uniqueKey() {
return `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return Date.now();
}
#getConditionFilterDefinition(key) {

View file

@ -1,5 +1,5 @@
class Account::Transaction < ApplicationRecord
include Account::Entryable, Transferable
include Account::Entryable, Transferable, Ruleable
belongs_to :category, optional: true
belongs_to :merchant, optional: true

View file

@ -0,0 +1,17 @@
module Account::Transaction::Ruleable
extend ActiveSupport::Concern
def eligible_for_category_rule?
rules.joins(:actions).where(
actions: {
action_type: "set_transaction_category",
value: category_id
}
).empty?
end
private
def rules
entry.account.family.rules
end
end

View file

@ -11,85 +11,20 @@ class Rule < ApplicationRecord
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
# Every rule must have at least 1 condition + action
validate :min_conditions_and_actions
validate :no_duplicate_actions
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
def action_executors
registry.action_executors
end
def action_types
registry.action_executors.map { |option| [ option.label, option.key ] }
def condition_filters
registry.condition_filters
end
def condition_types
registry.condition_filters.map { |option| [ option.label, option.key ] }
def title
"Test title"
end
def registry
@ -127,6 +62,22 @@ class Rule < ApplicationRecord
end
private
def min_conditions_and_actions
if conditions.reject(&:marked_for_destruction?).empty?
errors.add(:conditions, "must have at least one condition")
end
if actions.reject(&:marked_for_destruction?).empty?
errors.add(:actions, "must have at least one action")
end
end
def no_duplicate_actions
action_types = actions.map(&:action_type)
errors.add(:base, "Rule cannot have duplicate actions #{action_types.inspect}") if action_types.uniq.count != action_types.count
end
# Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions.
def no_nested_compound_conditions
return true if conditions.none? { |condition| condition.compound? }

View file

@ -1,4 +1,4 @@
<%= drawer(reload_on_close: true) do %>
<%= drawer do %>
<%= render "account/transactions/header", entry: @entry %>
<div class="space-y-2">

View file

@ -32,14 +32,7 @@
</p>
<% unless account.scheduled_for_deletion? %>
<%= form_with model: account,
namespace: account.id,
data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %>
<div class="relative inline-block select-none">
<%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
<%= form.label :is_active, "&nbsp;".html_safe, class: "switch" %>
</div>
<% end %>
<%= render "shared/toggle_form", model: account, attribute: :is_active, turbo_frame: "_top" %>
<% end %>
</div>
</div>

View file

@ -10,6 +10,8 @@
<div id="notification-tray" class="space-y-1 w-full">
<%= render_flash_notifications %>
<div id="cta"></div>
<% if Current.family&.syncing? %>
<% render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %>
<% end %>

View file

@ -5,9 +5,15 @@
<li data-rule-target="action">
<%= form.hidden_field :_destroy, value: false, data: { rule_target: "destroyField" } %>
<%= form.select :action_type, rule.action_types, {}, data: { action: "rule#handleActionTypeChange" } %>
<span>to</span>
<%= form.select :value, action.options, {}, data: { rule_target: "valueField" } %>
<%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule#handleActionTypeChange" } %>
<% if action.executor.type == "select" %>
<span>to</span>
<%= form.select :value, action.options, {}, data: { rule_target: "valueField" } %>
<% else %>
<%= form.hidden_field :value, data: { rule_target: "valueField" } %>
<% end %>
<button type="button"
data-action="rule#removeAction"
data-rule-destroy-param="<%= action.persisted? %>">

View file

@ -5,7 +5,7 @@
<li data-rule-target="condition">
<%= form.hidden_field :_destroy, value: false, data: { rule_target: "destroyField" } %>
<%= form.select :condition_type, rule.condition_types, {}, data: { action: "rule#handleConditionTypeChange" } %>
<%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: "rule#handleConditionTypeChange" } %>
<%= form.select :operator, condition.operators, {}, data: { rule_target: "operatorField" } %>
<% if condition.filter.type == "select" %>

View file

@ -1,20 +1,24 @@
<%# locals: (rule:) %>
<%= form_with model: rule,
class: "p-4 space-y-8 min-w-[600px]",
data: {
controller: "rule",
rule_registry_value: rule.registry.to_json
} do |f| %>
class: "p-4 space-y-8 min-w-[600px]",
data: {
controller: "rule",
rule_registry_value: rule.registry.to_json
} do |f| %>
<%= f.hidden_field :resource_type, value: rule.resource_type %>
<% if @rule.errors.any? %>
<%= render "shared/form_errors", model: @rule %>
<% end %>
<section class="space-y-4">
<h3 class="mb-4 font-bold">Conditions</h3>
<hr>
<template data-rule-target="newConditionTemplate">
<%= f.fields_for :conditions, @template_condition, child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %>
<% end %>
</template>
@ -35,7 +39,7 @@
<hr>
<template data-rule-target="newActionTemplate">
<%= f.fields_for :actions, @template_action, child_index: "IDX_PLACEHOLDER" do |af| %>
<%= f.fields_for :actions, Rule::Action.new(rule: rule, action_type: rule.action_executors.first.key), child_index: "IDX_PLACEHOLDER" do |af| %>
<%= render "rule/actions/action", form: af %>
<% end %>
</template>

View file

@ -0,0 +1,33 @@
<%# locals: (rule:) %>
<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">
<p class="flex items-center flex-wrap gap-1.5">
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
<%= rule.actions.first.executor.label %>
</span>
<% if rule.actions.count > 1 %>
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
<% end %>
</p>
<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 class="flex items-center gap-4">
<%= render "shared/toggle_form", model: rule, attribute: :active %>
<%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
<%= contextual_menu_item "Edit", url: edit_rule_path(rule), icon: "pencil", turbo_frame: "modal" %>
<%= contextual_menu_destructive_item "Delete", rule_path(rule), turbo_confirm: "Are you sure you want to delete this rule?" %>
<% end %>
</div>
</div>

View file

@ -1,4 +1,7 @@
<%= link_to "Back to rules", rules_path %>
<h2>Edit <%= @rule.resource_type %> rule</h2>
<%= render "rules/form", rule: @rule %>
<%= modal do %>
<h2>Edit <%= @rule.resource_type %> rule</h2>
<%= render "rules/form", rule: @rule %>
<% end %>

View file

@ -1,12 +1,37 @@
<% content_for :page_title, "Rules" %>
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">Rules</h1>
<div>
<%= link_to "New rule", new_rule_path %>
<ul>
<% @rules.each do |rule| %>
<li>
<%= link_to rule.id, edit_rule_path(rule) %>
</li>
<% end %>
</ul>
<%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<p>New rule</p>
<% end %>
</header>
<div class="bg-white shadow-border-xs rounded-xl p-4">
<% if @rules.any? %>
<div class="rounded-xl bg-gray-25 space-y-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p>Rules</p>
<span class="text-subdued">&middot;</span>
<p><%= @rules.count %></p>
</div>
<div class="space-y-1 p-1">
<%= render @rules %>
</div>
</div>
<% else %>
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-primary font-medium mb-1">No rules yet</p>
<p class="text-sm text-secondary mb-4">Set up rules to perform actions to your transactions and other data on every account sync.</p>
<div class="flex items-center gap-2">
<%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span>New rule</span>
<% end %>
</div>
</div>
</div>
<% end %>
</div>

View file

@ -1,4 +1,7 @@
<%= link_to "Back to rules", rules_path %>
<h2>New <%= @rule.resource_type %> rule</h2>
<%= render "rules/form", rule: @rule %>
<%= modal do %>
<h2>New <%= @rule.resource_type %> rule</h2>
<%= render "rules/form", rule: @rule %>
<% end %>

View file

@ -0,0 +1,11 @@
<%# locals: (model:, attribute:, turbo_frame: nil) %>
<%= form_with model: model,
namespace: model.id,
class: "flex items-center",
data: { controller: "auto-submit-form", turbo_frame: turbo_frame } do |form| %>
<div class="relative inline-block select-none">
<%= form.check_box attribute, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
<%= form.label attribute, "&nbsp;".html_safe, class: "switch" %>
</div>
<% end %>

View file

@ -1,21 +1,23 @@
<%# locals: (cta:) %>
<%= tag.div class: "relative flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-xs", data: { controller: "element-removal" } do %>
<div class="h-5 w-5 shrink-0 p-px text-white">
<div class="flex h-full items-center justify-center rounded-full bg-success">
<%= lucide_icon "check", class: "w-3 h-3" %>
</div>
</div>
<div class="space-y-4">
<div class="space-y-1">
<%= tag.p cta[:message], class: "text-primary text-sm font-medium" %>
<%= tag.p cta[:description], class: "text-secondary text-sm" %>
<div id="cta">
<%= tag.div class: "relative flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-xs", data: { controller: "element-removal" } do %>
<div class="h-5 w-5 shrink-0 p-px text-white">
<div class="flex h-full items-center justify-center rounded-full bg-success">
<%= lucide_icon "check", class: "w-3 h-3" %>
</div>
</div>
<%= tag.div class:"flex gap-2 justify-end" do %>
<%= tag.button cta[:decline_label], class: "btn btn--secondary", data: { action: "click->element-removal#remove" } %>
<%= tag.a cta[:accept_label], href: cta[:accept_href], class: "btn btn--primary" %>
<% end %>
</div>
<% end %>
<div class="space-y-4">
<div class="space-y-1">
<%= tag.p cta[:message], class: "text-primary text-sm font-medium" %>
<%= tag.p cta[:description], class: "text-secondary text-sm" %>
</div>
<%= tag.div class:"flex gap-2 justify-end" do %>
<%= tag.button cta[:decline_label], class: "btn btn--secondary", data: { action: "click->element-removal#remove" } %>
<%= tag.a cta[:accept_label], href: cta[:accept_href], class: "btn btn--primary", data: { turbo_frame: cta[:accept_turbo_frame] || "_top" } %>
<% end %>
</div>
<% end %>
</div>

View file

@ -143,7 +143,7 @@ Rails.application.routes.draw do
end
end
resources :rules do
resources :rules, except: :show do
resources :triggers, only: :new
resources :actions, only: :new
end

View file

@ -23,7 +23,7 @@ class CreateRules < ActiveRecord::Migration[7.2]
t.references :rule, null: false, foreign_key: true, type: :uuid
t.string :action_type, null: false
t.string :value, null: false
t.string :value
t.timestamps
end
end

2
db/schema.rb generated
View file

@ -477,7 +477,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_03_110915) do
create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "rule_id", null: false
t.string "action_type", null: false
t.string "value", null: false
t.string "value"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["rule_id"], name: "index_rule_actions_on_rule_id"