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",