Files
sure/app/views/holdings/show.html.erb
Guillem Arias Fauste cdce00c71e refactor(design-system): migrate 38 hand-rolled provider buttons to DS::Button / DS::Link (#1715 §5 part B) (#1860)
* refactor(design-system): migrate 9 hand-rolled buttons with orphan btn-- classes to DS::Button / DS::Link

Part of #1715 §5. The `btn`, `btn--primary`, `btn--outline`, `btn--ghost`,
`btn--sm` CSS classes have no backing styles anywhere in the codebase
(no .btn definition in app/assets/, no Bootstrap dependency). These
callsites have been rendering unstyled buttons / links since the
underlying CSS was last removed.

Migrate the 9 broken callsites:

- `app/views/transactions/show.html.erb` — duplicate-merge action
  buttons (×2): `button_to ... class: "btn btn--primary btn--sm"` /
  `class: "btn btn--outline btn--sm"` → DS::Button with href +
  variant + size + `data: { turbo_method: :post }`.
- `app/views/snaptrade_items/select_existing_account.html.erb` —
  "Go to Provider Settings" link → DS::Link primary sm.
- `app/views/indexa_capital_items/select_existing_account.html.erb` —
  same pattern → DS::Link primary sm.
- `app/views/import/confirms/show.html.erb` — Publish button +
  Cancel link → DS::Button primary full-width + DS::Link ghost
  full-width.
- `app/views/simplefin_items/new.html.erb` — Cancel link
  (`class: "btn"` only) + Connect submit → DS::Link secondary +
  bare `f.submit` (already routes to DS::Button via
  StyledFormBuilder).
- `app/views/settings/providers/_ibkr_panel.html.erb`,
  `_snaptrade_panel.html.erb`,
  `_indexa_capital_panel.html.erb` — strip the orphan
  `class: "btn btn--primary"` from `f.submit` callers; the submit
  is already a styled DS::Button via the form builder.

The next PR in this chain (Phase B) will tackle the larger inline-
button cluster (~29 files, 38 instances) — provider panels and
provider-item flows hand-rolling the same
`inline-flex items-center justify-center rounded-lg px-4 py-2
text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
focus:outline-none focus:ring-2 focus:ring-primary transition-colors`
string.

* refactor(design-system): migrate 38 hand-rolled provider buttons to DS::Button / DS::Link (#1715 §5 part B)

Bulk sweep of the second cluster from §5. 29 files, 38 button
instances — each one hand-rolled the same long Tailwind string for
the primary action button:

  inline-flex items-center justify-center rounded-lg px-4 py-2
  text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
  focus:outline-none focus:ring-2 focus:ring-primary transition-colors

(some variations used `button-bg-primary hover:button-bg-primary-hover`
instead of `bg-inverse hover:bg-inverse-hover` — same intent).

Every instance is now a DS::Button / DS::Link with `variant: :primary`,
which:

- Picks up the new focus-ring + touch-target work from #1840 once
  that merges.
- Stops duplicating the long Tailwind string across 29 files —
  single source of truth in `DS::Buttonish::VARIANTS[:primary]`.
- Picks up consistent `aria-label` derivation for icon-only forms.
- Removes the misnamed `focus:ring-primary` (no token) — the new
  ring comes from `base.css` automatically.

Migration patterns applied:

- `f.submit text, class: "inline-flex …"` inside `styled_form_with`
  → bare `<%= f.submit text %>`. StyledFormBuilder routes through
  DS::Button.
- `link_to text, path, class: "inline-flex …"` → DS::Link primary.
- `button_to text, path, method: :X, class: "inline-flex …"` →
  DS::Button with `href: path` and `data: { turbo_method: :X }`.
- `submit_tag text, class: "inline-flex …"` inside raw `form_with`
  → DS::Button with `type: :submit`.

Notable adjustments:

- `holdings/show.html.erb` — the form was `form_with` (not styled).
  Switched to `styled_form_with` so `f.submit` routes through
  DS::Button. `f.combobox` (hotwire_combobox) still works through
  the styled builder.
- Two `link_to settings_providers_path` callsites in
  `coinstats_items/new.html.erb` + `enable_banking_items/new.html.erb`
  had `w-full inline-flex … hidden md:inline-flex` — the responsive
  pair conflicted (both `inline-flex` and `hidden md:inline-flex`
  on the same element). Migrated to `full_width: true` without the
  responsive split; the buttons now render at all breakpoints
  consistently. (Pre-existing copy-paste bug, fixed in passing.)
- `enable_banking_panel` add-connection button gained
  `icon: "plus"` via the DS::Button API; the explicit `gap-2 …
  icon "plus"` markup is now redundant.

Sibling buttons that don't match the primary spec (destructive
trash, secondary outline-bordered, button-bg-secondary-strong on
holdings/show.html.erb, etc.) are intentionally left alone — they
need their own audit pass once #1840 lands and the focus-ring
behavior on those variants is stable.

* fix(review): restore SimpleFIN submit styling + i18n provider_form label

- SimpleFIN new modal: switch form_with -> styled_form_with so f.submit
  picks up the DS::Button render via styled builder (Codex #1860).
- _provider_form: replace hardcoded "Save and connect" with t(".save_and_connect")
  and add scoped key under settings.providers.provider_form (CodeRabbit).
2026-05-20 18:15:15 +02:00

341 lines
19 KiB
Plaintext

<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
<% dialog.with_header(custom_header: true) do %>
<div class="flex items-center justify-between">
<div>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-primary" %>
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
</div>
<div class="flex items-center gap-3">
<% if (logo = @holding.security.display_logo_url).present? %>
<%= image_tag logo, loading: "lazy", class: "w-9 h-9 rounded-full" %>
<% else %>
<%= render DS::FilledIcon.new(variant: :text, text: @holding.name, size: "md", rounded: true) %>
<% end %>
<%= dialog.close_button %>
</div>
</div>
<% end %>
<% dialog.with_body do %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4">
<% if @holding.security.provider_status == :provider_unavailable %>
<div class="px-3 pt-2">
<%= render DS::Alert.new(
message: t(".provider_disabled_warning", provider: @holding.security.price_provider&.humanize || "Unknown"),
variant: :warning
) %>
</div>
<% end %>
<% if (first_on = @holding.security.first_provider_price_on).present? &&
(earliest_trade_date = @holding.trades.minimum(:date)) &&
earliest_trade_date < first_on %>
<div class="px-3 pt-2">
<%= render DS::Alert.new(
message: t(".truncated_history_warning", date: l(first_on, format: :long)),
variant: :warning
) %>
</div>
<% end %>
<dl class="space-y-3 px-3 py-2">
<div data-controller="holding-security-remap">
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".security_label") %></dt>
<dd class="text-primary flex items-center gap-2">
<%= @holding.ticker %>
<% if @holding.security_remapped? && @holding.account.linked? %>
<span class="text-xs text-secondary">
(<%= t(".originally", ticker: @holding.provider_security.ticker) %>)
</span>
<%= icon "lock", size: "xs", class: "text-secondary" %>
<% end %>
<button type="button"
data-action="click->holding-security-remap#toggle"
class="text-secondary hover:text-primary"
aria-label="<%= t(".edit_security") %>">
<%= icon "pencil", size: "xs" %>
</button>
</dd>
</div>
<div data-holding-security-remap-target="form" class="hidden mt-3 space-y-3">
<% if Security.providers.any? %>
<%= styled_form_with url: remap_security_holding_path(@holding), method: :patch, class: "space-y-3" do |f| %>
<div class="form-field combobox">
<%= f.combobox :security_id,
securities_path(country_code: Current.family.country),
label: t(".search_security"),
placeholder: t(".search_security_placeholder"),
required: true %>
</div>
<div class="flex justify-end gap-2">
<button type="button"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover"
data-action="click->holding-security-remap#toggle">
<%= t(".cancel") %>
</button>
<%= f.submit t(".remap_security") %>
</div>
<% end %>
<% else %>
<p class="text-xs text-secondary"><%= t(".no_security_provider") %></p>
<% end %>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".current_market_price_label") %></dt>
<dd id="<%= dom_id(@holding, :current_market_price) %>" class="text-primary">
<% begin %>
<%= @holding.security.current_price ? format_money(@holding.security.current_price) : t(".unknown") %>
<% rescue ActiveRecord::RecordInvalid %>
<%= t(".unknown") %>
<% rescue StandardError => e %>
<% logger.error "Error fetching current price for security #{@holding.security.id}: #{e.message}" %>
<% logger.error e.backtrace.first(5).join("\n") %>
<%= t(".unknown") %>
<% end %>
</dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".shares_label") %></dt>
<dd class="text-primary"><%= format_quantity(@holding.qty) %></dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".portfolio_weight_label") %></dt>
<dd class="text-primary"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %></dd>
</div>
<%# Average Cost with inline editor %>
<%
currency = Money::Currency.new(@holding.currency)
current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil
current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil
%>
<div data-controller="drawer-cost-basis" data-drawer-cost-basis-qty-value="<%= @holding.qty %>">
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
<dd class="text-primary flex items-center gap-1">
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
<% if @holding.cost_basis_locked? %>
<%= icon "lock", size: "xs", class: "text-secondary" %>
<% end %>
<% if @holding.cost_basis_source.present? %>
<span class="text-xs text-secondary">(<%= @holding.cost_basis_source_label %>)</span>
<% end %>
<button type="button" class="ml-1" data-action="click->drawer-cost-basis#toggle">
<%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
</button>
</dd>
</div>
<%# Inline cost basis editor (hidden by default) %>
<div class="hidden mt-3 space-y-3" data-drawer-cost-basis-target="form">
<%
drawer_form_data = { turbo: false }
if @holding.avg_cost
drawer_form_data[:turbo_confirm] = {
title: t("holdings.cost_basis_cell.overwrite_confirm_title"),
body: t("holdings.cost_basis_cell.overwrite_confirm_body", current: format_money(@holding.avg_cost))
}
end
%>
<%= styled_form_with model: @holding,
url: holding_path(@holding),
method: :patch,
class: "space-y-3",
data: drawer_form_data do |f| %>
<p class="text-xs text-secondary mb-2">
<%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: format_quantity(@holding.qty)) %>
</p>
<!-- Total cost basis input -->
<div class="form-field">
<div class="form-field__body">
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
<div class="flex items-center gap-1">
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
<input type="number" step="any"
name="holding[cost_basis]"
class="form-field__input grow"
placeholder="0.00"
autocomplete="off"
value="<%= sprintf("%.2f", current_total) if current_total %>"
data-action="input->drawer-cost-basis#updatePerShare"
data-drawer-cost-basis-target="total">
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
</div>
</div>
</div>
<p class="text-xs text-secondary -mt-2">
= <%= currency.symbol %><span data-drawer-cost-basis-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t("holdings.cost_basis_cell.per_share") %>
</p>
<!-- Per-share input -->
<div class="pt-2 border-t border-tertiary">
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
<div class="flex items-center gap-1">
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
<input type="number" step="any"
class="form-field__input grow"
placeholder="0.00"
autocomplete="off"
value="<%= sprintf("%.2f", current_per_share) if current_per_share %>"
data-action="input->drawer-cost-basis#updateTotal"
data-drawer-cost-basis-target="perShare">
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover"
data-action="click->drawer-cost-basis#toggle">
<%= t("holdings.cost_basis_cell.cancel") %>
</button>
<%= f.submit t("holdings.cost_basis_cell.save") %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".book_value_label") %></dt>
<dd class="text-primary">
<% book_value = @holding.avg_cost ? @holding.avg_cost * @holding.qty : nil %>
<%= book_value ? format_money(book_value) : t(".unknown") %>
</dd>
</div>
<div id="<%= dom_id(@holding, :market_value) %>" class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".market_value_label") %></dt>
<dd class="text-primary"><%= format_money(@holding.amount_money) %></dd>
</div>
<div id="<%= dom_id(@holding, :total_return) %>" class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".total_return_label") %></dt>
<% if @holding.trend %>
<dd style="color: <%= @holding.trend.color %>;">
<%= render("shared/trend_change", trend: @holding.trend) %>
</dd>
<% else %>
<dd class="text-secondary"><%= t(".unknown") %></dd>
<% end %>
</div>
</dl>
</div>
<% end %>
<% dialog.with_section(title: t(".history"), open: true) do %>
<div class="space-y-2">
<div class="px-3 py-4">
<% if @holding.trades.any? %>
<ul class="space-y-2">
<% @holding.trades.each_with_index do |trade_entry, index| %>
<li class="flex gap-4 text-sm space-y-1">
<div class="flex flex-col items-center gap-1.5 pt-2">
<div class="rounded-full h-1.5 w-1.5 bg-gray-300"></div>
<% unless index == @holding.trades.length - 1 %>
<div class="h-12 w-px bg-alpha-black-200"></div>
<% end %>
</div>
<div>
<p class="text-secondary text-xs uppercase"><%= l(trade_entry.date, format: :long) %></p>
<p class="text-primary"><%= t(
".trade_history_entry",
qty: trade_entry.trade.qty,
security: trade_entry.trade.security.ticker,
price: trade_entry.trade.price_money.format
) %></p>
</div>
</li>
<% end %>
</ul>
<% else %>
<p class="text-secondary"><%= t(".no_trade_history") %></p>
<% end %>
</div>
</div>
<% end %>
<% if @holding.cost_basis_locked? || (@holding.security_remapped? && @holding.account.linked?) || @holding.account.can_delete_holdings? || !@holding.security.offline? || @holding.security.provider_status == :provider_unavailable %>
<% dialog.with_section(title: t(".settings"), open: true) do %>
<div class="pb-4">
<% if @holding.security.provider_status == :provider_unavailable %>
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
<div class="text-sm space-y-1 flex-1">
<h4 class="text-primary"><%= t(".switch_provider_label") %></h4>
<p class="text-secondary"><%= t(".switch_provider_description", provider: @holding.security.price_provider&.humanize || "Unknown") %></p>
<% if Security.providers.any? %>
<%= form_with url: remap_security_holding_path(@holding), method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "change" } do |f| %>
<div class="form-field combobox mt-2">
<%= f.combobox :security_id,
securities_path(country_code: Current.family.country),
id: "switch_provider_security_id",
placeholder: t(".search_security_placeholder"),
data: { "auto-submit-form-target": "auto" } %>
</div>
<% end %>
<% else %>
<p class="text-xs text-secondary mt-2"><%= t(".no_security_provider") %></p>
<% end %>
</div>
</div>
<% end %>
<% if @holding.security_remapped? && @holding.account.linked? %>
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".security_remapped_label") %></h4>
<p class="text-secondary"><%= t(".provider_sent", ticker: @holding.provider_security.ticker) %></p>
</div>
<%= button_to t(".reset_to_provider"),
reset_security_holding_path(@holding),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover",
form: { data: { turbo: false } },
data: { turbo_confirm: {
title: t(".reset_confirm_title"),
body: t(".reset_confirm_body", current: @holding.security.ticker, original: @holding.provider_security.ticker)
} } %>
</div>
<% end %>
<% unless @holding.security.offline? %>
<div id="<%= dom_id(@holding, :market_data_section) %>" class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".market_data_label") %></h4>
<p class="text-secondary">
<%= t(".last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t(".never") %>
</p>
</div>
<%= button_to t(".market_data_sync_button"),
sync_prices_holding_path(@holding),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover",
data: { loading_button_target: "button" },
form: { data: {
controller: "loading-button",
action: "submit->loading-button#showLoading",
loading_button_loading_text_value: t(".syncing")
} } %>
</div>
<% end %>
<% if @holding.cost_basis_locked? %>
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".cost_basis_locked_label") %></h4>
<p class="text-secondary"><%= t(".cost_basis_locked_description") %></p>
</div>
<%= button_to t(".unlock_cost_basis"),
unlock_cost_basis_holding_path(@holding),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover",
form: { data: { turbo: false } },
data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %>
</div>
<% end %>
<% if @holding.account.can_delete_holdings? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
holding_path(@holding),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
data: { turbo_confirm: true } %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>