mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
Gerald
parent
f05559cdc1
commit
4930aacdb8
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user