mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 20:44:08 +00:00
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:
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
Reference in New Issue
Block a user