diff --git a/app/components/DS/merchant_select.html.erb b/app/components/DS/merchant_select.html.erb new file mode 100644 index 000000000..e85c15478 --- /dev/null +++ b/app/components/DS/merchant_select.html.erb @@ -0,0 +1,101 @@ +
" + data-action="click@window->merchant-select#handleOutsideClick keydown->merchant-select#handleKeydown"> +
+
+ <%= form.label method, label, class: "form-field__label" if label.present? %> + + +
+
+ + > + + <% unless disabled %> + + <% end %> +
diff --git a/app/components/DS/merchant_select.rb b/app/components/DS/merchant_select.rb new file mode 100644 index 000000000..99649ef7b --- /dev/null +++ b/app/components/DS/merchant_select.rb @@ -0,0 +1,45 @@ +class DS::MerchantSelect < DesignSystemComponent + attr_reader :form, :method, :merchants, :selected_id, :disabled, :auto_submit, + :menu_placement, :label, :include_blank + + MENU_PLACEMENTS = %w[auto down up].freeze + + def initialize(form:, method:, merchants:, selected_id:, disabled: false, auto_submit: false, + menu_placement: :auto, label: nil, include_blank: nil) + @form = form + @method = method + @merchants = merchants + @selected_id = selected_id&.to_s + @disabled = disabled + @auto_submit = auto_submit + @menu_placement = normalize_menu_placement(menu_placement) + @label = label + @include_blank = include_blank + end + + def field_name + "#{form.object_name}[#{method}]" + end + + def menu_id + @menu_id ||= "merchant_select_#{field_name.gsub(/\W+/, "_")}_#{object_id}" + end + + def selected_merchant + merchants.find { |merchant| merchant.id.to_s == selected_id } + end + + def selected_merchant_logo_url + merchant = selected_merchant + return nil unless merchant&.respond_to?(:logo_url) && merchant.logo_url.present? + + Setting.transform_brand_fetch_url(merchant.logo_url) + end + + private + + def normalize_menu_placement(value) + normalized = value.to_s.downcase + MENU_PLACEMENTS.include?(normalized) ? normalized : "auto" + end +end diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index eb3f3a52d..a8bb4580d 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -47,9 +47,20 @@ class FamilyMerchantsController < ApplicationController respond_to do |format| format.html { redirect_to family_merchants_path, notice: t(".success") } format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } + format.json { render json: merchant_json(@family_merchant), status: :created } end else - render :new, status: :unprocessable_entity + respond_to do |format| + # No explicit format.turbo_stream branch: Turbo's form submissions send an + # Accept header that prefers turbo-stream, but forcing that format here would + # lock the response's Content-Type to turbo-stream while still rendering the + # plain :new HTML template — Turbo's client then sees a turbo-stream + # Content-Type with no tags in the body and does nothing. + # Leaving turbo-stream undeclared lets Rails' content negotiation fall back to + # format.html below, which renders :new with the correct text/html type. + format.html { render :new, status: :unprocessable_entity } + format.json { render json: { errors: @family_merchant.errors.full_messages }, status: :unprocessable_entity } + end end end @@ -158,6 +169,16 @@ class FamilyMerchantsController < ApplicationController params.require(key).permit(:name, :color, :website_url) end + def merchant_json(merchant) + merchant.as_json(only: %i[id name]).merge( + html: render_to_string( + partial: "DS/merchant_select/option", + formats: [ :html ], + locals: { merchant: merchant, selected: true, view_helpers: helpers } + ) + ) + end + def all_family_merchants family_merchant_ids = Current.family.merchants.pluck(:id) provider_merchant_ids = Current.family.assigned_merchants.where(type: "ProviderMerchant").pluck(:id) diff --git a/app/javascript/controllers/merchant_select_controller.js b/app/javascript/controllers/merchant_select_controller.js new file mode 100644 index 000000000..83522820b --- /dev/null +++ b/app/javascript/controllers/merchant_select_controller.js @@ -0,0 +1,375 @@ +import { autoUpdate } from "@floating-ui/dom"; +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = [ + "button", + "menu", + "search", + "option", + "selectionContainer", + "hiddenInput", + "createForm", + "createError", + "listbox", + ]; + + static values = { + createUrl: String, + fieldName: String, + disabled: Boolean, + autoSubmit: Boolean, + menuPlacement: { type: String, default: "auto" }, + offset: { type: Number, default: 6 }, + errorMessage: String, + }; + + connect() { + this.creating = false; + this.isOpen = false; + this.selectedId = this.hiddenInputTarget.value || ""; + if (this.disabledValue || !this.hasMenuTarget) return; + this.observeMenuResize(); + } + + disconnect() { + this.stopAutoUpdate(); + if (this.resizeObserver) this.resizeObserver.disconnect(); + } + + toggle(event) { + event.preventDefault(); + if (this.disabledValue) return; + + this.isOpen ? this.close() : this.open(); + } + + open() { + this.isOpen = true; + this.buttonTarget.setAttribute("aria-expanded", "true"); + this.menuTarget.classList.remove("hidden"); + this.searchTarget.value = ""; + this.filter(); + this.startAutoUpdate(); + + requestAnimationFrame(() => { + this.menuTarget.classList.remove( + "opacity-0", + "-translate-y-1", + "pointer-events-none", + ); + this.menuTarget.classList.add("opacity-100", "translate-y-0"); + this.updatePosition(); + this.searchTarget.focus({ preventScroll: true }); + }); + } + + close() { + this.isOpen = false; + this.stopAutoUpdate(); + this.buttonTarget.setAttribute("aria-expanded", "false"); + this.menuTarget.classList.remove("opacity-100", "translate-y-0"); + this.menuTarget.classList.add( + "opacity-0", + "-translate-y-1", + "pointer-events-none", + ); + + setTimeout(() => { + if (!this.isOpen) this.menuTarget.classList.add("hidden"); + }, 150); + } + + selectOption(event) { + event.preventDefault(); + this.applySelection(event.currentTarget); + this.close(); + this.buttonTarget.focus({ preventScroll: true }); + this.submitForm(); + } + + applySelection(option) { + const id = option.dataset.merchantId || ""; + + this.selectedId = id; + this.hiddenInputTarget.value = id; + this.hiddenInputTarget.dispatchEvent(new Event("change", { bubbles: true })); + + this.optionTargets.forEach((target) => this.updateOptionState(target, id)); + this.updateSelectionDisplay(option); + } + + updateOptionState(option, selectedId) { + const isSelected = (option.dataset.merchantId || "") === selectedId; + option.setAttribute("aria-selected", isSelected ? "true" : "false"); + option.classList.toggle("bg-container-inset", isSelected); + + const icon = option.querySelector(".check-icon"); + if (icon) icon.classList.toggle("hidden", !isSelected); + } + + updateSelectionDisplay(option) { + this.selectionContainerTarget.innerHTML = ""; + + Array.from(option.children).forEach((child) => { + if (child.classList.contains("check-icon")) return; + this.selectionContainerTarget.appendChild(child.cloneNode(true)); + }); + } + + filter() { + this.clearCreateError(); + + const query = this.searchTarget.value.trim().toLowerCase(); + let hasExactMatch = false; + + this.optionTargets.forEach((option) => { + const name = (option.dataset.filterName || "").toLowerCase(); + const isMatch = name.includes(query); + option.classList.toggle("hidden", !isMatch); + + if (name === query) hasExactMatch = true; + }); + + const canCreate = query.length > 0 && !hasExactMatch; + this.createFormTarget.classList.toggle("hidden", !canCreate); + this.createFormTarget.classList.toggle("flex", canCreate); + this.createNameElement.textContent = this.searchTarget.value.trim(); + } + + handleSearchKeydown(event) { + if (event.key !== "Enter") return; + + if (!this.createFormTarget.classList.contains("hidden") && !this.creating) { + event.preventDefault(); + this.createMerchant(); + return; + } + + event.preventDefault(); + + const query = this.searchTarget.value.trim().toLowerCase(); + const match = this.optionTargets.find( + (option) => + !option.classList.contains("hidden") && + (option.dataset.filterName || "").toLowerCase() === query, + ); + + if (match) { + this.applySelection(match); + this.close(); + this.submitForm(); + } + } + + async createMerchant() { + if (this.creating) return; + + const name = this.searchTarget.value.trim(); + if (!name) return; + + this.creating = true; + this.createFormTarget.disabled = true; + this.clearCreateError(); + + try { + const response = await fetch(this.createUrlValue, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfToken, + }, + body: JSON.stringify({ + family_merchant: { name }, + }), + }); + + const merchant = await this.parseJson(response); + + if (!response.ok) { + this.showCreateError(merchant.errors?.join(", ") || merchant.error); + return; + } + + this.listboxTarget.insertAdjacentHTML("beforeend", merchant.html); + const newOption = this.listboxTarget.lastElementChild; + this.applySelection(newOption); + this.searchTarget.value = ""; + this.filter(); + this.close(); + this.submitForm(); + } catch { + this.showCreateError(); + } finally { + this.creating = false; + this.createFormTarget.disabled = false; + } + } + + handleOutsideClick(event) { + if (this.isOpen && !this.element.contains(event.target)) this.close(); + } + + handleKeydown(event) { + if (!this.isOpen) return; + + if (event.key === "Escape") { + event.preventDefault(); + this.close(); + this.buttonTarget.focus(); + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + this.moveActiveOption(event.key === "ArrowDown" ? 1 : -1); + return; + } + + if ( + event.key === "Enter" && + event.target.getAttribute("role") === "option" + ) { + event.preventDefault(); + event.target.click(); + } + } + + moveActiveOption(delta) { + const options = this.visibleOptions; + if (options.length === 0) return; + + const currentIndex = options.indexOf(document.activeElement); + const nextIndex = + currentIndex === -1 + ? delta > 0 + ? 0 + : options.length - 1 + : (currentIndex + delta + options.length) % options.length; + + options[nextIndex].focus({ preventScroll: true }); + options[nextIndex].scrollIntoView({ block: "nearest" }); + } + + get visibleOptions() { + return this.optionTargets.filter( + (option) => !option.classList.contains("hidden"), + ); + } + + async submitForm() { + if (!this.autoSubmitValue) return; + + const form = this.element.closest("form"); + const controllers = (form?.dataset.controller || "").split(/\s+/); + if (form && controllers.includes("auto-submit-form")) { + form.requestSubmit(); + } + } + + startAutoUpdate() { + if (!this._cleanup && this.hasButtonTarget && this.hasMenuTarget) { + this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => + this.updatePosition(), + ); + } + } + + stopAutoUpdate() { + if (!this._cleanup) return; + + this._cleanup(); + this._cleanup = null; + } + + observeMenuResize() { + this.resizeObserver = new ResizeObserver(() => { + if (this.isOpen) requestAnimationFrame(() => this.updatePosition()); + }); + this.resizeObserver.observe(this.menuTarget); + } + + getScrollParent(element) { + let parent = element.parentElement; + while (parent) { + const style = getComputedStyle(parent); + const overflowY = style.overflowY; + if (overflowY === "auto" || overflowY === "scroll") return parent; + parent = parent.parentElement; + } + return document.documentElement; + } + + placementMode() { + const mode = (this.menuPlacementValue || "auto").toLowerCase(); + return ["auto", "down", "up"].includes(mode) ? mode : "auto"; + } + + updatePosition() { + if (!this.hasButtonTarget || !this.hasMenuTarget || !this.isOpen) return; + + const container = this.getScrollParent(this.element); + const containerRect = container.getBoundingClientRect(); + const buttonRect = this.buttonTarget.getBoundingClientRect(); + const menuHeight = this.menuTarget.scrollHeight; + + const spaceBelow = containerRect.bottom - buttonRect.bottom; + const spaceAbove = buttonRect.top - containerRect.top; + const placement = this.placementMode(); + const shouldOpenUp = + placement === "up" || + (placement === "auto" && + spaceBelow < menuHeight && + spaceAbove > spaceBelow); + + this.menuTarget.style.left = "0"; + this.menuTarget.style.width = "100%"; + this.menuTarget.style.top = ""; + this.menuTarget.style.bottom = ""; + this.menuTarget.style.overflowY = "auto"; + + if (shouldOpenUp) { + this.menuTarget.style.bottom = "100%"; + this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`; + } else { + this.menuTarget.style.top = "100%"; + this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`; + } + } + + get csrfToken() { + return document.querySelector("meta[name='csrf-token']")?.content; + } + + get createNameElement() { + return this.createFormTarget.querySelector( + "[data-merchant-select-create-name]", + ); + } + + showCreateError(message) { + if (!this.hasCreateErrorTarget) return; + + this.createErrorTarget.textContent = message || this.errorMessageValue; + this.createErrorTarget.classList.remove("hidden"); + this.searchTarget.setAttribute("aria-invalid", "true"); + this.searchTarget.focus({ preventScroll: true }); + } + + async parseJson(response) { + try { + return await response.json(); + } catch { + return {}; + } + } + + clearCreateError() { + if (!this.hasCreateErrorTarget) return; + + this.createErrorTarget.textContent = ""; + this.createErrorTarget.classList.add("hidden"); + this.searchTarget.removeAttribute("aria-invalid"); + } +} diff --git a/app/views/DS/merchant_select/_option.html.erb b/app/views/DS/merchant_select/_option.html.erb new file mode 100644 index 000000000..42819efcb --- /dev/null +++ b/app/views/DS/merchant_select/_option.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 02eb479de..ec068b645 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -90,14 +90,14 @@ <%= render DS::Disclosure.new(title: t(".details")) do %>
<%= f.fields_for :entryable do |ef| %> - <%= ef.collection_select :merchant_id, - merchants, - :id, :name, - { include_blank: t(".none"), - label: t(".merchant_label"), - variant: :logo, - searchable: true, - menu_placement: :auto } %> + <%= 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, diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 132ffff80..fe6459ed2 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -112,13 +112,16 @@ { label: t(".account_label") }, { disabled: true } %> <%= f.fields_for :entryable do |ef| %> - <%= ef.collection_select :merchant_id, - Current.family.available_merchants_for(Current.user).alphabetically, - :id, :name, - { include_blank: t(".none"), - label: t(".merchant_label"), - variant: :logo, searchable: true, menu_placement: :auto, disabled: @entry.split_child? || !can_annotate_entry? }, - "data-auto-submit-form-target": "auto" %> + <%= render DS::MerchantSelect.new( + form: ef, + method: :merchant_id, + merchants: Current.family.available_merchants_for(Current.user).alphabetically, + selected_id: ef.object.merchant_id, + include_blank: t(".none"), + label: t(".merchant_label"), + disabled: @entry.split_child? || !can_annotate_entry?, + auto_submit: true + ) %> <%= render DS::TagSelect.new( form: ef, tags: Current.family.tags.alphabetically, diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index bd1a41606..e46bf495d 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -45,6 +45,9 @@ en: note_label: Notes note_placeholder: Enter a note create_tag: Create + create_merchant: Create + create_merchant_error: Could not create merchant + merchant_search_placeholder: Search or create merchant submit: Add transaction tag_search_placeholder: Search or create tag tags_label: Tags diff --git a/test/controllers/family_merchants_controller_test.rb b/test/controllers/family_merchants_controller_test.rb index 16ee04f75..b3fb6a7f5 100644 --- a/test/controllers/family_merchants_controller_test.rb +++ b/test/controllers/family_merchants_controller_test.rb @@ -40,6 +40,39 @@ class FamilyMerchantsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to family_merchants_path end + test "should create merchant as json" do + assert_difference("FamilyMerchant.count") do + post family_merchants_url(format: :json), params: { family_merchant: { name: "Quick Merchant" } } + end + + assert_response :created + response_body = JSON.parse(response.body) + assert_equal "Quick Merchant", response_body["name"] + assert response_body["id"].present? + assert_includes response_body["html"], "data-merchant-select-target=\"option\"" + end + + test "should return json validation errors for duplicate merchant name" do + assert_no_difference("FamilyMerchant.count") do + post family_merchants_url(format: :json), params: { family_merchant: { name: @merchant.name } } + end + + assert_response :unprocessable_entity + assert JSON.parse(response.body)["errors"].present? + end + + test "renders html (not turbo-stream) for duplicate merchant name when submitted like a Turbo form" do + assert_no_difference("FamilyMerchant.count") do + post family_merchants_url, + params: { family_merchant: { name: @merchant.name } }, + headers: { "Accept" => "text/vnd.turbo-stream.html, text/html, application/xhtml+xml" } + end + + assert_response :unprocessable_entity + assert_equal "text/html", response.media_type + assert_includes response.body, @merchant.name + end + test "enhance enqueues job and redirects" do assert_enqueued_with(job: EnhanceProviderMerchantsJob) do post enhance_family_merchants_path