feat(splits): add exclusion support for splits and improve rendering (#1661)

* feat(splits): add excluded attribute support for split children and improve rendering of split transactions

* address coderabbitai suggestions to improve code quality

* Fix split excluded coercion, DRY helpers, and clean up view partials

Fix boolean coercion bug where string "false" from form params was
truthy in Ruby, causing all split children to be marked excluded.
Use ActiveModel::Type::Boolean for explicit casting in Entry#split!.

Additional changes addressing code review feedback:

- Extract duplicated in_split_group logic from TransactionsController
  and TransactionCategoriesController into TransactionsHelper
- Remove redundant local_assigns.fetch calls in partials that already
  declare defaults via the Rails 7.1 locals: magic comment
- Simplify ternary in _transaction.html.erb to pass grouped directly
- Guard hidden_field_tag :grouped to only emit when value is "true"
- Add model tests for excluded on split children (boolean and string)
- Add controller test for excluded param through full HTTP stack
- Add test confirming excluded children are dropped from balance queries

* fix(splits): simplify excluded attribute boolean check

* refactor(splits): extract truthy values constant for excluded check

Extract the array of truthy values used for excluded attribute check
into a private constant to improve code maintainability and avoid
duplication of the magic array.

* refactor: simplify split grouping link generation and add test coverage for excluded split parameters
This commit is contained in:
CrossDrain
2026-05-09 10:36:41 +00:00
committed by GitHub
parent 96b1d28d5d
commit 0b7fa732ae
15 changed files with 128 additions and 18 deletions

View File

@@ -16,7 +16,7 @@ class SplitsController < ApplicationController
raw_splits = raw_splits.values if raw_splits.respond_to?(:values)
splits = raw_splits.map do |s|
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence }
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] }
end
@entry.split!(splits)
@@ -51,7 +51,7 @@ class SplitsController < ApplicationController
raw_splits = raw_splits.values if raw_splits.respond_to?(:values)
splits = raw_splits.map do |s|
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence }
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] }
end
Entry.transaction do
@@ -95,6 +95,6 @@ class SplitsController < ApplicationController
end
def split_params
params.require(:split).permit(splits: [ :name, :amount, :category_id ])
params.require(:split).permit(splits: [ :name, :amount, :category_id, :excluded ])
end
end

View File

@@ -21,6 +21,7 @@ class TransactionCategoriesController < ApplicationController
transaction.lock_saved_attributes!
@entry.lock_saved_attributes!
in_split_group = helpers.in_split_group?(@entry, params[:grouped])
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@entry) }
format.turbo_stream do
@@ -28,12 +29,12 @@ class TransactionCategoriesController < ApplicationController
turbo_stream.replace(
dom_id(transaction, "category_menu_mobile"),
partial: "transactions/transaction_category",
locals: { transaction: transaction, variant: "mobile" }
locals: { transaction: transaction, variant: "mobile", in_split_group: in_split_group }
),
turbo_stream.replace(
dom_id(transaction, "category_menu_desktop"),
partial: "transactions/transaction_category",
locals: { transaction: transaction, variant: "desktop" }
locals: { transaction: transaction, variant: "desktop", in_split_group: in_split_group }
),
turbo_stream.replace(
"category_name_mobile_#{transaction.id}",

View File

@@ -142,6 +142,7 @@ class TransactionsController < ApplicationController
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
format.turbo_stream do
in_split_group = helpers.in_split_group?(@entry, params[:grouped])
render turbo_stream: [
turbo_stream.replace(
dom_id(@entry, :header),
@@ -158,7 +159,11 @@ class TransactionsController < ApplicationController
partial: "transactions/notes",
locals: { entry: @entry, can_annotate: can_annotate_entry? }
) if params[:entry]&.key?(:notes) && notes_changed),
turbo_stream.replace(@entry),
turbo_stream.replace(
dom_id(@entry),
partial: "entries/entry",
locals: { entry: @entry, in_split_group: in_split_group }
),
*flash_notification_stream_items
].compact
end

View File

@@ -20,6 +20,10 @@ module TransactionsHelper
transaction_search_filters[0]
end
def in_split_group?(entry, params_grouped)
entry.split_child? && Current.user.show_split_grouped? && params_grouped == "true"
end
# ---- Transaction extra details helpers ----
# Returns a structured hash describing extra details for a transaction.
# Input can be a Transaction or an Entry (responds_to :transaction).

View File

