Files
sure/app/javascript/controllers/merchant_select_controller.js
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

376 lines
10 KiB
JavaScript

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");
}
}