Enhance ticker search and validation in "Convert to Trade" form (#688)

- Updated resolution logic to support combobox-based ticker selection and validation.
- Added market price display with validation against entered prices to detect significant mismatches.
- Improved messaging and UI for custom ticker input and market price warnings.

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-17 16:46:15 -05:00
committed by GitHub
parent 47e0185409
commit 0f6dd536df
5 changed files with 184 additions and 32 deletions

View File

@@ -5,12 +5,24 @@
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| %>
@@ -20,7 +32,7 @@
<% end %>
<% dialog.with_body do %>
<%= form_with url: create_trade_from_transaction_transaction_path(@transaction), method: :post, class: "space-y-4", data: { controller: "convert-to-trade", turbo_frame: "_top" } do |f| %>
<%= form_with url: create_trade_from_transaction_transaction_path(@transaction), method: :post, class: "space-y-4", data: { controller: "convert-to-trade", turbo_frame: "_top", convert_to_trade_amount_cents_value: (@entry.amount_money.abs.amount * 100).to_i, convert_to_trade_currency_value: @entry.currency } do |f| %>
<!-- Pre-filled Transaction Info (Read-only) -->
<div class="space-y-2 p-3 bg-surface-inset rounded-lg border border-primary">
<div class="text-sm">
@@ -45,7 +57,7 @@
<%= f.select :security_id,
options_for_select(
[[t(".security_prompt"), ""]] +
account_securities.map { |s| ["#{s.ticker}#{s.name.present? ? " - #{s.name.truncate(30)}" : ""}", s.id] } +
security_options_with_prices +
[[t(".security_custom"), "__custom__"]],
nil
),
@@ -53,27 +65,50 @@
{
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",
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,
<% 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 %>
<%= 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",
pattern: "[A-Za-z0-9.:-]{1,20}",
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"),
required: true,
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
pattern: "[A-Za-z0-9.:-]{1,20}",
title: t(".ticker_hint"),
data: { convert_to_trade_target: "customField" } %>
</div>
<p class="text-xs text-secondary"><%= t(".security_hint") %></p>
<% else %>
<%= f.text_field :ticker,
placeholder: t(".ticker_placeholder"),
required: true,
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
pattern: "[A-Za-z0-9.:-]{1,20}",
title: t(".ticker_hint") %>
<p class="text-xs text-secondary"><%= t(".ticker_hint") %></p>
title: t(".ticker_hint") %>
<p class="text-xs text-secondary"><%= t(".ticker_hint") %></p>
<% end %>
<% end %>
</div>
@@ -83,7 +118,11 @@
<%= f.number_field :qty,
step: "any",
placeholder: t(".quantity_placeholder"),
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container" %>
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
data: {
action: "input->convert-to-trade#validatePrice",
convert_to_trade_target: "qtyField"
} %>
<p class="text-xs text-secondary"><%= t(".quantity_hint") %></p>
</div>
@@ -93,11 +132,26 @@
step: "0.0001",
min: "0",
placeholder: t(".price_placeholder"),
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container" %>
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
data: {
action: "input->convert-to-trade#validatePrice",
convert_to_trade_target: "priceField"
} %>
<p class="text-xs text-secondary"><%= t(".price_hint", currency: @entry.currency) %></p>
</div>
</div>
<!-- Price mismatch warning -->
<div class="hidden p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg" data-convert-to-trade-target="priceWarning">
<div class="flex items-start gap-2">
<%= icon "alert-triangle", size: "sm", class: "text-yellow-600 dark:text-yellow-500 mt-0.5 shrink-0" %>
<div class="text-sm">
<p class="font-medium text-yellow-800 dark:text-yellow-200"><%= t(".price_mismatch_title") %></p>
<p class="text-yellow-700 dark:text-yellow-300" data-convert-to-trade-target="priceWarningMessage"></p>
</div>
</div>
</div>
<p class="text-xs text-secondary bg-surface-inset p-2 rounded">
<%= icon "info", size: "xs", class: "inline-block mr-1" %>
<%= t(".qty_or_price_hint", amount: format_money(@entry.amount_money.abs)) %>