3 Commits

Author SHA1 Message Date
b6649a3d1d fixes 2026-05-31 08:37:44 -07:00
38ae6f460f Cleanup of simple/advanced mode 2026-05-31 08:30:11 -07:00
e156d8bfd8 fixes vendor selection bug 2026-05-30 09:21:39 -07:00
5 changed files with 654 additions and 165 deletions

View File

@@ -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.describe('Transaction Link Date Display', () => {
test('should show payment date when linking to payment', async ({ page }) => { test('should show payment date when linking to payment', async ({ page }) => {
await openEditModalForTransaction(page, 'Transaction for payment link'); await openEditModalForTransaction(page, 'Transaction for payment link');

View File

@@ -80,9 +80,7 @@
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}] (defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
(let [preserved (transaction-nav-params request)] (let [preserved (transaction-nav-params request)]
(hu/url (bidi/path-for ssr-routes/only-routes route) (hu/url (bidi/path-for ssr-routes/only-routes route)
#_(if (or (:start-date preserved) (:end-date preserved)) {:date-range "month"})))
preserved
(merge default-params preserved)))))
(defn left-aside- [{:keys [nav page-specific]} & _] (defn left-aside- [{:keys [nav page-specific]} & _]
[:aside {:id "left-nav", [:aside {:id "left-nav",

View File

@@ -63,14 +63,14 @@
:x-model (:x-model params)} :x-model (:x-model params)}
(if (:disabled params) (if (:disabled params)
[:span {:x-text "value.label"}] [:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer")) (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}" "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.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
:tabindex 0 :tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"} :x-ref "input"}
[:input (-> params [:input (-> params
(dissoc :class) (dissoc :class)
(dissoc :value-fn) (dissoc :value-fn)
@@ -81,9 +81,9 @@
(assoc (assoc
"x-ref" "hidden" "x-ref" "hidden"
:type "hidden" :type "hidden"
":value" "value.value" ":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 [:div.flex.w-full.justify-items-stretch
[:span.flex-grow.text-left {"x-text" "value.label"}] [: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"} [: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"} "!")]]]) :x-tooltip "value.warning"} "!")]]])
[:template {:x-ref "dropdown"} [:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " "@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
:x-destroy "if ($refs.input) {$refs.input.focus();}"} :x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:input {:type "text" [:input {:type "text"
:autofocus true :autofocus true
:class (-> (:class params) :class (-> (:class params)
(or "") (or "")
(hh/add-class default-input-classes) (hh/add-class default-input-classes)
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
"x-model" "search" "x-model" "search"
"placeholder" (:placeholder params) "placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@change.stop" ""
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"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()}) }})"}] "@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"} [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"} [: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" [: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 "#" :href "#"
":class" "active == index ? 'active' : ''" ":class" "active == index ? 'active' : ''"
"@mouseover" "active = index" "@mouseover" "active = index"
"@mouseout" "active = -1" "@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()" "@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
"x-html" "element.label"}]]] "x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"} [: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 "} [: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"]]]]]]) "No results found"]]]]]])
(defn multi-typeahead-dropdown- [params] (defn multi-typeahead-dropdown- [params]
[:template {:x-ref "dropdown"} [: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" [: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();" "@keydown.escape.prevent" "tippy.hide();"
:x-destroy "if ($refs.input) {$refs.input.focus();}"} :x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:div {:class (-> "relative" [:div {:class (-> "relative"
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} #_(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"} [: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"} [: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"}]]] [: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) :class (-> (:class params)
(or "") (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 "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)) (hh/add-class default-input-classes))
"x-model" "search" "x-model" "search"
"placeholder" (:placeholder params) "placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : 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} } " "@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 "} [:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "}
[:template {:x-for "(element, index) in elements"} [:template {:x-for "(element, index) in elements"}
[:li {":style" "index == 0 && 'border: 0 !important;'"} [: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" [: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 "#" :href "#"
":class" (hx/json {"active" (hx/js-fn "active==index") ":class" (hx/json {"active" (hx/js-fn "active==index")
"implied" (hx/js-fn "all_selected && index != 0")}) "implied" (hx/js-fn "all_selected && index != 0")})
"@mouseover" "active = index" "@mouseover" "active = index"
"@mouseout" "active = -1" "@mouseout" "active = -1"
"@click.prevent" "toggle(element)"} "@click.prevent" "toggle(element)"}
(checkbox- {":checked" "value.has(element.value) || all_selected" (checkbox- {":checked" "value.has(element.value) || all_selected"
:class "group-[&.implied]:bg-green-200"}) :class "group-[&.implied]:bg-green-200"})
#_[:input {:type "checkbox"}] #_[:input {:type "checkbox"}]
[:span {"x-html" "element.label"}]]]] [:span {"x-html" "element.label"}]]]]
[:template {:x-if "elements.length == 0"} [: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"} [: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"]]]]]) "No results found"]]]]])
@@ -225,7 +226,7 @@
:x-init (str "$watch('value', v => $dispatch('change')); ") :x-init (str "$watch('value', v => $dispatch('change')); ")
:search "" :search ""
:active -1 :active -1
:elements (cond-> [{:value "all" :label "All"}] :elements (cond-> [{:value "all" :label "All"}]
(sequential? (:value params)) (sequential? (:value params))
(into (map (fn [v] (into (map (fn [v]
{:value ((:value-fn params identity) v) {:value ((:value-fn params identity) v)
@@ -237,24 +238,24 @@
:x-init "value=new Set(value || []); "} :x-init "value=new Set(value || []); "}
(if (:disabled params) (if (:disabled params)
[:span {:x-text "value.label"}] [:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer")) (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}" "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.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);" "@keydown.backspace" "tippy.hide(); value=new Set( []);"
:tabindex 0 :tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"} :x-ref "input"}
[:template {:x-for "v in Array.from(value.values())"} [:template {:x-for "v in Array.from(value.values())"}
[:input (-> params [:input (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model) (dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc (assoc
:type "hidden" :type "hidden"
"x-bind:value" "v"))]] "x-bind:value" "v"))]]
[:template {:x-if "value.size == 0"} [:template {:x-if "value.size == 0"}
[:input (-> params [:input (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model) (dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc :type "hidden" (assoc :type "hidden"
:value ""))]] :value ""))]]
[:div.flex.w-full.justify-items-stretch [:div.flex.w-full.justify-items-stretch
(multi-typeahead-selected-pill- params) (multi-typeahead-selected-pill- params)
@@ -296,23 +297,23 @@
(defn money-input- [{:keys [size] :as params}] (defn money-input- [{:keys [size] :as params}]
[:input [:input
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right") (update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size))) (update :class #(str % (use-size size)))
(assoc :type "number" (assoc :type "number"
:step "0.01") :step "0.01")
(dissoc :size))]) (dissoc :size))])
(defn int-input- [{:keys [size] :as params}] (defn int-input- [{:keys [size] :as params}]
[:input [:input
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right") (update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size))) (update :class #(str % (use-size size)))
(assoc :type "number" (assoc :type "number"
:step "1") :step "1")
(dissoc :size))]) (dissoc :size))])
(defn date-input- [{:keys [size] :as params}] (defn date-input- [{:keys [size] :as params}]
[:div.shrink {:x-data (hx/json {:value (:value params) [:div.shrink {:x-data (hx/json {:value (:value params)
@@ -321,40 +322,40 @@
"x-effect" "console.log('changed to' +value)" "x-effect" "console.log('changed to' +value)"
"@change-date.camel" "$dispatch('change')"} "@change-date.camel" "$dispatch('change')"}
[:input [:input
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value") (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-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 :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text") (assoc :type "text")
(assoc "autocomplete" "off") (assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;") (assoc "@change" "value = $event.target.value;")
(assoc "@keydown.escape" "tippy.hide(); ") (assoc "@keydown.escape" "tippy.hide(); ")
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size))] (dissoc :size))]
[:template {:x-ref "tooltip"} [:template {:x-ref "tooltip"}
[:div.shrink [:div.shrink
[:div [:div
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text") (assoc :type "text")
(assoc :value (:value params)) (assoc :value (:value params))
;; the data-date field has to be bound before the datepicker can be initialized ;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ") (assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value") (assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]]]) (dissoc :size :name :x-model :x-modelable))]]]])
(defn multi-calendar-input- [{:keys [size] :as params}] (defn multi-calendar-input- [{:keys [size] :as params}]
(let [value (str/join ", " (let [value (str/join ", "
@@ -368,21 +369,21 @@
[:template {:x-for "v in value"} [:template {:x-for "v in value"}
[:input {:type "hidden" :name (:name params) :x-model "v"}]] [:input {:type "hidden" :name (:name params) :x-model "v"}]]
[:div [:div
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text") (assoc :type "text")
(assoc :value value) (assoc :value value)
;; the data-date field has to be bound before the datepicker can be initialized ;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ") (assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ") (assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
(assoc ":data-date" "Array.prototype.join.call(value, ', ')") (assoc ":data-date" "Array.prototype.join.call(value, ', ')")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]])) (dissoc :size :name :x-model :x-modelable))]]))
(defn calendar-input- [{:keys [size] :as params}] (defn calendar-input- [{:keys [size] :as params}]
(let [value (:value params)] (let [value (:value params)]
@@ -392,21 +393,21 @@
:x-model (:x-model params)} :x-model (:x-model params)}
[:input {:type "hidden" :name (:name params) :x-model "value"}] [:input {:type "hidden" :name (:name params) :x-model "value"}]
[:div [:div
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text") (assoc :type "text")
(assoc :value value) (assoc :value value)
;; the data-date field has to be bound before the datepicker can be initialized ;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ") (assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value") (assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]])) (dissoc :size :name :x-model :x-modelable))]]))
(defn field-errors- [{:keys [source key]} & rest] (defn field-errors- [{:keys [source key]} & rest]
(let [errors (:errors (cond-> (meta source) (let [errors (:errors (cond-> (meta source)

View File

@@ -514,6 +514,7 @@
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section" :hx-target "#manual-coding-section"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"} :hx-include "closest form"}
(fc/with-field :transaction/vendor (fc/with-field :transaction/vendor
(com/validated-field (com/validated-field
@@ -882,9 +883,13 @@
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id})) #_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
(mm/form-schema linear-wizard)) (mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] (render-step [this {{:keys [snapshot step-params] :as multi-form-state} :multi-form-state :as request}]
(let [tx-id (mm/get-mfs-field multi-form-state :db/id) (let [tx-id (mm/get-mfs-field multi-form-state :db/id)
tx (d-transactions/get-by-id tx-id)] tx (d-transactions/get-by-id tx-id)
;; Preserve explicit mode choice from step-params; only fall back to
;; row-count heuristic on initial load when no mode has been chosen.
mode (keyword (or (:mode step-params)
(name (manual-mode-initial snapshot))))]
(mm/default-render-step (mm/default-render-step
linear-wizard this linear-wizard this
:head [:div.p-2 "Edit Transaction"] :head [:div.p-2 "Edit Transaction"]
@@ -950,7 +955,7 @@
(transaction-rules-view request)] (transaction-rules-view request)]
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"} [:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
[:div {} [:div {}
(manual-coding-section* (manual-mode-initial snapshot) request) (manual-coding-section* mode request)
(fc/with-field :transaction/approval-status (fc/with-field :transaction/approval-status
(com/validated-field (com/validated-field
{:label "Status" {:label "Status"
@@ -1429,10 +1434,13 @@
(let [multi-form-state (:multi-form-state request) (let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state) snapshot (:snapshot multi-form-state)
step-params (:step-params 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) client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id)) (-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params) vendor-id (or (:transaction/vendor step-params)
(->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot)) (:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount) total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot) (:transaction/amount snapshot)
@@ -1440,18 +1448,30 @@
amount-mode (or (:amount-mode snapshot) "$") amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params)) existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))) (seq (:transaction/accounts snapshot)))
default-account (when (and (empty? existing-accounts) vendor-id client-id) ;; The form always submits an account row (even when empty with account=nil),
;; so we check if any row has a meaningful account ID.
has-meaningful-accounts? (some #(some? (:transaction-account/account %))
existing-accounts)
;; Simple mode: always populate vendor default (overwrite existing).
;; Advanced mode: populate only when 0 rows OR 1 empty row.
should-populate? (case mode
:simple true
:advanced (or (empty? existing-accounts)
(and (= 1 (count existing-accounts))
(not has-meaningful-accounts?))))
default-account (when (and should-populate? vendor-id client-id)
(vendor-default-account vendor-id client-id)) (vendor-default-account vendor-id client-id))
render-request render-request
(if (and (empty? existing-accounts) vendor-id client-id) (-> (if (and should-populate? vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared") :transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)} :transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))] default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)] request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
(html-response (html-response
(fc/start-form (:multi-form-state render-request) nil (fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params (fc/with-field :step-params

View File

@@ -5,12 +5,13 @@
[auto-ap.solr] [auto-ap.solr]
[auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.transaction.edit :refer [clientize-vendor [auto-ap.ssr.transaction.edit
edit-vendor-changed-handler :refer [clientize-vendor
edit-wizard-toggle-mode-handler edit-vendor-changed-handler
location-select* edit-wizard-toggle-mode-handler
manual-coding-section* location-select*
vendor-default-account]] manual-coding-section*
vendor-default-account]]
[clojure.test :refer [deftest is testing use-fixtures]] [clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as dc] [datomic.api :as dc]
[hiccup.core :as hiccup])) [hiccup.core :as hiccup]))
@@ -52,7 +53,7 @@
(testing "AC3: multi-account (2+) transaction opens in advanced mode" (testing "AC3: multi-account (2+) transaction opens in advanced mode"
(is (= :advanced (manual-mode-initial {:db/id 123 (is (= :advanced (manual-mode-initial {:db/id 123
:transaction/accounts [{:transaction-account/account 1} :transaction/accounts [{:transaction-account/account 1}
{:transaction-account/account 2}]}))) {:transaction-account/account 2}]})))
(is (= :advanced (manual-mode-initial {:db/id 123 (is (= :advanced (manual-mode-initial {:db/id 123
:transaction/accounts [{} {} {}]}))))) :transaction/accounts [{} {} {}]})))))
@@ -105,7 +106,7 @@
(is (re-find #"Test Account" body) (is (re-find #"Test Account" body)
"Response should contain the vendor's default account name"))) "Response should contain the vendor's default account name")))
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account" (testing "AC5: vendor selection in simple mode DOES overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id" (let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"} :vendor/name "Test Vendor"}
{:db/id "account-id" {:db/id "account-id"
@@ -126,9 +127,10 @@
:transaction/client "client-id"}]) :transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id") tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id") vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
other-account-id (tempid->id result "other-account-id") other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id") client-id (tempid->id result "client-id")
;; existing-accounts already set means vendor should NOT overwrite ;; existing-accounts already set — but simple mode should still overwrite
existing-accounts [{:db/id "row-id" existing-accounts [{:db/id "row-id"
:transaction-account/account other-account-id :transaction-account/account other-account-id
:transaction-account/location "DT" :transaction-account/location "DT"
@@ -149,12 +151,12 @@
;; The handler returns an html-response; verify the body is HTML ;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body) (is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element") "Response body should contain the manual-coding-section element")
;; The original account ID must still appear in the rendered HTML ;; The vendor's default account SHOULD appear (overwriting previous)
(is (re-find (re-pattern (str other-account-id)) body) (is (re-find (re-pattern (str account-id)) body)
"Response should contain the original (pre-existing) account ID") "Vendor change in simple mode should overwrite with vendor's default account")
;; The vendor's default account ID must NOT appear — it was not used ;; The previous account should NOT appear
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body)) (is (not (re-find (re-pattern (str other-account-id)) body))
"Response should NOT contain the vendor's default account ID when existing account is set")))) "Previous account should be replaced by vendor default"))))
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
;;; AC6: save round-trip — manual mode saves vendor + account to DB ;;; AC6: save round-trip — manual mode saves vendor + account to DB
@@ -163,18 +165,18 @@
(deftest save-manual-round-trip-test (deftest save-manual-round-trip-test
(testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values" (testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values"
(let [result @(dc/transact conn [{:db/id "vendor-id" (let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Save Vendor"} :vendor/name "Save Vendor"}
{:db/id "account-id" {:db/id "account-id"
:account/name "Save Account" :account/name "Save Account"
:account/type :account-type/expense} :account/type :account-type/expense}
{:db/id "client-id" {:db/id "client-id"
:client/code "SAVECL" :client/code "SAVECL"
:client/locations ["DT"]} :client/locations ["DT"]}
{:db/id "transaction-id" {:db/id "transaction-id"
:transaction/amount 100.0 :transaction/amount 100.0
:transaction/date #inst "2023-01-01" :transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID)) :transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}]) :transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id") tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id") vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id") account-id (tempid->id result "account-id")
@@ -934,3 +936,384 @@
;; Should NOT show 'Switch to simple mode' ;; Should NOT show 'Switch to simple mode'
(is (not (re-find #"Switch to simple mode" html)) (is (not (re-find #"Switch to simple mode" html))
"AC20: Simple mode should NOT show 'Switch to simple mode' link")))) "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")))))
;;; ---------------------------------------------------------------------------
;;; Bug: vendor change does not populate account
;;; ---------------------------------------------------------------------------
(deftest vendor-change-simple-mode-overwrites-test
(testing "BUG: vendor change in simple mode should overwrite existing account"
;; When a vendor is changed in simple mode, it should always populate
;; the vendor's default account, even if an account was already set.
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Previously Selected 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")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
;; Simulate form state with an already-selected account (as the form submits)
existing-accounts [{:db/id "row-1"
:transaction-account/account existing-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"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The vendor's default account SHOULD appear (overwriting the previous)
(is (re-find (re-pattern (str vendor-account-id)) body)
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
;; The previously selected account should NOT appear
(is (not (re-find (re-pattern (str existing-account-id)) body))
"Previously selected account should be replaced by vendor default")
(is (re-find #"Vendor Default Account" body)
"Vendor default account name should appear"))))
(deftest vendor-change-advanced-mode-empty-row-test
(testing "BUG: vendor change in advanced mode should populate empty row"
;; In advanced mode with 1 empty row, changing vendor should populate it
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "client-id"
:client/code "ADVEMPTYCL"
: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")
vendor-account-id (tempid->id result "vendor-account-id")
client-id (tempid->id result "client-id")
;; Simulate advanced mode with 1 empty row (account=nil, as form submits)
empty-row [{:db/id "row-1"
:transaction-account/account nil
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts empty-row}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts empty-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The vendor's default account SHOULD appear in the row
(is (re-find (re-pattern (str vendor-account-id)) body)
"BUG: Vendor change in advanced mode with empty row should populate it")
(is (re-find #"Vendor Default Account" body)
"Vendor default account name should appear in the row"))))
(deftest vendor-change-advanced-mode-filled-row-test
(testing "AC15b: vendor change in advanced mode with filled row should NOT overwrite"
;; In advanced mode with 1 row that already has an account selected,
;; changing vendor should NOT overwrite it
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Manually Selected Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVFILLEDCL"
: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")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
;; Advanced mode with 1 row that already has an account
filled-row [{:db/id "row-1"
:transaction-account/account existing-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 filled-row}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts filled-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The existing account should still be there
(is (re-find (re-pattern (str existing-account-id)) body)
"Existing account should remain when vendor changes in advanced mode with filled row")
;; The vendor's default account should NOT appear
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default should NOT overwrite filled row in advanced mode"))))
(deftest vendor-change-advanced-mode-two-rows-test
(testing "AC15c: vendor change in advanced mode with 2+ rows should NOT modify any"
;; In advanced mode with 2 or more rows, vendor change should not touch any row
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "account-1"
:account/name "Account One"
:account/type :account-type/expense}
{:db/id "account-2"
:account/name "Account Two"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVTWOROWCL"
: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")
vendor-account-id (tempid->id result "vendor-account-id")
account-1 (tempid->id result "account-1")
account-2 (tempid->id result "account-2")
client-id (tempid->id result "client-id")
;; Advanced mode with 2 rows
two-rows [{:db/id "row-1"
:transaction-account/account account-1
:transaction-account/location "DT"
:transaction-account/amount 50.0}
{:db/id "row-2"
:transaction-account/account account-2
:transaction-account/location "DT"
:transaction-account/amount 50.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts two-rows}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts two-rows})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; Both existing accounts should remain
(is (re-find (re-pattern (str account-1)) body)
"First row account should remain")
(is (re-find (re-pattern (str account-2)) body)
"Second row account should remain")
;; Vendor default should NOT appear
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default should NOT modify rows when 2+ exist"))))
(deftest vendor-change-client-specific-override-test
(testing "BUG: vendor change should use client-specific account override if present"
;; When a vendor has a client-specific account override, changing vendor
;; should populate the client-specific account, not the global default.
(let [result @(dc/transact conn [{:db/id "global-account-id"
:account/name "Global Default"
:account/type :account-type/expense}
{:db/id "client-specific-account-id"
:account/name "Client Specific Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "CLIOVERRIDE"
:client/locations ["DT"]}
{:db/id "vendor-id"
:vendor/name "Clientized Vendor"
:vendor/default-account "global-account-id"
:vendor/account-overrides [{:vendor-account-override/client "client-id"
:vendor-account-override/account "client-specific-account-id"}]}])
vendor-id (tempid->id result "vendor-id")
client-id (tempid->id result "client-id")
global-account-id (tempid->id result "global-account-id")
client-specific-account-id (tempid->id result "client-specific-account-id")
;; Simple mode with empty account row
empty-row [{:db/id "row-1"
:transaction-account/account nil
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id 999999
:transaction/client client-id
:transaction/accounts empty-row}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts empty-row})
:entity {:db/id 999999
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The client-specific account should appear, not the global default
(is (re-find (re-pattern (str client-specific-account-id)) body)
"BUG: Vendor change should populate client-specific account override")
(is (re-find #"Client Specific Account" body)
"Client-specific account name should appear")
;; The global default should NOT appear
(is (not (re-find (re-pattern (str global-account-id)) body))
"Global vendor default should NOT appear when client override exists"))))
;;; Update AC5: simple mode SHOULD overwrite existing accounts
(deftest vendor-change-simple-mode-overwrites-ac5-test
(testing "AC5 UPDATED: vendor selection in simple mode DOES overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Test Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "account-id"}
{:db/id "other-account-id"
:account/name "Other Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "TESTCL2"
: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")
other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id")
;; existing-accounts already set — but simple mode should still overwrite
existing-accounts [{:db/id "row-id"
:transaction-account/account other-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"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element")
;; The vendor's default account SHOULD appear (overwriting previous)
(is (re-find (re-pattern (str account-id)) body)
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
;; The previous account should NOT appear
(is (not (re-find (re-pattern (str other-account-id)) body))
"Previous account should be replaced by vendor default"))))