Add parent/child category hierarchy to all category selects; add search to account select

Category selection consistency:
- DS::Select (shared component powering the main transaction form,
  transaction edit, bulk-update, and transfer category pickers) now
  indents subcategories with a corner-down-right icon, matching the
  existing transaction-row category dropdown.
- Feed DS::Select-based category pickers with Category.alphabetically_by_hierarchy
  (parent name, then parent-before-children, then own name) so children
  render directly under their parent.
- Added Category::Group.select_options, a shared helper producing
  parent-then-child ordered options (with an indent marker) for plain
  HTML <select> elements. Used by:
  - Rule builder category condition/action selects
  - Bulk 'categorize transactions' select
  - CSV/QIF import category mapping select
- Grouped the splits category combobox and the transaction search
  category filter checklist the same way, both with the
  corner-down-right indent icon used elsewhere.

Account selection:
- Added searchable: true to the account select in the new/edit
  transaction form, matching the category and merchant selects next
  to it.
This commit is contained in:
Claude
2026-07-28 16:44:20 +00:00
parent 0fa1fbd650
commit b39a08be6a
15 changed files with 60 additions and 34 deletions

View File

@@ -50,6 +50,7 @@
<% items.each do |item| %>
<% is_selected = item[:value] == selected_value %>
<% obj = item[:object] %>
<% is_child = obj&.respond_to?(:parent_id) && obj.parent_id.present? %>
<%# Options use tabindex="-1" always — keyboard focus is managed
programmatically so listbox items never appear in the Tab
@@ -67,6 +68,10 @@
<%= helpers.icon("check") %>
</span>
<% if is_child %>
<%= helpers.icon("corner-down-right", size: "sm") %>
<% end %>
<% case variant %>
<% when :simple %>
<%= item[:label] %>

View File

@@ -498,7 +498,7 @@ class TransactionsController < ApplicationController
.alphabetically
.includes(:account_providers, logo_attachment: :blob)
.to_a
@categories = Current.family.categories.alphabetically.to_a
@categories = Current.family.categories.alphabetically_by_hierarchy.to_a
@merchants = Current.family.available_merchants_for(Current.user).alphabetically.to_a
@tags = Current.family.tags.alphabetically.to_a
end

View File

@@ -10,7 +10,7 @@ class TransfersController < ApplicationController
end
def show
@categories = Current.family.categories.alphabetically
@categories = Current.family.categories.alphabetically_by_hierarchy
# Whether the current user can hit `mark_as_recurring`: feature flag on,
# AND they have write access to BOTH transfer endpoints. Gating the

View File

@@ -20,6 +20,6 @@ module CategoriesHelper
end
def family_categories
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
[ Category.uncategorized ].concat(Current.family.categories.alphabetically_by_hierarchy)
end
end

View File

@@ -130,6 +130,17 @@ class Category < ApplicationRecord
end
end
# Builds [label, id] pairs for plain HTML <select> elements, ordered
# parent-then-children with children visually indented. Native <select>
# options can't render icons, so we use a unicode arrow prefix (regular
# leading spaces collapse in <option> text).
def self.select_options(categories)
for(categories).flat_map do |group|
[ [ group.category.name, group.category.id ] ] +
group.subcategories.map { |sub| [ "#{sub.name}", sub.id ] }
end
end
def initialize(category, subcategories = nil)
@category = category
@subcategories = subcategories || []

View File

@@ -25,7 +25,7 @@ class Import::CategoryMapping < Import::Mapping
end
def selectable_values
family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }
family_categories = Category::Group.select_options(import.family.categories)
unless key.blank?
family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ]

View File

@@ -4,7 +4,7 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor
end
def options
family.categories.alphabetically.pluck(:name, :id)
Category::Group.select_options(family.categories)
end
def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil)

View File

@@ -4,7 +4,7 @@ class Rule::ConditionFilter::TransactionCategory < Rule::ConditionFilter
end
def options
family.categories.alphabetically.pluck(:name, :id)
Category::Group.select_options(family.categories)
end
def prepare(scope)

View File

