Files
sure/app/views/transactions/_form.html.erb
T
4930aacdb8 feat: create merchant inline from transaction detail (#3106)
* feat: create merchant inline from transaction detail

Add a searchable "create or select" merchant combobox (DS::MerchantSelect,
mirroring the existing DS::TagSelect pattern) so a new FamilyMerchant can be
created directly from the transaction detail and new-transaction forms,
instead of requiring a trip to Settings > Merchants first.

FamilyMerchantsController#create now also responds to JSON so the combobox
can create-and-select a merchant without a full page reload.

* fix: address PR review feedback on merchant inline creation

- Handle Turbo validation failures in FamilyMerchantsController#create
  (missing format.turbo_stream branch raised ActionController::UnknownFormat)
- Guard connect() so disabled merchant selectors (no menu target) don't throw
- Select the exact match (or block form submission) on Enter when the
  create row is hidden
- Catch network/parse failures in createMerchant and show an error
- Localize the fallback "could not create merchant" error message
- Move the create button/error text outside the listbox and add
  aria-controls for accessibility
- Align create-option avatar size with the selected-value display

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: keep created merchant option inside the listbox, move logo URL logic to component

- Add a dedicated listbox target and insert newly created merchant
  options inside it (previously landed outside role="listbox" after
  the create button was moved out in the prior review-fix commit)
- Move selected-merchant logo URL transformation out of the template
  into DS::MerchantSelect#selected_merchant_logo_url

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: address maintainer review feedback on merchant inline creation

- Drop the explicit format.turbo_stream branch in the merchant creation
  failure path: respond_to's turbo_stream matching forces the response
  Content-Type to text/vnd.turbo-stream.html before the block runs, so
  render :new, formats: [:html] only changed template lookup, not the
  Content-Type. Turbo's client then received a turbo-stream response
  with no <turbo-stream> tags and silently did nothing. Leaving
  turbo_stream undeclared lets Rails negotiate down to format.html,
  which renders :new with the correct text/html type.
- Add truncate/shrink-0 classes to the merchant option partial's name
  and avatar spans so they match the selected-value display once
  updateSelectionDisplay clones them into the trigger button.
- Add a regression test simulating a Turbo form submission (Accept:
  turbo-stream, text/html) against a duplicate merchant name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:40:46 +02:00

119 lines
4.8 KiB
ERB

<%# locals: (entry:, account_currencies:, manual_accounts:, categories:, merchants:, tags:) %>
<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4", data: { controller: "transaction-form", transaction_form_exchange_rate_url_value: exchange_rate_path, transaction_form_account_currencies_value: account_currencies.to_json } do |f| %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<section data-controller="transaction-type-tabs">
<%= render "shared/transaction_type_tabs", active_tab: params[:nature] == "inflow" ? "income" : "expense", account_id: params[:account_id] %>
<%= f.hidden_field :nature, value: params[:nature] || "outflow", data: { "transaction-type-tabs-target": "natureField" } %>
<%= f.hidden_field :entryable_type, value: "Transaction" %>
</section>
<section class="space-y-2">
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<% if @entry.account_id %>
<%= f.hidden_field :account_id, data: { transaction_form_target: "account" } %>
<% else %>
<%= f.collection_select :account_id, manual_accounts, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo, searchable: true }, required: true, class: "form-field__input text-ellipsis", data: { transaction_form_target: "account", action: "change->transaction-form#checkCurrencyDifference" } %>
<% end %>
<%= f.money_field :amount,
label: t(".amount"),
required: true,
container_class: "money-field-wrapper",
amount_data: { transaction_form_target: "amount", action: "input->transaction-form#onAmountChange" },
currency_data: { transaction_form_target: "currency", action: "change->transaction-form#onCurrencyChange" } %>
<%= f.fields_for :entryable do |ef| %>
<%= render DS::CategorySelect.new(
form: ef,
categories: categories,
selected_id: ef.object.category_id,
blank_label: t(".category_prompt")
) %>
<% end %>
<%= f.date_field :date,
label: t(".date"),
required: true,
min: Entry.min_supported_date,
max: Date.current,
value: f.object.date || Date.current,
data: { transaction_form_target: "date", action: "change->transaction-form#checkCurrencyDifference" } %>
<% convert_input = capture do %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.number_field :exchange_rate,
label: t("shared.exchange_rate_tabs.exchange_rate"),
min: "0.00000000000001",
step: "0.00000000000001",
placeholder: "1.0",
class: "form-field__input",
data: {
transaction_form_target: "exchangeRateField",
action: "input->transaction-form#onConvertExchangeRateChange"
} %>
<% end %>
<% end %>
<% destination_input = capture do %>
<%= number_field_tag :destination_amount,
nil,
id: "transaction_form_destination_amount",
class: "form-field__input",
autocomplete: "off",
min: "0",
step: "0.00000001",
placeholder: "92",
data: {
transaction_form_target: "destinationAmount",
action: "input->transaction-form#onCalculateRateDestinationAmountChange"
} %>
<% end %>
<%= render "shared/exchange_rate_tabs",
controller_id: "transaction-form",
controller_key: "transaction_form",
help_text: t("shared.exchange_rate_tabs.exchange_rate_help"),
convert_tab_label: t("shared.exchange_rate_tabs.convert_tab"),
calculate_rate_tab_label: t("shared.exchange_rate_tabs.calculate_rate_tab"),
destination_amount_label: t("shared.exchange_rate_tabs.destination_amount"),
exchange_rate_label: t("shared.exchange_rate_tabs.exchange_rate"),
convert_input: convert_input,
destination_input: destination_input %>
</section>
<%= render DS::Disclosure.new(title: t(".details")) do %>
<section class="space-y-2">
<%= f.fields_for :entryable do |ef| %>
<%= render DS::MerchantSelect.new(
form: ef,
method: :merchant_id,
merchants: merchants,
selected_id: ef.object.merchant_id,
include_blank: t(".none"),
label: t(".merchant_label")
) %>
<%= render DS::TagSelect.new(
form: ef,
tags: tags,
selected_ids: ef.object.tag_ids
) %>
<% end %>
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
rows: 5,
"data-auto-submit-form-target": "auto" %>
</section>
<% end %>
<section>
<%= f.submit t(".submit") %>
</section>
<% end %>