Improve convert-to-trade security selection with search-first UX (#703)

* Enhance security handling logic:
- Prioritize user's country in sorting securities and country codes.
- Add comprehensive mapping for MIC codes to user-friendly exchange names.
- Revamp combobox to consistently pull from a provider when available.
- Improve handling of custom ticker and exchange input fields.

* Localize securities combobox display and exchange labels.

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-19 09:49:51 -05:00
committed by GitHub
parent bf9bcae600
commit 237035c8d4
9 changed files with 250 additions and 79 deletions

View File

@@ -3,26 +3,6 @@
# otherwise determine default based on amount sign
# Negative amount (money going out) = Buy, Positive (money coming in) = Sell
default_label = params[:activity_label].presence || (@entry.amount > 0 ? "Sell" : "Buy")
# Get unique securities from account holdings for ticker suggestions
# Include current price for display and validation
account_securities = @entry.account.holdings
.includes(:security)
.where.not(security: nil)
.map(&:security)
.uniq(&:ticker)
.sort_by(&:ticker)
# Build options with price data for validation
security_options_with_prices = account_securities.map do |s|
price = s.current_price
price_display = price ? " (#{format_money(price)})" : ""
label = "#{s.ticker}#{price_display}"
label += " - #{s.name.truncate(25)}" if s.name.present?
# Store price as cents for JS comparison (amount is BigDecimal, multiply by 100 for cents)
price_cents = price ? (price.amount * 100).to_i : nil
[label, s.id, { "data-price-cents" => price_cents, "data-ticker" => s.ticker }]
end
%>
<%= render DS::Dialog.new(variant: "modal", reload_on_close: true) do |dialog| %>
@@ -53,34 +33,55 @@
<div class="space-y-4">
<div class="space-y-1">
<%= f.label :security_id, t(".security_label"), class: "font-medium text-sm text-primary block" %>
<% if account_securities.any? %>
<%= f.select :security_id,
options_for_select(
[[t(".security_prompt"), ""]] +
security_options_with_prices +
[[t(".security_custom"), "__custom__"]],
nil
),
{},
{
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
data: {
action: "change->convert-to-trade#toggleCustomTicker change->convert-to-trade#validatePrice",
convert_to_trade_target: "tickerSelect"
}
} %>
<p class="text-xs text-secondary mt-1"><%= t(".security_not_listed_hint") %></p>
<div class="hidden mt-2" data-convert-to-trade-target="customWrapper">
<% if Security.provider.present? %>
<div class="form-field combobox">
<%= f.combobox :ticker,
securities_path(country_code: Current.family.country),
name_when_new: "custom_ticker",
placeholder: t(".ticker_search_placeholder"),
data: { convert_to_trade_target: "customField" } %>
</div>
<p class="text-xs text-secondary mt-1"><%= t(".ticker_search_hint") %></p>
<% else %>
<% if Security.provider.present? %>
<%# Always use searchable combobox when provider available - prevents picking wrong similar tickers %>
<div class="form-field combobox" style="--hw-handle-width: 0; --hw-handle-image: none;">
<%= f.combobox :ticker,
securities_path(country_code: Current.family.country),
name_when_new: "custom_ticker",
placeholder: t(".ticker_search_placeholder"),
required: true %>
</div>
<p class="text-xs text-secondary"><%= t(".ticker_search_hint") %></p>
<% else %>
<%# No provider: show existing holdings dropdown with custom option %>
<%
# Get unique securities from account holdings for dropdown
account_securities = @entry.account.holdings
.includes(:security)
.where.not(security: nil)
.map(&:security)
.uniq(&:ticker)
.sort_by(&:ticker)
# Build options with price data for validation
security_options_with_prices = account_securities.map do |s|
price = s.current_price
price_display = price ? " (#{format_money(price)})" : ""
label = "#{s.ticker}#{price_display}"
label += " - #{s.name.truncate(25)}" if s.name.present?
price_cents = price ? (price.amount * 100).to_i : nil
[label, s.id, { "data-price-cents" => price_cents, "data-ticker" => s.ticker }]
end
%>
<% if account_securities.any? %>
<%= f.select :security_id,
options_for_select(
[[t(".security_prompt"), ""]] +
security_options_with_prices +
[[t(".security_custom"), "__custom__"]],
nil
),
{},
{
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
data: {
action: "change->convert-to-trade#toggleCustomTicker change->convert-to-trade#validatePrice",
convert_to_trade_target: "tickerSelect"
}
} %>
<p class="text-xs text-secondary mt-1"><%= t(".security_not_listed_hint") %></p>
<div class="hidden mt-2" data-convert-to-trade-target="customWrapper">
<%= f.text_field :custom_ticker,
placeholder: t(".ticker_placeholder"),
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
@@ -88,18 +89,7 @@
title: t(".ticker_hint"),
data: { convert_to_trade_target: "customField" } %>
<p class="text-xs text-secondary mt-1"><%= t(".ticker_hint") %></p>
<% end %>
</div>
<% else %>
<% if Security.provider.present? %>
<div class="form-field combobox">
<%= f.combobox :ticker,
securities_path(country_code: Current.family.country),
name_when_new: "custom_ticker",
placeholder: t(".ticker_search_placeholder"),
required: true %>
</div>
<p class="text-xs text-secondary"><%= t(".ticker_search_hint") %></p>
<% else %>
<%= f.text_field :ticker,
placeholder: t(".ticker_placeholder"),
@@ -166,15 +156,18 @@
<p class="text-xs text-secondary"><%= t(".trade_type_hint") %></p>
</div>
<div class="space-y-1">
<%= f.label :exchange_operating_mic, t(".exchange_label"), class: "font-medium text-sm text-primary block" %>
<%= f.text_field :exchange_operating_mic,
placeholder: t(".exchange_placeholder"),
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
pattern: "[A-Z]{4}",
title: t(".exchange_hint") %>
<p class="text-xs text-secondary"><%= t(".exchange_hint") %></p>
</div>
<%# Only show exchange field when no provider - combobox selections already include exchange %>
<% unless Security.provider.present? %>
<div class="space-y-1">
<%= f.label :exchange_operating_mic, t(".exchange_label"), class: "font-medium text-sm text-primary block" %>
<%= f.text_field :exchange_operating_mic,
placeholder: t(".exchange_placeholder"),
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
pattern: "[A-Z]{4}",
title: t(".exchange_hint") %>
<p class="text-xs text-secondary"><%= t(".exchange_hint") %></p>
</div>
<% end %>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-primary">