@@ -59,29 +59,35 @@
<%= t("splits.new.uncategorized") %>
</div>
<% categories.each do |category| %>
<% is_selected = category.id == selected_id %>
<% hex_color = category.color.presence || default_color %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="0"
aria-selected="<%= is_selected %>"
data-action="click->select#select"
data-value="<%= category.id %>"
data-filter-name="<%= category.display_name %>">
<span class="check-icon <%= "hidden" unless is_selected %>">
<%= icon("check") %>
</span>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent); border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent); color: <%= hex_color %>;">
<% if category.lucide_icon.present? %>
<%= icon category.lucide_icon, size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% Category::Group.for(categories).each do |group| %>
<% [ group.category, *group.subcategories ].each do |category| %>
<% is_child = category.parent_id.present? %>
<% is_selected = category.id == selected_id %>
<% hex_color = category.color.presence || default_color %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="0"
aria-selected="<%= is_selected %>"
data-action="click->select#select"
data-value="<%= category.id %>"
data-filter-name="<%= category.display_name %>">
<span class="check-icon <%= "hidden" unless is_selected %>">
<%= icon("check") %>
</span>
<% if is_child %>
<%= icon("corner-down-right", size: "sm") %>
<% end %>
<%= category.display_name %>
</span>
</div>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent); border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent); color: <%= hex_color %>;">
<% if category.lucide_icon.present? %>
<%= icon category.lucide_icon, size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% end %>
<%= category.display_name %>
</span>
</div>
<% end %>
<% end %>
</div>
</div>

View File

@@ -18,7 +18,7 @@
<% if @entry.account_id %>
<%= f.hidden_field :account_id, data: { transaction_form_target: "account" } %>
<% else %>
<%= f.collection_select :account_id, manual_accounts, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo }, required: true, class: "form-field__input text-ellipsis", data: { transaction_form_target: "account", action: "change->transaction-form#checkCurrencyDifference" } %>
<%= f.collection_select :account_id, manual_accounts, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo, searchable: true }, required: true, class: "form-field__input text-ellipsis", data: { transaction_form_target: "account", action: "change->transaction-form#checkCurrencyDifference" } %>
<% end %>
<%= f.money_field :amount,

View File

@@ -11,7 +11,7 @@
<%= render DS::Disclosure.new(title: t(".transactions_section"), open: true) do %>
<div class="space-y-2">
<%= form.text_field :name, label: t(".name_label"), placeholder: t(".name_placeholder") %>
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category_label"), class: "text-subdued", variant: :badge, searchable: true } %>
<%= form.collection_select :category_id, Current.family.categories.alphabetically_by_hierarchy, :id, :name, { prompt: t(".category_prompt"), label: t(".category_label"), class: "text-subdued", variant: :badge, searchable: true } %>
<%= form.collection_select :merchant_id, Current.family.available_merchants_for(Current.user).alphabetically, :id, :name, { prompt: t(".merchant_prompt"), label: t(".merchant_label"), class: "text-subdued" } %>
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: t(".none"), multiple: true, label: t(".tags_label"), include_hidden: false } %>
<%= form.text_area :notes, label: t(".notes_label"), placeholder: t(".notes_placeholder"), rows: 5 %>

View File

@@ -11,7 +11,7 @@
<%= format_money(entry.amount_money.abs) %>
</span>
<%= select_tag "category_id",
options_from_collection_for_select(categories, :id, :name),
options_for_select(Category::Group.select_options(categories)),
prompt: t("transactions.categorizes.show.assign_category_prompt"),
aria: { label: t("transactions.categorizes.entry_row.assign_category_select", name: entry.name) },
class: "w-full text-xs border border-primary rounded-lg px-1.5 py-0.5 bg-container text-secondary",

View File

@@ -11,7 +11,8 @@
) %>
<div class="my-2" id="list" data-list-filter-target="list">
<% family_categories.each do |category| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= category.display_name %>">
<% is_child = category.parent_id.present? %>
<div class="filterable-item flex items-center gap-2 p-2 <%= "pl-6" if is_child %>" data-filter-name="<%= category.display_name %>">
<%= form.check_box :categories,
{
multiple: true,
@@ -21,6 +22,9 @@
category.name,
nil %>
<%= form.label :categories, category.display_name, value: category.name, class: "text-sm text-primary cursor-pointer" do %>
<% if is_child %>
<%= icon("corner-down-right", size: "sm") %>
<% end %>
<%= render partial: "categories/badge", locals: { category: category } %>
<% end %>
</div>

View File

@@ -84,7 +84,7 @@
</div>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id,
Current.family.categories.alphabetically,
Current.family.categories.alphabetically_by_hierarchy,
:id, :name,
{ label: t(".category_label"),
class: "text-subdued", include_blank: t(".uncategorized"),

View File

@@ -84,7 +84,7 @@
<%= styled_form_with model: @transfer,
data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %>
<% if @transfer.categorizable? %>
<%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: t(".category"), include_blank: t(".uncategorized"), selected: @transfer.outflow_transaction.category&.id, variant: :badge, searchable: true }, "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :category_id, @categories, :id, :name, { label: t(".category"), include_blank: t(".uncategorized"), selected: @transfer.outflow_transaction.category&.id, variant: :badge, searchable: true }, "data-auto-submit-form-target": "auto" %>
<% end %>
<%= f.text_area :notes,
label: t(".note_label"),