Feat: Add default user account and consolidate account actions in menu (#1130)

* feat: Add default account for manual transaction entries (#1061)

Allow users to designate a default account that auto-selects
in the transaction creation form. Also consolidates account list
actions (edit, link/unlink, enable/disable) into a meatball menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* - handle context menu width on mobile
- restrict default account to depository types only
- added FR, ES and DE i18n files

* - Add credit card accounts can also be used as default
- Moved logic into controller

* Scope context menu max-width to accounts menu only
- decouples the width constraint from the shared DS::Menu component by introducing an optional max_width param

* fix ci test and address issues raised by coderabbit and codex

* Address CodeRabbit review feedback

- Use .present? for institution_name guards to avoid empty UI artifacts
- Align "Set default" menu visibility with actual preselection eligibility
  (active + unlinked + supports_default?) to prevent drift between UI and model
- Keep disabled star visible when account is already default but now ineligible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add eligible_for_transaction_default? predicate to Account model

Consolidates active + unlinked + supports_default? checks into a single
shared predicate used by the controller, view, and user model guard,
preventing a direct PATCH from bypassing UI eligibility rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Added "Unset default" option
Added negative test for default account
Removed duplicated logic for account.eligible_for_transaction_default

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Serge L
2026-03-15 16:26:26 -04:00
committed by GitHub
parent 581d3684b2
commit 5aa808e668
17 changed files with 189 additions and 45 deletions

View File

@@ -1,4 +1,4 @@
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %>
<% if variant == :icon %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
<% elsif variant == :button %>
@@ -12,7 +12,7 @@
<% end %>
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
@@ -22,6 +22,6 @@
<%= custom_content %>
<% end %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
class DS::Menu < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
@@ -23,7 +23,7 @@ class DS::Menu < DesignSystemComponent
VARIANTS = %i[icon button avatar].freeze
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@initials = initials
@@ -32,6 +32,8 @@ class DS::Menu < DesignSystemComponent
@icon_vertical = icon_vertical
@no_padding = no_padding
@testid = testid
@mobile_fullwidth = mobile_fullwidth
@max_width = max_width
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end

View File

@@ -16,6 +16,7 @@ export default class extends Controller {
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
mobileFullwidth: { type: Boolean, default: true },
};
connect() {
@@ -105,13 +106,14 @@ export default class extends Controller {
if (!this.buttonTarget || !this.contentTarget) return;
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
computePosition(this.buttonTarget, this.contentTarget, {
placement: isSmallScreen ? "bottom" : this.placementValue,
placement: useMobileFullwidth ? "bottom" : this.placementValue,
middleware: [offset(this.offsetValue), shift({ padding: 5 })],
strategy: "fixed",
}).then(({ x, y }) => {
if (isSmallScreen) {
if (useMobileFullwidth) {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: "0px",

View File

@@ -1,5 +1,5 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider]
before_action :set_account, only: %i[sync sparkline toggle_active set_default remove_default show destroy unlink confirm_unlink select_provider]
include Periodable
def index
@@ -89,6 +89,21 @@ class AccountsController < ApplicationController
redirect_to accounts_path
end
def set_default
unless @account.eligible_for_transaction_default?
redirect_to accounts_path, alert: t("accounts.set_default.depository_only")
return
end
Current.user.update!(default_account: @account)
redirect_to accounts_path
end
def remove_default
Current.user.update!(default_account: nil)
redirect_to accounts_path
end
def destroy
if @account.linked?
redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked")

View File

@@ -310,6 +310,14 @@ class Account < ApplicationRecord
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end
def supports_default?
depository? || credit_card?
end
def eligible_for_transaction_default?
supports_default? && active? && !linked?
end
# Determines if this account supports manual trade entry
# Investment accounts always support trades; Crypto only if subtype is "exchange"
def supports_trades?

View File

@@ -24,6 +24,7 @@ class User < ApplicationRecord
belongs_to :family
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
belongs_to :default_account, class_name: "Account", optional: true
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, dependent: :destroy
@@ -244,6 +245,15 @@ class User < ApplicationRecord
AccountOrder.find(default_account_order) || AccountOrder.default
end
def default_account_for_transactions
return nil unless default_account_id.present?
account = default_account
return nil unless account&.eligible_for_transaction_default? && account.family_id == family_id
account
end
# Dashboard preferences management
def dashboard_section_collapsed?(section_key)
preferences&.dig("collapsed_sections", section_key) == true

View File

@@ -1,7 +1,9 @@
<%# locals: (account:, return_to: nil) %>
<% is_default = Current.user&.default_account_id == account.id %>
<%= turbo_frame_tag dom_id(account) do %>
<div class="p-4 flex items-center justify-between gap-3 group/account hover:bg-surface-hover">
<div class="relative p-4 flex items-center justify-between gap-3 group/account hover:bg-surface-hover">
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account, size: "md" %>
@@ -16,41 +18,26 @@
</span>
</p>
<% else %>
<div>
<div class="flex items-center gap-1.5">
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<% if account.institution_name %>
<span class="text-secondary">• <%= account.institution_name %></span>
<% if account.institution_name.present? %>
<span class="hidden sm:inline text-secondary">• <%= account.institution_name %></span>
<% end %>
</div>
<% if account.long_subtype_label %>
<p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p>
<% end %>
<% if account.supports_default? && is_default %>
<p class="text-xs text-secondary opacity-50"><%= t("accounts.account.default_label") %></p>
<% end %>
<% if account.institution_name.present? %>
<p class="sm:hidden text-sm text-secondary truncate"><%= account.institution_name %></p>
<% end %>
<% end %>
</div>
<% unless account.pending_deletion? %>
<%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
<%= icon("pencil-line", size: "sm") %>
<% end %>
<% if !account.linked? && ["Depository", "CreditCard", "Investment", "Crypto"].include?(account.accountable_type) %>
<%= link_to select_provider_account_path(account),
data: { turbo_frame: :modal },
class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1",
title: t("accounts.account.link_provider") do %>
<%= icon("link", size: "sm") %>
<% end %>
<% elsif account.linked? %>
<%= link_to confirm_unlink_account_path(account),
data: { turbo_frame: :modal },
class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1",
title: t("accounts.account.unlink_provider") do %>
<%= icon("unlink", size: "sm") %>
<% end %>
<% end %>
<% end %>
</div>
<div class="flex items-center gap-8">
<div class="flex items-center gap-4">
<% if account.draft? %>
<!-- Balance hidden for draft accounts -->
<% elsif account.syncing? %>
@@ -68,14 +55,34 @@
variant: :outline,
frame: :modal
) %>
<% elsif account.active? || account.disabled? %>
<%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= render DS::Toggle.new(
id: "account_#{account.id}_active",
name: "active",
checked: account.active?,
data: { auto_submit_form_target: "auto" }
) %>
<% elsif !account.pending_deletion? %>
<%= render DS::Menu.new(icon_vertical: true, mobile_fullwidth: false, max_width: "280px") do |menu| %>
<% menu.with_item(variant: "link", text: t("accounts.account.edit"), href: edit_account_path(account, return_to: return_to), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% if !account.linked? && %w[Depository CreditCard Investment Crypto].include?(account.accountable_type) %>
<% menu.with_item(variant: "link", text: t("accounts.account.link_provider"), href: select_provider_account_path(account), icon: "link", data: { turbo_frame: :modal }) %>
<% elsif account.linked? %>
<% menu.with_item(variant: "link", text: t("accounts.account.unlink_provider"), href: confirm_unlink_account_path(account), icon: "unlink", data: { turbo_frame: :modal }) %>
<% end %>
<% menu.with_item(variant: "divider") %>
<% if account.active? %>
<% menu.with_item(variant: "button", text: t("accounts.account.disable"), href: toggle_active_account_path(account), method: :patch, icon: "toggle-right", data: { turbo_frame: :_top }) %>
<% elsif account.disabled? %>
<% menu.with_item(variant: "button", text: t("accounts.account.enable"), href: toggle_active_account_path(account), method: :patch, icon: "toggle-left", data: { turbo_frame: :_top }) %>
<% end %>
<% if is_default %>
<% menu.with_item(variant: "button", text: t("accounts.account.remove_default"), href: remove_default_account_path(account), method: :patch, icon: "star-off", data: { turbo_frame: :_top }) %>
<% elsif account.eligible_for_transaction_default? %>
<% menu.with_item(variant: "button", text: t("accounts.account.set_default"), href: set_default_account_path(account), method: :patch, icon: "star", data: { turbo_frame: :_top }) %>
<% end %>
<% unless account.linked? %>
<% menu.with_item(variant: "divider") %>
<% menu.with_item(variant: "button", text: t("accounts.account.delete"), href: account_path(account), method: :delete, icon: "trash-2", confirm: CustomConfirm.for_resource_deletion("account", high_severity: true), data: { turbo_frame: :_top }) %>
<% end %>
<% end %>
<% end %>
</div>

View File

@@ -18,7 +18,7 @@
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<% else %>
<%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), variant: :logo }, required: true, class: "form-field__input text-ellipsis" %>
<%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :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" %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>