@@ -1,6 +1,9 @@
class Entry < ApplicationRecord
include Monetizable, Enrichable
TRUTHY_VALUES = [ true, "true", "1", 1 ].freeze
private_constant :TRUTHY_VALUES
attr_accessor :unsplitting
monetize :amount
@@ -361,7 +364,7 @@ class Entry < ApplicationRecord
# Splits this entry into child entries. Marks parent as excluded.
#
# @param splits [Array<Hash>] array of { name:, amount:, category_id: } hashes
# @param splits [Array<Hash>] array of { name:, amount:, category_id:, excluded: } hashes
# @return [Array<Entry>] the created child entries
def split!(splits)
total = splits.sum { |s| s[:amount].to_d }
@@ -383,6 +386,7 @@ class Entry < ApplicationRecord
name: split_attrs[:name],
amount: split_attrs[:amount],
currency: currency,
excluded: TRUTHY_VALUES.include?(split_attrs[:excluded]),
entryable: child_transaction
)
end

View File

@@ -1,4 +1,4 @@
<%# locals: (transaction:) %>
<%# locals: (transaction:, in_split_group: false) %>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(class: "block w-full overflow-hidden") do %>
@@ -11,7 +11,7 @@
<% end %>
<% menu.with_custom_content do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id, grouped: in_split_group), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>
</div>

View File

@@ -10,6 +10,7 @@
data: { filter_name: category.name } do %>
<%= button_to transaction_category_path(
@transaction.entry,
grouped: params[:grouped],
entry: {
entryable_type: "Transaction",
entryable_attributes: { id: @transaction.id, category_id: category.id }

View File

@@ -51,7 +51,7 @@
<%= button_to transaction_path(@transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) },
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
params: { grouped: params[:grouped], entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
<%= icon("minus") %>
@@ -85,6 +85,7 @@
method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.check_box "entry[excluded]",
checked: @transaction.entry.excluded,
class: "checkbox checkbox--light",

View File

@@ -1,6 +1,6 @@
<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %>
<%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %>
<% if entry.entryable.present? %>
<%= render partial: entry.entryable.to_partial_path,
locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx } %>
locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx, in_split_group: in_split_group } %>
<% end %>

View File

@@ -3,6 +3,7 @@
<%= styled_form_with model: entry,
url: transaction_path(entry),
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.text_area :notes,
label: t("transactions.show.note_label"),
placeholder: t("transactions.show.note_placeholder"),

View File

@@ -19,7 +19,7 @@
} %>
<div class="flex md:hidden items-center gap-1 col-span-2 relative shrink-0">
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile", in_split_group: in_split_group %>
<% if transaction.merchant&.logo_url.present? %>
<%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url),
class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none",
@@ -70,7 +70,7 @@
<% else %>
<%= link_to(
entry.name,
entry_path(entry),
in_split_group ? entry_path(entry, grouped: true) : entry_path(entry),
data: {
turbo_frame: "drawer",
turbo_prefetch: false
@@ -163,7 +163,7 @@
<%# For investment accounts, show activity label instead of category %>
<%= render "investment_activity/quick_edit_badge", entry: entry, entryable: transaction %>
<% else %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop" %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop", in_split_group: in_split_group %>
<% end %>
</div>

View File

@@ -1,8 +1,8 @@
<%# locals: (transaction:, variant:) %>
<%# locals: (transaction:, variant:, in_split_group: false) %>
<div id="<%= dom_id(transaction, "category_menu_#{variant}") %>" class="min-w-0 overflow-hidden">
<% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
<%= render "categories/menu", transaction: transaction %>
<%= render "categories/menu", transaction: transaction, in_split_group: in_split_group %>
<% else %>
<div class="hidden lg:flex">
<%= render "categories/badge", category: transaction.transfer&.payment? ? payment_category : transfer_category %>

View File

@@ -54,6 +54,7 @@
url: transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.text_field :name,
label: t(".name_label"),
disabled: @entry.split_child? || edit_locked,
@@ -95,6 +96,7 @@
url: transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<% unless @entry.transaction.transfer? %>
<%= f.select :account,
options_for_select(
@@ -262,12 +264,13 @@
<% if can_edit_entry? %>
<% dialog.with_section(title: t(".settings")) do %>
<% unless @entry.split_parent? || @entry.split_child? %>
<% unless @entry.split_parent? %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".exclude") %></h4>
@@ -284,6 +287,7 @@
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
@@ -309,6 +313,7 @@
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">