From e156d8bfd86bcd1fb0f4d8f082fdf70b34bc0123 Mon Sep 17 00:00:00 2001 From: Bryce Date: Sat, 30 May 2026 09:21:39 -0700 Subject: [PATCH] fixes vendor selection bug --- e2e/transaction-edit.spec.ts | 87 +++++++ src/clj/auto_ap/ssr/components/inputs.clj | 243 +++++++++--------- src/clj/auto_ap/ssr/transaction/edit.clj | 25 +- .../edit_simple_advanced_mode_test.clj | 100 +++++-- 4 files changed, 305 insertions(+), 150 deletions(-) diff --git a/e2e/transaction-edit.spec.ts b/e2e/transaction-edit.spec.ts index 0ce1b3f4..297af94d 100644 --- a/e2e/transaction-edit.spec.ts +++ b/e2e/transaction-edit.spec.ts @@ -455,6 +455,93 @@ test.describe('Transaction Edit Vendor Pre-population', () => { }); }); +// Drives the *real* vendor typeahead the way a user does: open the dropdown, +// click a rendered result. The vendor search is backed by Solr (unavailable in +// tests), so the result option is injected into the typeahead's Alpine +// `elements` instead of being fetched. Everything else -- the dropdown's own +// search input firing a native `change` on blur, the `value = element` click +// handler, the Alpine reactivity, and the HTMX round-trip to +// `edit-vendor-changed` -- runs exactly as in production. This is the flow that +// regressed: a stale native `change` from the search input used to win the race +// and revert the vendor to its previous value. +async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) { + const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first(); + const typeahead = wrapper.locator('div.relative[x-data]').first(); + + // Open the dropdown (tippy renders the popper into [data-tippy-root]). + await typeahead.locator('a[x-ref="input"]').click(); + + const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); + await search.waitFor({ state: 'visible' }); + + // Type under the 3-char search threshold so no Solr request fires and clears + // our injected option, while still dirtying the input so it fires a native + // `change` on blur -- the event that used to clobber the selection. + await search.fill('te'); + + // Inject a clickable result into the typeahead's Alpine state. + await typeahead.evaluate( + (el: HTMLElement, opt: { id: number; label: string }) => { + (window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }]; + }, + { id: vendorId, label: vendorName } + ); + + // Click the rendered option: fires the search input's native change (stale + // value) AND the synthetic change carrying the new value, then HTMX swaps. + await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click(); + + await page.waitForResponse( + (response: any) => + response.url().includes('/edit-vendor-changed') && response.status() === 200 + ); + await page.waitForTimeout(500); +} + +// Opens the edit modal and activates the Manual tab, waiting on the vendor +// typeahead rather than the account grid (which only exists in advanced mode). +async function openManualVendorSection(page: any, transactionIndex: number) { + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); + + const editButton = page + .locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]') + .nth(transactionIndex); + await editButton.click(); + + await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); + await page.waitForSelector('#wizardmodal'); + await page.click('button:has-text("Manual")'); + await page.waitForSelector('div[hx-post*="edit-vendor-changed"]'); +} + +test.describe('Transaction Edit Vendor Selection', () => { + test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => { + await openManualVendorSection(page, 3); + + const testInfo = await getTestInfo(page); + const vendorId: number = testInfo.accounts.vendor; + + await selectVendorViaDropdown(page, vendorId, 'Test Vendor'); + + // The displayed vendor label must reflect the selection after the HTMX + // round-trip. Before the fix this reverted to blank because a stale + // `change` event submitted the previous vendor and its response won. + const label = page + .locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]') + .first(); + await expect(label).toHaveText('Test Vendor'); + + // The server-rendered hidden input must carry the newly selected vendor id. + const hidden = page + .locator( + 'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]' + ) + .first(); + await expect(hidden).toHaveValue(vendorId.toString()); + }); +}); + test.describe('Transaction Link Date Display', () => { test('should show payment date when linking to payment', async ({ page }) => { await openEditModalForTransaction(page, 'Transaction for payment link'); diff --git a/src/clj/auto_ap/ssr/components/inputs.clj b/src/clj/auto_ap/ssr/components/inputs.clj index d7e9dddc..ae865616 100644 --- a/src/clj/auto_ap/ssr/components/inputs.clj +++ b/src/clj/auto_ap/ssr/components/inputs.clj @@ -63,14 +63,14 @@ :x-model (:x-model params)} (if (:disabled params) [:span {:x-text "value.label"}] - [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) - (hh/add-class "cursor-pointer")) - "x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" - "@keydown.down.prevent.stop" "tippy.show();" - "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" - :tabindex 0 - :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) - :x-ref "input"} + [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) + (hh/add-class "cursor-pointer")) + "x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" + "@keydown.down.prevent.stop" "tippy.show();" + "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" + :tabindex 0 + :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) + :x-ref "input"} [:input (-> params (dissoc :class) (dissoc :value-fn) @@ -81,9 +81,9 @@ (assoc "x-ref" "hidden" - :type "hidden" + :type "hidden" ":value" "value.value" - :x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))] + :x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))] [:div.flex.w-full.justify-items-stretch [:span.flex-grow.text-left {"x-text" "value.label"}] [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} @@ -93,71 +93,72 @@ :x-tooltip "value.warning"} "!")]]]) [:template {:x-ref "dropdown"} - [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" - "@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " + [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" + "@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " :x-destroy "if ($refs.input) {$refs.input.focus();}"} - [:input {:type "text" + [:input {:type "text" :autofocus true - :class (-> (:class params) - (or "") - (hh/add-class default-input-classes) - (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) - "x-model" "search" - "placeholder" (:placeholder params) - "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" - "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" - "@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()" - "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] + :class (-> (:class params) + (or "") + (hh/add-class default-input-classes) + (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) + "x-model" "search" + "placeholder" (:placeholder params) + "@change.stop" "" + "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" + "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" + "@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()" + "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"} [:template {:x-for "(element, index) in elements"} - [:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" - :href "#" - ":class" "active == index ? 'active' : ''" + [:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" + :href "#" + ":class" "active == index ? 'active' : ''" - "@mouseover" "active = index" - "@mouseout" "active = -1" - "@click.prevent" "value = element; tippy.hide(); $refs.input.focus()" - "x-html" "element.label"}]]] + "@mouseover" "active = index" + "@mouseout" "active = -1" + "@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)" + "x-html" "element.label"}]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "} "No results found"]]]]]]) (defn multi-typeahead-dropdown- [params] [:template {:x-ref "dropdown"} - [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4" - "@keydown.escape.prevent" "tippy.hide();" + [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4" + "@keydown.escape.prevent" "tippy.hide();" :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:div {:class (-> "relative" #_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} [:div {:class "absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"} [:svg {:class "w-4 h-4 text-gray-500 dark:text-gray-400", :aria-hidden "true", :xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 20 20"} [:path {:stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "2", :d "m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"}]]] - [:input {:type "text" + [:input {:type "text" :class (-> (:class params) (or "") (hh/add-class "block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500") (hh/add-class default-input-classes)) - "x-model" "search" - "placeholder" (:placeholder params) - "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" - "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" + "x-model" "search" + "placeholder" (:placeholder params) + "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" + "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.enter.prevent.stop" "if ($data.elements[active]) { if (value.has($data.elements[active].value)) { value.delete($data.elements[active].value) } else {value.add($data.elements[active].value); lookup[$data.elements[active].value] = $data.elements[active].label} } " - "x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]] + "x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]] [:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "} [:template {:x-for "(element, index) in elements"} - [:li {":style" "index == 0 && 'border: 0 !important;'"} - [:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer" + [:li {":style" "index == 0 && 'border: 0 !important;'"} + [:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer" - :href "#" - ":class" (hx/json {"active" (hx/js-fn "active==index") + :href "#" + ":class" (hx/json {"active" (hx/js-fn "active==index") "implied" (hx/js-fn "all_selected && index != 0")}) - "@mouseover" "active = index" - "@mouseout" "active = -1" + "@mouseover" "active = index" + "@mouseout" "active = -1" "@click.prevent" "toggle(element)"} (checkbox- {":checked" "value.has(element.value) || all_selected" :class "group-[&.implied]:bg-green-200"}) #_[:input {:type "checkbox"}] - [:span {"x-html" "element.label"}]]]] + [:span {"x-html" "element.label"}]]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 pt-4 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs " "style" "border: 0 !important"} "No results found"]]]]]) @@ -225,7 +226,7 @@ :x-init (str "$watch('value', v => $dispatch('change')); ") :search "" :active -1 - :elements (cond-> [{:value "all" :label "All"}] + :elements (cond-> [{:value "all" :label "All"}] (sequential? (:value params)) (into (map (fn [v] {:value ((:value-fn params identity) v) @@ -237,24 +238,24 @@ :x-init "value=new Set(value || []); "} (if (:disabled params) [:span {:x-text "value.label"}] - [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) - (hh/add-class "cursor-pointer")) - "x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" - "@keydown.down.prevent.stop" "tippy.show();" - "@keydown.backspace" "tippy.hide(); value=new Set( []);" - :tabindex 0 - :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) - :x-ref "input"} + [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) + (hh/add-class "cursor-pointer")) + "x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" + "@keydown.down.prevent.stop" "tippy.show();" + "@keydown.backspace" "tippy.hide(); value=new Set( []);" + :tabindex 0 + :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) + :x-ref "input"} [:template {:x-for "v in Array.from(value.values())"} [:input (-> params (dissoc :class :value-fn :content-fn :placeholder :x-model) (assoc - :type "hidden" + :type "hidden" "x-bind:value" "v"))]] [:template {:x-if "value.size == 0"} [:input (-> params (dissoc :class :value-fn :content-fn :placeholder :x-model) - (assoc :type "hidden" + (assoc :type "hidden" :value ""))]] [:div.flex.w-full.justify-items-stretch (multi-typeahead-selected-pill- params) @@ -296,23 +297,23 @@ (defn money-input- [{:keys [size] :as params}] [:input - (-> params - (update :class (fnil hh/add-class "") default-input-classes) - (update :class hh/add-class "appearance-none text-right") - (update :class #(str % (use-size size))) - (assoc :type "number" - :step "0.01") - (dissoc :size))]) + (-> params + (update :class (fnil hh/add-class "") default-input-classes) + (update :class hh/add-class "appearance-none text-right") + (update :class #(str % (use-size size))) + (assoc :type "number" + :step "0.01") + (dissoc :size))]) (defn int-input- [{:keys [size] :as params}] [:input - (-> params - (update :class (fnil hh/add-class "") default-input-classes) - (update :class hh/add-class "appearance-none text-right") - (update :class #(str % (use-size size))) - (assoc :type "number" - :step "1") - (dissoc :size))]) + (-> params + (update :class (fnil hh/add-class "") default-input-classes) + (update :class hh/add-class "appearance-none text-right") + (update :class #(str % (use-size size))) + (assoc :type "number" + :step "1") + (dissoc :size))]) (defn date-input- [{:keys [size] :as params}] [:div.shrink {:x-data (hx/json {:value (:value params) @@ -321,40 +322,40 @@ "x-effect" "console.log('changed to' +value)" "@change-date.camel" "$dispatch('change')"} [:input - (-> params - (update :class (fnil hh/add-class "") default-input-classes) - (assoc :x-model "value") - (assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") + (-> params + (update :class (fnil hh/add-class "") default-input-classes) + (assoc :x-model "value") + (assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") - (assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ") - (assoc :type "text") + (assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ") + (assoc :type "text") - (assoc "autocomplete" "off") - (assoc "@change" "value = $event.target.value;") + (assoc "autocomplete" "off") + (assoc "@change" "value = $event.target.value;") - (assoc "@keydown.escape" "tippy.hide(); ") - #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) - (update :class #(str % (use-size size) " w-full")) - (dissoc :size))] + (assoc "@keydown.escape" "tippy.hide(); ") + #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) + (update :class #(str % (use-size size) " w-full")) + (dissoc :size))] [:template {:x-ref "tooltip"} [:div.shrink [:div - (-> params - (update :class (fnil hh/add-class "") default-input-classes) - (assoc :type "text") - (assoc :value (:value params)) + (-> params + (update :class (fnil hh/add-class "") default-input-classes) + (assoc :type "text") + (assoc :value (:value params)) ;; the data-date field has to be bound before the datepicker can be initialized - (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") - (assoc "x-effect" "if(dp) { dp.setDate(value); } ") - (assoc ":data-date" "value") - (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") - (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") - (assoc "x-destroy" "destroyDatepicker(dp)") - (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") + (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") + (assoc "x-effect" "if(dp) { dp.setDate(value); } ") + (assoc ":data-date" "value") + (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") + (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") + (assoc "x-destroy" "destroyDatepicker(dp)") + (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") - (update :class #(str % (use-size size) " w-full")) - (dissoc :size :name :x-model :x-modelable))]]]]) + (update :class #(str % (use-size size) " w-full")) + (dissoc :size :name :x-model :x-modelable))]]]]) (defn multi-calendar-input- [{:keys [size] :as params}] (let [value (str/join ", " @@ -368,21 +369,21 @@ [:template {:x-for "v in value"} [:input {:type "hidden" :name (:name params) :x-model "v"}]] [:div - (-> params - (update :class (fnil hh/add-class "") default-input-classes) - (assoc :type "text") - (assoc :value value) + (-> params + (update :class (fnil hh/add-class "") default-input-classes) + (assoc :type "text") + (assoc :value value) ;; the data-date field has to be bound before the datepicker can be initialized - (assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ") - (assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ") - (assoc ":data-date" "Array.prototype.join.call(value, ', ')") - (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") - (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") - (assoc "x-destroy" "destroyDatepicker(dp)") - (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") + (assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ") + (assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ") + (assoc ":data-date" "Array.prototype.join.call(value, ', ')") + (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") + (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") + (assoc "x-destroy" "destroyDatepicker(dp)") + (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") - (update :class #(str % (use-size size) " w-full")) - (dissoc :size :name :x-model :x-modelable))]])) + (update :class #(str % (use-size size) " w-full")) + (dissoc :size :name :x-model :x-modelable))]])) (defn calendar-input- [{:keys [size] :as params}] (let [value (:value params)] @@ -392,21 +393,21 @@ :x-model (:x-model params)} [:input {:type "hidden" :name (:name params) :x-model "value"}] [:div - (-> params - (update :class (fnil hh/add-class "") default-input-classes) - (assoc :type "text") - (assoc :value value) + (-> params + (update :class (fnil hh/add-class "") default-input-classes) + (assoc :type "text") + (assoc :value value) ;; the data-date field has to be bound before the datepicker can be initialized - (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") - (assoc "x-effect" "if(dp) { dp.setDate(value); } ") - (assoc ":data-date" "value") - (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") - (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") - (assoc "x-destroy" "destroyDatepicker(dp)") - (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") + (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") + (assoc "x-effect" "if(dp) { dp.setDate(value); } ") + (assoc ":data-date" "value") + (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") + (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") + (assoc "x-destroy" "destroyDatepicker(dp)") + (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") - (update :class #(str % (use-size size) " w-full")) - (dissoc :size :name :x-model :x-modelable))]])) + (update :class #(str % (use-size size) " w-full")) + (dissoc :size :name :x-model :x-modelable))]])) (defn field-errors- [{:keys [source key]} & rest] (let [errors (:errors (cond-> (meta source) diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj index 87be5931..b23526d7 100644 --- a/src/clj/auto_ap/ssr/transaction/edit.clj +++ b/src/clj/auto_ap/ssr/transaction/edit.clj @@ -514,6 +514,7 @@ :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) :hx-target "#manual-coding-section" :hx-swap "outerHTML" + :hx-sync "this:replace" :hx-include "closest form"} (fc/with-field :transaction/vendor (com/validated-field @@ -1429,10 +1430,13 @@ (let [multi-form-state (:multi-form-state request) snapshot (:snapshot multi-form-state) step-params (:step-params multi-form-state) - mode (keyword (or (:mode step-params) "simple")) + mode (keyword (or (:mode step-params) + (get (:form-params request) "mode") + "simple")) client-id (or (:transaction/client snapshot) (-> request :entity :transaction/client :db/id)) vendor-id (or (:transaction/vendor step-params) + (->db-id (get step-params "transaction/vendor")) (:transaction/vendor snapshot)) total (Math/abs (or (-> request :entity :transaction/amount) (:transaction/amount snapshot) @@ -1443,15 +1447,16 @@ default-account (when (and (empty? existing-accounts) vendor-id client-id) (vendor-default-account vendor-id client-id)) render-request - (if (and (empty? existing-accounts) vendor-id client-id) - (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) - :transaction-account/location (or (:account/location default-account) "Shared") - :transaction-account/amount (if (= amount-mode "%") 100.0 total)} - default-account (assoc :transaction-account/account (:db/id default-account)))] - (-> request - (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) - (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) - request)] + (-> (if (and (empty? existing-accounts) vendor-id client-id) + (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) + :transaction-account/location (or (:account/location default-account) "Shared") + :transaction-account/amount (if (= amount-mode "%") 100.0 total)} + default-account (assoc :transaction-account/account (:db/id default-account)))] + (-> request + (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) + (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) + request) + (assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))] (html-response (fc/start-form (:multi-form-state render-request) nil (fc/with-field :step-params diff --git a/test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj b/test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj index 1a1e7ed0..117f8c16 100644 --- a/test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj +++ b/test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj @@ -5,12 +5,13 @@ [auto-ap.solr] [auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.form-cursor :as fc] - [auto-ap.ssr.transaction.edit :refer [clientize-vendor - edit-vendor-changed-handler - edit-wizard-toggle-mode-handler - location-select* - manual-coding-section* - vendor-default-account]] + [auto-ap.ssr.transaction.edit + :refer [clientize-vendor + edit-vendor-changed-handler + edit-wizard-toggle-mode-handler + location-select* + manual-coding-section* + vendor-default-account]] [clojure.test :refer [deftest is testing use-fixtures]] [datomic.api :as dc] [hiccup.core :as hiccup])) @@ -52,7 +53,7 @@ (testing "AC3: multi-account (2+) transaction opens in advanced mode" (is (= :advanced (manual-mode-initial {:db/id 123 :transaction/accounts [{:transaction-account/account 1} - {:transaction-account/account 2}]}))) + {:transaction-account/account 2}]}))) (is (= :advanced (manual-mode-initial {:db/id 123 :transaction/accounts [{} {} {}]}))))) @@ -163,18 +164,18 @@ (deftest save-manual-round-trip-test (testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values" (let [result @(dc/transact conn [{:db/id "vendor-id" - :vendor/name "Save Vendor"} - {:db/id "account-id" - :account/name "Save Account" - :account/type :account-type/expense} - {:db/id "client-id" - :client/code "SAVECL" - :client/locations ["DT"]} - {:db/id "transaction-id" - :transaction/amount 100.0 - :transaction/date #inst "2023-01-01" - :transaction/id (str (java.util.UUID/randomUUID)) - :transaction/client "client-id"}]) + :vendor/name "Save Vendor"} + {:db/id "account-id" + :account/name "Save Account" + :account/type :account-type/expense} + {:db/id "client-id" + :client/code "SAVECL" + :client/locations ["DT"]} + {:db/id "transaction-id" + :transaction/amount 100.0 + :transaction/date #inst "2023-01-01" + :transaction/id (str (java.util.UUID/randomUUID)) + :transaction/client "client-id"}]) tx-id (tempid->id result "transaction-id") vendor-id (tempid->id result "vendor-id") account-id (tempid->id result "account-id") @@ -934,3 +935,64 @@ ;; Should NOT show 'Switch to simple mode' (is (not (re-find #"Switch to simple mode" html)) "AC20: Simple mode should NOT show 'Switch to simple mode' link")))) + +;;; --------------------------------------------------------------------------- +;;; Bug: vendor selection gets erased on vendor-changed HTMX response +;;; --------------------------------------------------------------------------- + +(deftest vendor-selection-preserved-in-htmx-response-test + (testing "BUG: vendor selection should be preserved when HTMX re-renders the edit form" + (let [result @(dc/transact conn [{:db/id "vendor-id" + :vendor/name "Test Vendor"} + {:db/id "account-id" + :account/name "Existing Account" + :account/type :account-type/expense} + {:db/id "client-id" + :client/code "VENDORCL" + :client/locations ["DT"]} + {:db/id "transaction-id" + :transaction/amount 100.0 + :transaction/date #inst "2023-01-01" + :transaction/id (str (java.util.UUID/randomUUID)) + :transaction/client "client-id"}]) + tx-id (tempid->id result "transaction-id") + vendor-id (tempid->id result "vendor-id") + account-id (tempid->id result "account-id") + client-id (tempid->id result "client-id") + ;; Simulate the request after middleware decoding. + ;; In production, form values arrive as strings. The middleware decodes + ;; step-params with keyword keys but leaves values as strings. + existing-accounts [{:db/id "row-1" + :transaction-account/account account-id + :transaction-account/location "DT" + :transaction-account/amount 100.0}] + request {:multi-form-state (mm/->MultiStepFormState + {:db/id tx-id + :transaction/client client-id + :transaction/accounts existing-accounts} + [] + {:mode "simple" + ;; This is how the vendor ID arrives from the form: + ;; as a string, not a long. + :transaction/vendor (str vendor-id) + :transaction/accounts existing-accounts}) + :entity {:db/id tx-id + :transaction/client {:db/id client-id} + :transaction/amount 100.0}} + ;; The handler should return a successful response with the vendor + ;; preserved. Currently it crashes because the string vendor-id is + ;; not converted to a long before being passed to Datomic. + response (try + (edit-vendor-changed-handler request) + (catch Exception e + {:error e}))] + (is (not (:error response)) + (str "BUG: String vendor-id from form submission should be converted to long. " + "Server crashes with: " (some-> response :error ex-message))) + (when-not (:error response) + (is (= 200 (:status response)) + "Response should be successful") + (is (re-find #"Test Vendor" (:body response)) + "Vendor name should appear in the HTMX response") + (is (re-find (re-pattern (str vendor-id)) (:body response)) + "Vendor ID should be preserved in the response HTML")))))