mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
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:
@@ -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] %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user