From c5dc305854ac41f558b536137781859352002579 Mon Sep 17 00:00:00 2001 From: Bryce Date: Thu, 25 Jun 2026 22:39:10 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ssr):=20Phase=209=20=E2=80=94=20migrat?= =?UTF-8?q?e=20New/Edit=20Vendor=20onto=20the=20engine=20(5-step=20wizard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A five-step linear wizard (info → terms → account → address → legal) plus a separate Merge dialog, migrated off mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2), following the Phase 8 template. Latent bug found + fixed: the old "Next" PUT /admin/vendor/navigat carried a [:map [:db/id entity-id]] route-schema on a route with no :db/id path param, so empty route-params {} → main-transformer's parse-empty-as-nil → nil → 500 on every advance (the same quirk as Phase 8's query-params, now via route-params). The engine's submit is a POST with no such schema; the dead navigate route is deleted. What changed: - defrecord 5 → 0 (InfoModal/TermsModal/AccountModal/AddressModal/LegalEntityModal + VendorWizard), mm/ 0, fc/ cursor refs 0 (wizard AND the de-cursored Merge dialog), step-params[…] 0. - 5 de-cursored step renders (plain data + path->name2 + a *errors* binding); the 3 repeated grids became add-row-handler + a blank-row row render; the timeline is preserved as a per-step side panel. - :init-fn branches new (empty) vs edit (entity split across the 5 steps' :init-data, seeded as per-step step-data so edit opens populated); per-step :validate via mc/validate + me/humanize replaces wrap-ensure-step; vendor-step wraps handle-step-submit in try+ to surface create-time validation as a 4xx. Two new gotchas found + fixed + documented: - empty-step decode: an all-blank step collapses to nil (parse-empty-as-nil), which a schema :validate rejects as "invalid type"; decode-with coerces nil → {} so optional- only steps advance while required-field steps still fail on the missing key. - blank nested entity: an untouched Address (all-nil, no :db/id) makes :upsert-entity mint a tempid used only as value (datomic error); blank-address? drops it. Verification: full e2e suite 65/65 (61 prior + 4 new: info renders + timeline; create across all 5 steps persists; edit opens prefilled and a rename persists; a too-short name blocks advancing). Create + edit confirmed at the REPL incl. the cookie-session EDN round-trip. Skill fed (scorecard Phase 9; gotchas for both new traps). Co-Authored-By: Claude Opus 4.8 --- .../ssr-form-migration/reference/gotchas.md | 32 + .../ssr-form-migration/reference/scorecard.md | 33 + e2e/vendor-wizard.spec.ts | 103 ++ src/clj/auto_ap/ssr/admin/vendors.clj | 1042 +++++++---------- src/cljc/auto_ap/routes/admin/vendors.cljc | 1 - 5 files changed, 597 insertions(+), 614 deletions(-) create mode 100644 e2e/vendor-wizard.spec.ts diff --git a/.claude/skills/ssr-form-migration/reference/gotchas.md b/.claude/skills/ssr-form-migration/reference/gotchas.md index 5f05a4ed..9e394f31 100644 --- a/.claude/skills/ssr-form-migration/reference/gotchas.md +++ b/.claude/skills/ssr-form-migration/reference/gotchas.md @@ -365,3 +365,35 @@ before they land in step-data, and coerce back to clj-time only for display (`->edn-safe-dates`) right after `mc/decode` is the clean seam — both the step `:decode` and the edit `:init-fn` run the posted/persisted map through it. Datomic's upsert wants `java.util.Date` anyway, so the done-fn needs no extra conversion. + +## The `{}`→nil trap has a THIRD face: empty-step decode → validation "invalid type" + +Beyond query-params (Phase 8) and route-params (Phase 9's `/navigat`), the same +`parse-empty-as-nil` `:map` decoder bites a wizard step whose fields are all blank: an +all-empty step posts only blank inputs → the decoded all-nil map collapses to `nil`. If that +`nil` then flows into a `:validate` that does `(mc/validate step-schema data)`, validation +fails with `[invalid type]` (nil isn't a map) and the step can never advance — even though +every field is optional. The legal/address steps (all-optional) hit this. + +Fix at the seam: have the step `:decode` coerce nil back to `{}`: +```clojure +(defn- decode-with [schema request] + (or (mc/decode schema (... nested form-params ...) main-transformer) {})) +``` +Now an optional-only step validates `{}` (passes, advances) while a required-field step +(e.g. account needs `:vendor/default-account`) still fails on the *missing key*, not on a +spurious nil. Don't "fix" it by skipping validation when data is nil — that lets a genuinely +empty required step through. + +## A new (db/id-less) nested entity with all-nil fields → datomic "tempid used only as value" + +The empty Address step decodes to `{:vendor/address {:address/street1 nil, …}}` — a map of +nils with no `:db/id`. `:upsert-entity` mints a tempid for that nested map but, since every +attribute is nil, the address entity has nothing transacted, so the tempid is referenced as +a ref value but never defined → `:db.error/tempid-not-an-entity … used only as value`. Drop +such blank nested maps before the upsert: +```clojure +(defn- blank-address? [a] (and (map? a) (not (:db/id a)) (every? nil? (vals a)))) +``` +This is the nested-entity analogue of "don't create empty rows"; the engine's `blank-row` +gives *added* rows a tempid, but a never-touched optional nested entity must be elided. diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md index f1504f8e..deaf5686 100644 --- a/.claude/skills/ssr-form-migration/reference/scorecard.md +++ b/.claude/skills/ssr-form-migration/reference/scorecard.md @@ -260,3 +260,36 @@ now / Add another / Close), matching the Phase 7 pay-success shape. default-path create → next-steps; customize-path → accounts grid → create → next-steps); the `maybe-spread-locations` unit test still 6/6; create semantics + edit prefill confirmed at the REPL; dates ride as `#inst` so step-data is EDN-safe across the non-terminal step. + +--- + +## Phase 9 — New / Edit Vendor (5-step linear wizard) + +`auto-ap.ssr.admin.vendors` — a five-step linear wizard (info → terms → account → address → +legal) plus a separate Merge dialog. Migrated onto the engine following the Phase 8 template. + +**Latent bug found + fixed (again):** the old "Next" PUT `/admin/vendor/navigat` (note the +typo) carried a `[:map [:db/id entity-id]]` route-schema on a route with **no** `:db/id` path +param, so empty route-params `{}`→nil 500d every advance (same `main-transformer` quirk as +Phase 8's query-params). The engine's submit is a POST with no such schema → gone. + +**Coupling outcome:** `defrecord` **5 → 0** (InfoModal / TermsModal / AccountModal / +AddressModal / LegalEntityModal + VendorWizard all gone), `mm/` **0**, `fc/` cursor refs +**0** (the wizard *and* the de-cursored Merge dialog), `step-params[…]` **0**. Routes: the +broken `navigate` is deleted (open + submit + 3 add-rows + account-typeahead remain). The 5 +step renders are plain data + `path->name2` + a `*errors*` binding; the 3 repeated grids +(terms-overrides / automatic-payment / account-overrides) became `add-row-handler` + a +`blank-row` row render. The wizard timeline is preserved as a per-step side panel. + +**Engine refinements exercised:** conditionless linear `:next`; `:init-fn` branches new +(empty) vs edit (entity split across the 5 steps' `:init-data`, which `create-wizard!` seeds +as per-step step-data so edit opens fully populated); per-step `:validate` via +`mc/validate` + `me/humanize` replaces the old `wrap-ensure-step` schema assertion; +`vendor-step` wraps `handle-step-submit` in `try+` to surface create-time validation as a +4xx. Two new gotchas surfaced and are documented: the empty-step `{}`→nil decode trap and the +blank-nested-entity upsert error (see `gotchas.md`). + +**Verification:** full e2e suite **65/65** (61 prior + 4 new: info renders + timeline; +create across all 5 steps persists; edit opens prefilled and a rename persists; a too-short +name blocks advancing). Create + edit semantics also confirmed at the REPL (incl. the +cookie-session EDN round-trip). `maybe-spread-locations`-style domain helpers untouched. diff --git a/e2e/vendor-wizard.spec.ts b/e2e/vendor-wizard.spec.ts new file mode 100644 index 00000000..8c841516 --- /dev/null +++ b/e2e/vendor-wizard.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; + +// Acceptance spec for the New/Edit Vendor wizard (info → terms → account → address → legal), +// migrated onto the session-backed engine (wizard2). Like New Invoice, the pre-migration +// "Next" PUT /admin/vendor/navigat 500d on the empty route-params {}→nil quirk (a +// [:map [:db/id …]] route-schema on a route with no path param), so this is an ACCEPTANCE +// gate: green on the engine. Required fields: vendor/name (min 3) on info, vendor/default-account +// on account. +test.beforeEach(async ({ request }) => { await request.post('/test-reset'); }); + +async function seedAccount(page: any): Promise { + const info = await (await page.request.get('/test-info')).json(); + return info.accounts['test-account']; +} + +async function openNewVendor(page: any) { + await page.goto('/admin/vendor'); + await page.waitForSelector('#entity-table'); + await page.locator('button:has-text("New Vendor")').first().click(); + await page.waitForSelector('#wizard-form'); + await page.waitForTimeout(400); +} + +// The advance/save button is the engine's data-primary nav button. +const primary = (page: any) => page.locator('#wizard-form button[data-primary]').first().click(); + +async function setHidden(page: any, name: string, value: number) { + await page.evaluate(({ name, value }: { name: string; value: number }) => { + const h = document.querySelector(`input[name="${name}"]`) as HTMLInputElement; + h.value = String(value); + h.dispatchEvent(new Event('change', { bubbles: true })); + }, { name, value }); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Vendor wizard (acceptance)', () => { + test('info step renders with the name field and a timeline', async ({ page }) => { + await openNewVendor(page); + const form = page.locator('#wizard-form'); + await expect(form).toContainText('Basic Info'); + await expect(form).toContainText('Terms'); // timeline step + await expect(form.locator('input[name="vendor/name"]')).toBeVisible(); + }); + + test('create a vendor across all 5 steps adds it to the grid', async ({ page }) => { + const account = await seedAccount(page); + await openNewVendor(page); + // info + await page.locator('input[name="vendor/name"]').fill('Acme Supplies'); + await primary(page); // -> terms + await page.waitForTimeout(500); + await expect(page.locator('#wizard-form')).toContainText('Terms Overrides'); + await primary(page); // -> account + await page.waitForTimeout(500); + await expect(page.locator('#wizard-form')).toContainText('Default Account'); + await setHidden(page, 'vendor/default-account', account); + await primary(page); // -> address + await page.waitForTimeout(500); + await expect(page.locator('#wizard-form')).toContainText('Street'); + await primary(page); // -> legal + await page.waitForTimeout(500); + await expect(page.locator('#wizard-form')).toContainText('Legal Entity'); + await primary(page); // Save + await page.waitForTimeout(1200); + // the vendor persists: reload the grid and it's there + await page.goto('/admin/vendor'); + await page.waitForSelector('#entity-table tbody tr'); + await expect(page.locator('#entity-table')).toContainText('Acme Supplies'); + }); + + test('edit opens prefilled and a rename persists', async ({ page }) => { + const account = await seedAccount(page); + await page.goto('/admin/vendor'); + await page.waitForSelector('#entity-table tbody tr'); + // open the edit wizard for the seeded "Test Vendor" (its row pencil) + await page.locator('#entity-table tbody tr', { hasText: 'Test Vendor' }).first() + .locator('[hx-get*="/edit"]').first().click(); + await page.waitForSelector('#wizard-form'); + await page.waitForTimeout(400); + // info step is prefilled with the existing name + await expect(page.locator('input[name="vendor/name"]')).toHaveValue('Test Vendor'); + await page.locator('input[name="vendor/name"]').fill('Test Vendor RENAMED'); + await primary(page); await page.waitForTimeout(400); // terms + await primary(page); await page.waitForTimeout(400); // account (default-account already set) + await setHidden(page, 'vendor/default-account', account); + await primary(page); await page.waitForTimeout(400); // address + await primary(page); await page.waitForTimeout(400); // legal + await primary(page); await page.waitForTimeout(1000); // save + await page.goto('/admin/vendor'); + await page.waitForSelector('#entity-table tbody tr'); + await expect(page.locator('#entity-table')).toContainText('Test Vendor RENAMED'); + }); + + test('info step blocks advancing when the name is too short', async ({ page }) => { + await openNewVendor(page); + await page.locator('input[name="vendor/name"]').fill('ab'); // < 3 chars + await primary(page); + await page.waitForTimeout(500); + // still on the info step (validation re-renders it, no advance) + await expect(page.locator('#wizard-form')).toContainText('Basic Info'); + }); +}); diff --git a/src/clj/auto_ap/ssr/admin/vendors.clj b/src/clj/auto_ap/ssr/admin/vendors.clj index 36c0b2ea..aa9faf94 100644 --- a/src/clj/auto_ap/ssr/admin/vendors.clj +++ b/src/clj/auto_ap/ssr/admin/vendors.clj @@ -1,9 +1,15 @@ (ns auto-ap.ssr.admin.vendors + "Vendor grid + the New/Edit Vendor wizard, migrated onto the session-backed engine + (wizard2). Five linear steps (info -> terms -> account -> address -> legal); per-step + data lives in the Ring session and is combined by the engine's get-all for the done-fn + (`save-vendor!`). The pre-migration `mm` flow's \"Next\" PUT /vendor/navigat 500d on the + empty route-params {}->nil quirk (a `[:map [:db/id …]]` route-schema on a route with no + path param); the engine's submit is a POST with no such schema, so that latent bug is + gone. The separate Merge dialog is a plain two-field form (no wizard)." (:require - [auto-ap.cursor :as cursor] [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-3 - audit-transact audit-transact-batch audit-transact-batch + audit-transact audit-transact-batch conn merge-query pull-attr pull-many query2]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.logging :as alog] @@ -13,36 +19,36 @@ :refer [wrap-admin wrap-client-redirect-unauthenticated]] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] - [auto-ap.ssr.common-handlers :refer [add-new-entity-handler - add-new-primitive-handler]] [auto-ap.ssr.components :as com] - [auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.components.timeline :as timeline] - [auto-ap.ssr.form-cursor :as fc] + [auto-ap.ssr.components.wizard-state :as ws] + [auto-ap.ssr.components.wizard2 :as wizard2] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.nested-form-params :as nfp] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils - :refer [apply-middleware-to-all-handlers + :refer [->db-id apply-middleware-to-all-handlers default-grid-fields-schema entity-id - form-validation-error html-response many-entity - modal-response ref->enum-schema ref->select-options strip - temp-id wrap-entity wrap-form-4xx-2 wrap-merge-prior-hx + form-validation-error html-response main-transformer many-entity + modal-response path->name2 ref->enum-schema ref->select-options strip + temp-id wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]] [bidi.bidi :as bidi] [clojure.string :as str] [datomic.api :as dc] [malli.core :as mc] + [malli.error :as me] [malli.transform :as mt] - [malli.util :as mut])) + [malli.util :as mut] + [slingshot.slingshot :refer [try+]])) (def query-schema (mc/schema [:maybe (into [:map {} - [:name {:optional true :default nil} [:maybe [:string {:string/decode strip}]]] - #_[:role {:optional true} [:maybe (ref->enum-schema "user-role")]] - #_[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]] + [:name {:optional true :default nil} [:maybe [:string {:string/decode strip}]]]] default-grid-fields-schema)])) + (defn filters [request] [:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" "hx-get" (bidi/path-for ssr-routes/only-routes @@ -67,9 +73,7 @@ {:value "only-hidden" :content "Only hidden"} {:value "only-global" - :content "Only global"} - #_{:value "potential-duplicates" - :content "Potential duplicates"}]}))]]) + :content "Only global"}]}))]]) (def default-read '[:db/id :vendor/name @@ -140,7 +144,6 @@ (defn fetch-page [request] (let [db (dc/db conn) {ids-to-retrieve :ids matching-count :count} (fetch-ids db request)] - [(->> (hydrate-results ids-to-retrieve db request)) matching-count])) @@ -203,7 +206,16 @@ (def row* (partial helper/row* grid-page)) (def table* (partial helper/table* grid-page)) -(defn merge-submit [{:keys [form-params request-method identity] :as request}] +;; --------------------------------------------------------------------------- +;; Merge dialog (a plain two-field form, not a wizard). +;; --------------------------------------------------------------------------- + +(def merge-form-schema (mc/schema + [:map + [:source-vendor {:optional false} entity-id] + [:target-vendor {:optional false} entity-id]])) + +(defn merge-submit [{:keys [form-params identity]}] (if (= (:source-vendor form-params) (:target-vendor form-params)) (form-validation-error "Please select two different vendors" @@ -215,10 +227,8 @@ (dc/db conn) (:source-vendor form-params)) (mapcat (fn [[src attr]] - [[:db/retract src attr (:source-vendor form-params)] [:db/add src attr (:target-vendor form-params)]])))] - (alog/peek transaction) (audit-transact-batch transaction identity) (audit-transact [[:db/retractEntity (:source-vendor form-params)]] identity)) (html-response @@ -226,87 +236,37 @@ :headers {"hx-trigger" (hx/json {"modalclose" "" "notification" "Vendor merge successful."})})) -(defn back-button [] - [:a {"@click" "$dispatch('modalprevious')" - "class" "text-sm font-medium text-gray-700 cursor-pointer"} - "Back"]) +(defn merge-dialog [{:keys [form-params form-errors]}] + (let [vendor-typeahead (fn [field label] + (com/validated-field {:label label :errors (get form-errors field)} + (com/typeahead {:name (name field) + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :vendor-search) + :id (str "vendor-search-" (name field)) + :value (get form-params field) + :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))] + (modal-response + [:div {:class "w-full h-full"} + [:form#my-form {:hx-ext "response-targets" + :hx-swap "outerHTML" + :hx-target-400 "#form-errors .error-content" + :hx-trigger "submit" + :class "h-full w-full" + :hx-put (str (bidi/path-for ssr-routes/only-routes ::route/merge-submit))} + (com/modal + {} + (com/modal-card + {} + [:div.m-2 "Merge Vendors"] + [:div.space-y-6.m-1 + (vendor-typeahead :source-vendor "Source vendor (to be deleted)") + (vendor-typeahead :target-vendor "Target vendor")] + [:div.flex.justify-end + [:div#form-errors (com/form-errors {:errors (:form form-errors)})] + [:div.flex.items-baseline.gap-x-4 + (com/validated-save-button {:errors (seq form-errors) :class "w-48"} "Merge")]]))]]))) -(defn timeline [{:keys [active]}] - (let [steps ["Info" "Terms" "Account" "Address" "Legal"] - active-index (.indexOf steps active)] - (timeline/timeline - {} - (for [[n i] (map vector steps (range))] - (timeline/timeline-step (cond-> {} - (= i active-index) (assoc :active? true) - (< i active-index) (assoc :visited? true) - (= i (dec (count steps))) (assoc :last? true)) - n))))) - -;; TODO add plaid merchant -;; TODO each client only used once - -(defn terms-override-row [terms-override-cursor] - (com/data-grid-row - (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? terms-override-cursor))))}) - :data-key "show" - :x-ref "p"} - hx/alpine-mount-then-appear) - (list - (fc/with-field :db/id - (com/hidden {:name (fc/field-name) - :value (fc/field-value)})) - (fc/with-field :vendor-terms-override/client - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - (com/typeahead {:name (fc/field-name) - :error? (fc/error?) - :autofocuse true - :class "w-full grow shrink" - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes :company-search) - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})))) - (fc/with-field :vendor-terms-override/terms - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - [:div.flex.items-baseline.gap-x-4 - (com/int-input {:name (fc/field-name) - :value (fc/field-value) - :class "w-16"}) - "days"]))) - (com/data-grid-cell {:class "align-top"} - (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) - -(defn automatically-paid-when-due-row [terms-override-cursor] - (com/data-grid-row - (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? terms-override-cursor))))}) - :data-key "show" - :x-ref "p"} - hx/alpine-mount-then-appear) - (list - (com/data-grid-cell - {} - (com/validated-field {:errors (fc/field-errors (:db/id fc/*current*))} - (com/typeahead {:name (fc/field-name (:db/id fc/*current*)) - :class "w-full" - :url (bidi/path-for ssr-routes/only-routes - :company-search) - :value (fc/field-value) - :value-fn :db/id - - :content-fn #(pull-attr (dc/db conn) :client/name (:db/id %)) - :size :small}))) - - (com/data-grid-cell {:class "align-top"} - (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) - -(defn- account-typeahead* - [{:keys [name value client-id x-model]}] +(defn account-typeahead* [{:keys [name value client-id x-model]}] [:div.flex.flex-col (com/typeahead {:name name :placeholder "Search..." @@ -318,74 +278,12 @@ (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) client-id)))})]) -(defn account-override-row [terms-override-cursor] - (alog/peek @terms-override-cursor) - (let [client-id (fc/field-value (:vendor-account-override/client terms-override-cursor))] - (com/data-grid-row - (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? terms-override-cursor)))) - :clientId client-id - :accountId (fc/field-value (:vendor-account-override/account terms-override-cursor))}) - :data-key "show" - :x-ref "p"} - hx/alpine-mount-then-appear) - (list - (fc/with-field :db/id - (com/hidden {:name (fc/field-name) - :value (fc/field-value)})) - (fc/with-field :vendor-account-override/client - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - (com/typeahead {:name (fc/field-name) - :error? (fc/error?) - :x-model "clientId" - :autofocuse true - :class "w-full grow shrink" - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes :company-search) - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})))) - (fc/with-field :vendor-account-override/account - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - [:div {:hx-trigger "changed" - :hx-target "next div" - :hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId, value: event.detail.accountId || ''}" (fc/field-name)) - :hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead)) - :x-init "$watch('clientId', cid => $dispatch('changed', $data));"}] - (account-typeahead* {:value (fc/field-value) - :client-id client-id - :name (fc/field-name) - :x-model "accountId"})))) - (com/data-grid-cell {:class "align-top"} - (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))) +(defn account-typeahead [{{:keys [name value client-id]} :query-params}] + (html-response (account-typeahead* {:name name :value value :client-id client-id :x-model "accountId"}))) -(defn dialog* [{:keys [entity form-params form-errors] :as params}] - (alog/peek ::dialog-entity form-params) - (fc/start-form form-params form-errors - [:div {:x-data (hx/json {"vendorName" (:vendor/name form-params) - "showPrintAs" (boolean (not-empty (:vendor/print-as form-params))) - "printAs" (:vendor/print-as form-params)}) - :class "w-full h-full"} - [:form#my-form (-> {:hx-ext "response-targets" - :hx-swap "outerHTML" - :hx-target-400 "#form-errors .error-content" - :hx-trigger "submit" - :class "h-full w-full"} - (assoc (if (:db/id entity) - :hx-put - :hx-post) - (str (bidi/path-for ssr-routes/only-routes ::route/save)))) - (com/modal - {} - - #_(terms-modal params) - #_(account-modal params) - - [:div])]])) +;; --------------------------------------------------------------------------- +;; Wizard: schema, de-cursored field names + errors, per-step renders. +;; --------------------------------------------------------------------------- (def form-schema (mc/schema [:map @@ -409,7 +307,6 @@ [:vendor-account-override/client entity-id])] [:vendor/hidden {:default false} [:boolean {:decode/string {:enter #(if (= % "on") true - (boolean %))}}]] [:vendor/address {:optional true} [:maybe @@ -428,483 +325,403 @@ [:vendor/legal-entity-tin-type {:optional true} [:maybe (ref->enum-schema "legal-entity-tin-type")]] [:vendor/legal-entity-1099-type {:optional true} [:maybe (ref->enum-schema "legal-entity-1099-type")]]])) -(def merge-form-schema (mc/schema - [:map - [:source-vendor {:optional false} entity-id] - [:target-vendor {:optional false} entity-id]])) +(def ^:dynamic *errors* {}) +(defn- ferr [& path] (get-in *errors* (vec path))) +(defn- err? [& path] (boolean (seq (apply ferr path)))) -(defn merge-dialog [{:keys [entity form-params form-errors]}] - (modal-response - (fc/start-form form-params form-errors - [:div {:class "w-full h-full"} - [:form#my-form (-> {:hx-swap "outerHTML" - :hx-trigger "submit" - :class "h-full w-full" - :hx-put (str (bidi/path-for ssr-routes/only-routes ::route/merge-submit))}) - (com/modal - {} - (com/modal-card - {} - [:div.m-2 "Merge Vendors"] +(defn- step-schema [ks] (mc/schema (mut/select-keys form-schema ks))) - [:div.space-y-6.m-1 - (fc/with-field :source-vendor - (com/validated-field {:label "Source vendor (to be deleted)" - :errors (fc/field-errors)} - (com/typeahead {:name (fc/field-name) - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes - :vendor-search) - :id (str "vendor-search") - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))}))) - (fc/with-field :target-vendor - (com/validated-field {:label "Target vendor" - :errors (fc/field-errors)} - (com/typeahead {:name (fc/field-name) - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes - :vendor-search) - :id (str "vendor-search") - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))] - [:div.flex.justify-end - (com/form-errors {:errors (:errors fc/*form-errors*)}) - [:div.flex.items-baseline.gap-x-4 - (com/validated-save-button {:errors (seq form-errors) - :class "w-48"} +(def ^:private info-schema (step-schema #{:vendor/name :vendor/print-as :vendor/hidden :db/id})) +(def ^:private terms-schema (step-schema #{:vendor/terms :vendor/terms-overrides :vendor/automatically-paid-when-due})) +(def ^:private account-schema (step-schema #{:vendor/default-account :vendor/account-overrides})) +(def ^:private address-schema (step-schema #{:vendor/address})) +(def ^:private legal-schema (step-schema #{:vendor/legal-entity-1099-type :vendor/legal-entity-first-name + :vendor/legal-entity-last-name :vendor/legal-entity-tin + :vendor/legal-entity-tin-type :vendor/legal-entity-middle-name + :vendor/legal-entity-name})) - "Merge")]]))]]))) +(defn- decode-with [schema request] + ;; An all-empty step posts only blank fields; main-transformer's parse-empty-as-nil then + ;; decodes the resulting all-nil map to nil. Coerce that back to {} so an optional-only + ;; step (legal/address) validates + advances, while a required-field step (account) still + ;; fails validation on the missing key rather than on a spurious nil "invalid type". + (or (mc/decode schema (:form-params (nfp/nested-params-request request {})) main-transformer) {})) -(defn account-typeahead [{{:keys [name value client-id] :as qp} :query-params}] - (html-response (account-typeahead* {:name name - :value value - :client-id client-id - :x-model "accountId"}))) +(defn- validate-with + "Per-step validation (mirrors the old wrap-ensure-step step-schema assertion): a step can't + advance until its fields satisfy the step schema. Returns a humanized errors map or nil." + [schema data _request] + (when-not (mc/validate schema data) + (me/humanize (mc/explain schema data)))) -(defrecord LegalEntityModal [linear-wizard] - mm/ModalWizardStep - (step-key [this] - :legal) - (edit-path [this request] []) - (render-step [this _] - (mm/default-render-step - linear-wizard this - :head [:div.flex [:div.p-2 "Legal Entity"] [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 - [:span {:x-text "vendorName"}]]] - :body (mm/default-step-body - {} - [:div {:class "w-[600px] h-[350px]"} - [:div.grid.grid-cols-6.gap-x-4.gap-y-2 - [:div.col-span-6 - (fc/with-field :vendor/legal-entity-name - (com/validated-field {:label "Legal Entity Name" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :class "w-full" - :autofocus true - :value (fc/field-value) - :placeholder "Good Restaurant LLC"})))] - [:div.col-span-6.text-center " - OR -"] - [:div.col-span-2 - (fc/with-field :vendor/legal-entity-first-name - (com/validated-field {:label "First Name" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :value (fc/field-value) - :placeholder "John"})))] - [:div.col-span-2 - (fc/with-field :vendor/legal-entity-middle-name - (com/validated-field {:label "Middle Name" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :value (fc/field-value) - :placeholder "C."})))] - [:div.col-span-2 - (fc/with-field :vendor/legal-entity-last-name - (com/validated-field {:label "Last Name" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :value (fc/field-value) - :placeholder "Riley"})))] - [:div.col-span-2 - (fc/with-field :vendor/legal-entity-tin - (com/validated-field {:label "TIN" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :value (fc/field-value) - :placeholder "John"})))] - [:div.col-span-2 - (fc/with-field :vendor/legal-entity-tin-type - (com/validated-field {:label "TIN Type" - :errors (fc/field-errors)} - (com/select {:name (fc/field-name) - :allow-blank? true - :value (some-> (fc/field-value) name) - :options [["ein" "EIN"] - ["ssn" "SSN"]]})))] - [:div.col-span-2 - (fc/with-field :vendor/legal-entity-1099-type - (com/validated-field {:label "1099 Type" - :errors (fc/field-errors)} - (com/select {:name (fc/field-name) - :allow-blank? true - :value (some-> (fc/field-value) name) - :options (ref->select-options "legal-entity-1099-type")})))]]]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) - :validation-route ::route/navigate)) - (step-schema [this] - (mut/select-keys form-schema #{:vendor/legal-entity-1099-type :vendor/legal-entity-first-name :vendor/legal-entity-last-name :vendor/legal-entity-tin - :vendor/legal-entity-tin-type :vendor/legal-entity-middle-name :vendor/legal-entity-name})) - (step-name [this] - "Legal Entity")) +(defn- vendor-timeline [active] + (let [steps ["Info" "Terms" "Account" "Address" "Legal"] + active-index (.indexOf steps active)] + (timeline/timeline + {} + (for [[n i] (map vector steps (range))] + (timeline/timeline-step (cond-> {} + (= i active-index) (assoc :active? true) + (< i active-index) (assoc :visited? true) + (= i (dec (count steps))) (assoc :last? true)) + n))))) -(defrecord AddressModal [linear-wizard] - mm/ModalWizardStep - (step-key [this] - :address) - (edit-path [this request] []) - (render-step [this _] - (mm/default-render-step - linear-wizard this - :head [:div.flex [:div.p-2 "Address"] [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 - [:span {:x-text "vendorName"}]]] - :body (mm/default-step-body - {} - [:div.space-y-1 {:class "w-[600px] h-[350px]"} - (fc/with-field-default :vendor/address {} - [:div.flex.flex-col.w-full - (when (:db/id @fc/*current*) - (fc/with-field :db/id - (com/hidden {:name (fc/field-name) - :value (fc/field-value)}))) - (fc/with-field :address/street1 - (com/validated-field {:label "Street" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :error? (fc/error?) - :autofocus true - :class "w-full" - :placeholder "1200 Pennsylvania Avenue" - :value (fc/field-value)}))) - (fc/with-field :address/street2 - (com/validated-field {:errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :error? (fc/error?) - :class "w-full" - :placeholder "Suite 300" - :value (fc/field-value)}))) - [:div.flex.w-full.space-x-4 - (fc/with-field :address/city - (com/validated-field {:errors (fc/field-errors) - :class "w-full grow shrink"} - (com/text-input {:name (fc/field-name) - :error? (fc/error?) - :class "w-full" - :placeholder "Suite 300" - :value (fc/field-value)}))) - (fc/with-field :address/state - (com/validated-field {:errors (fc/field-errors) - :class "w-16 shrink-0"} - (com/text-input {:name (fc/field-name) - :error? (fc/error?) - :class "w-full" - :placeholder "Suite 300" - :value (fc/field-value)}))) - (fc/with-field :address/zip - (com/validated-field {:errors (fc/field-errors) - :class "w-24 shrink-0"} - (com/text-input {:name (fc/field-name) - :error? (fc/error?) - :placeholder "Suite 300" - :class "w-full" - :value (fc/field-value)})))]])]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) - :validation-route ::route/navigate)) - (step-schema [this] - (mut/select-keys form-schema #{:vendor/address})) - (step-name [this] - "Address")) +(defn- step-card + "A wizard step's modal card: header (title + the vendor name chip), a side timeline, the + body, and the engine nav footer. `x-data` carries vendorName/showPrintAs/printAs so the + header chip + the print-as toggle work within the (per-step) form scope." + [{:keys [title active all-data nav body]}] + (com/modal-card-advanced + {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" + :class " md:w-[760px] md:h-[520px] w-full h-full" + :x-data (hx/json {"vendorName" (:vendor/name all-data) + "showPrintAs" (boolean (not-empty (:vendor/print-as all-data))) + "printAs" (:vendor/print-as all-data)})} + (com/modal-header {} [:div.flex [:div.p-2 title] + [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 [:span {:x-text "vendorName"}]]]) + [:div.flex.shrink.overflow-auto.grow + [:div.grow-0.pr-6.pt-2.bg-gray-100.self-stretch.hidden.md:block (vendor-timeline active)] + (com/modal-body {} body)] + (com/modal-footer {} nav))) -(defrecord AccountModal [linear-wizard] - mm/ModalWizardStep - (step-key [this] - :account) - (edit-path [this request] - []) - (render-step [this _] - (mm/default-render-step - linear-wizard this - :head [:div.flex [:div.p-2 "Account Assignments"] [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 - [:span {:x-text "vendorName"}]]] - :body (mm/default-step-body - {} - [:div.space-y-1 {:class "w-[600px] h-[350px] "} - (fc/with-field :vendor/default-account - (alog/info ::acount-check :a (fc/field-value)) - (com/validated-field {:label "Default Account" - :errors (fc/field-errors)} - (com/typeahead {:name (fc/field-name) - :error? (fc/error?) - :autofocus true - :class "w-96" - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes :account-search) - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :account/name c))}))) - (fc/with-field :vendor/account-overrides +(defn render-info [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {})] + (step-card + {:title "Basic Info" :active "Info" :all-data (merge all-data data) + :nav (wizard2/nav-footer {:next "Terms"}) + :body [:div.space-y-1.mt-4 {:class "w-[600px] h-[350px]"} + (when (:db/id data) (com/hidden {:name "db/id" :value (:db/id data)})) (com/validated-field - {:errors (fc/field-errors) - :label "Account Overrides"} - (com/data-grid {:headers [(com/data-grid-header {} "Client") - (com/data-grid-header {} "Account") - (com/data-grid-header {:class "w-16"})]} - (fc/cursor-map #(account-override-row %)) - (com/data-grid-new-row {:colspan 3 - :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-account-override) - :index (count (fc/field-value))} - "New override"))))]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) - :validation-route ::route/navigate)) - (step-schema [this] - (mut/select-keys (mm/form-schema linear-wizard) #{:vendor/account-overrides :vendor/default-account})) - (step-name [this] - "Account Assignment")) - -;; TODO signature -(defrecord TermsModal [linear-wizard] - mm/ModalWizardStep - (step-key [this] - :terms) - (edit-path [this request] - []) - (render-step [this _] - (mm/default-render-step - linear-wizard this - :head [:div.flex [:div.p-2 "Terms"] [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 - [:span {:x-text "vendorName"}]]] - :body (mm/default-step-body - {} - [:div.space-y-1 {:class "w-[600px] h-[350px]"} - (fc/with-field :vendor/terms - (com/validated-field {:label "Terms" - :errors (fc/field-errors)} - [:div.flex.items-baseline.gap-x-4 - (com/int-input {:name (fc/field-name) - :autofocus true - :value (fc/field-value)}) - "days"])) - (fc/with-field :vendor/terms-overrides + {:label "Name" :errors (ferr :vendor/name)} + (com/text-input {:name "vendor/name" :value (:vendor/name data) + :error? (err? :vendor/name) :x-model "vendorName" :autofocus true :class "w-96"})) (com/validated-field - {:errors (fc/field-errors) - :label "Terms Overrides"} + {} + [:div (com/checkbox {:x-model "showPrintAs" "@change" "if (!showPrintAs) { printAs = ''; }"} + "Use different name for checks")]) + (com/validated-field + (-> {:label "Print as" :errors (ferr :vendor/print-as) :x-show "showPrintAs"} + hx/alpine-appear hx/alpine-disappear) + (com/text-input {:name "vendor/print-as" :x-model "printAs" + :value (:vendor/print-as data) :class "w-96"})) + (com/checkbox {:name "vendor/hidden" :value (boolean (:vendor/hidden data)) + :checked (boolean (:vendor/hidden data))} + "Admin-only")]})))) + +(defn- terms-override-row [{:keys [data index]}] + (com/data-grid-row + (-> {:x-data (hx/json {:show (boolean (not (:new? data)))}) :data-key "show" :x-ref "p"} + hx/alpine-mount-then-appear) + (com/hidden {:name (path->name2 :vendor/terms-overrides index :db/id) :value (:db/id data)}) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ferr :vendor/terms-overrides index :vendor-terms-override/client)} + (com/typeahead {:name (path->name2 :vendor/terms-overrides index :vendor-terms-override/client) + :class "w-full grow shrink" :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :company-search) + :value (:vendor-terms-override/client data) + :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))}))) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ferr :vendor/terms-overrides index :vendor-terms-override/terms)} + [:div.flex.items-baseline.gap-x-4 + (com/int-input {:name (path->name2 :vendor/terms-overrides index :vendor-terms-override/terms) + :value (:vendor-terms-override/terms data) :class "w-16"}) + "days"])) + (com/data-grid-cell {:class "align-top"} + (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) + +(defn- automatically-paid-when-due-row [{:keys [data index]}] + (com/data-grid-row + (-> {:x-data (hx/json {:show (boolean (not (:new? data)))}) :data-key "show" :x-ref "p"} + hx/alpine-mount-then-appear) + (com/data-grid-cell + {} + (com/validated-field {:errors (ferr :vendor/automatically-paid-when-due index :db/id)} + (com/typeahead {:name (path->name2 :vendor/automatically-paid-when-due index :db/id) + :class "w-full" + :url (bidi/path-for ssr-routes/only-routes :company-search) + :value data + :value-fn :db/id + :content-fn #(pull-attr (dc/db conn) :client/name (:db/id %)) + :size :small}))) + (com/data-grid-cell {:class "align-top"} + (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) + +(defn- account-override-row [{:keys [data index]}] + (let [client (:vendor-account-override/client data) + client-id (if (map? client) (:db/id client) client) + account (:vendor-account-override/account data) + account-id (if (map? account) (:db/id account) account) + aname (path->name2 :vendor/account-overrides index :vendor-account-override/account)] + (com/data-grid-row + (-> {:x-data (hx/json {:show (boolean (not (:new? data))) :clientId client-id :accountId account-id}) + :data-key "show" :x-ref "p"} + hx/alpine-mount-then-appear) + (com/hidden {:name (path->name2 :vendor/account-overrides index :db/id) :value (:db/id data)}) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ferr :vendor/account-overrides index :vendor-account-override/client)} + (com/typeahead {:name (path->name2 :vendor/account-overrides index :vendor-account-override/client) + :x-model "clientId" :class "w-full grow shrink" :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :company-search) + :value client + :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))}))) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ferr :vendor/account-overrides index :vendor-account-override/account)} + [:div {:hx-trigger "changed" + :hx-target "next div" + :hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId, value: event.detail.accountId || ''}" aname) + :hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead)) + :x-init "$watch('clientId', cid => $dispatch('changed', $data));"}] + (account-typeahead* {:value account-id :client-id client-id :name aname :x-model "accountId"}))) + (com/data-grid-cell {:class "align-top"} + (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) + +(defn render-terms [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + overrides (vec (:vendor/terms-overrides data)) + autos (vec (:vendor/automatically-paid-when-due data))] + (step-card + {:title "Terms" :active "Terms" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Account"}) + :body [:div.space-y-1 {:class "w-[600px] h-[350px]"} + (com/validated-field + {:label "Terms" :errors (ferr :vendor/terms)} + [:div.flex.items-baseline.gap-x-4 + (com/int-input {:name "vendor/terms" :value (:vendor/terms data)}) "days"]) + (com/validated-field + {:label "Terms Overrides" :errors (ferr :vendor/terms-overrides)} (com/data-grid {:headers [(com/data-grid-header {} "Client") (com/data-grid-header {:class "w-16"} "Terms") (com/data-grid-header {:class "w-16"})]} - (fc/cursor-map #(terms-override-row %)) - (com/data-grid-new-row {:colspan 3 - :hx-get (bidi/path-for ssr-routes/only-routes - ::route/new-terms-override) - :index (count (fc/field-value))} - "New override")))) - - (fc/with-field :vendor/automatically-paid-when-due + (map-indexed (fn [i a] (terms-override-row {:data a :index i})) overrides) + (com/data-grid-new-row {:colspan 3 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-terms-override) + :index (count overrides)} + "New override"))) (com/validated-field - {:errors (fc/field-errors) - :label "Automatically pay when due"} + {:label "Automatically pay when due" :errors (ferr :vendor/automatically-paid-when-due)} (com/data-grid {:headers [(com/data-grid-header {} "Client") (com/data-grid-header {:class "w-16"})]} - (fc/cursor-map #(automatically-paid-when-due-row %)) - (com/data-grid-new-row {:colspan 2 - :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-automatic-payment) - :index (count (fc/field-value))} - "New automatic payment for client"))))]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) - :validation-route ::route/navigate)) - (step-schema [this] - (mut/select-keys (mm/form-schema linear-wizard) #{:vendor/terms :vendor/terms-overrides :vendor/automatically-paid-when-due})) - (step-name [this] - "Terms")) + (map-indexed (fn [i a] (automatically-paid-when-due-row {:data a :index i})) autos) + (com/data-grid-new-row {:colspan 2 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-automatic-payment) + :index (count autos)} + "New automatic payment for client")))]})))) -(defrecord InfoModal [linear-wizard] - mm/ModalWizardStep - (step-name [this] - "Basic Info") +(defn render-account [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + overrides (vec (:vendor/account-overrides data)) + default-account (:vendor/default-account data)] + (step-card + {:title "Account Assignments" :active "Account" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Address"}) + :body [:div.space-y-1 {:class "w-[600px] h-[350px] "} + (com/validated-field + {:label "Default Account" :errors (ferr :vendor/default-account)} + (com/typeahead {:name "vendor/default-account" + :error? (err? :vendor/default-account) + :autofocus true :class "w-96" :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :account-search) + :value (if (map? default-account) (:db/id default-account) default-account) + :content-fn (fn [c] (pull-attr (dc/db conn) :account/name c))})) + (com/validated-field + {:label "Account Overrides" :errors (ferr :vendor/account-overrides)} + (com/data-grid {:headers [(com/data-grid-header {} "Client") + (com/data-grid-header {} "Account") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i a] (account-override-row {:data a :index i})) overrides) + (com/data-grid-new-row {:colspan 3 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-account-override) + :index (count overrides)} + "New override")))]})))) - (step-key [this] - :info) +(defn render-address [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [addr (or (:vendor/address (or step-data {})) {}) + an (fn [f] (path->name2 :vendor/address f))] + (step-card + {:title "Address" :active "Address" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Legal"}) + :body [:div.space-y-1 {:class "w-[600px] h-[350px]"} + [:div.flex.flex-col.w-full + (when (:db/id addr) (com/hidden {:name (an :db/id) :value (:db/id addr)})) + (com/validated-field + {:label "Street" :errors (ferr :vendor/address :address/street1)} + (com/text-input {:name (an :address/street1) :autofocus true :class "w-full" + :placeholder "1200 Pennsylvania Avenue" :value (:address/street1 addr)})) + (com/validated-field + {:errors (ferr :vendor/address :address/street2)} + (com/text-input {:name (an :address/street2) :class "w-full" + :placeholder "Suite 300" :value (:address/street2 addr)})) + [:div.flex.w-full.space-x-4 + (com/validated-field + {:errors (ferr :vendor/address :address/city) :class "w-full grow shrink"} + (com/text-input {:name (an :address/city) :class "w-full" :placeholder "City" :value (:address/city addr)})) + (com/validated-field + {:errors (ferr :vendor/address :address/state) :class "w-16 shrink-0"} + (com/text-input {:name (an :address/state) :class "w-full" :placeholder "DC" :value (:address/state addr)})) + (com/validated-field + {:errors (ferr :vendor/address :address/zip) :class "w-24 shrink-0"} + (com/text-input {:name (an :address/zip) :class "w-full" :placeholder "20500" :value (:address/zip addr)}))]]]})))) - (edit-path [this request] []) +(defn render-legal [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {})] + (step-card + {:title "Legal Entity" :active "Legal" :all-data all-data + :nav (wizard2/nav-footer {:back? true :save? true}) + :body [:div {:class "w-[600px] h-[350px]"} + [:div.grid.grid-cols-6.gap-x-4.gap-y-2 + [:div.col-span-6 + (com/validated-field + {:label "Legal Entity Name" :errors (ferr :vendor/legal-entity-name)} + (com/text-input {:name "vendor/legal-entity-name" :class "w-full" :autofocus true + :value (:vendor/legal-entity-name data) :placeholder "Good Restaurant LLC"}))] + [:div.col-span-6.text-center " - OR -"] + [:div.col-span-2 + (com/validated-field + {:label "First Name" :errors (ferr :vendor/legal-entity-first-name)} + (com/text-input {:name "vendor/legal-entity-first-name" :value (:vendor/legal-entity-first-name data) :placeholder "John"}))] + [:div.col-span-2 + (com/validated-field + {:label "Middle Name" :errors (ferr :vendor/legal-entity-middle-name)} + (com/text-input {:name "vendor/legal-entity-middle-name" :value (:vendor/legal-entity-middle-name data) :placeholder "C."}))] + [:div.col-span-2 + (com/validated-field + {:label "Last Name" :errors (ferr :vendor/legal-entity-last-name)} + (com/text-input {:name "vendor/legal-entity-last-name" :value (:vendor/legal-entity-last-name data) :placeholder "Riley"}))] + [:div.col-span-2 + (com/validated-field + {:label "TIN" :errors (ferr :vendor/legal-entity-tin)} + (com/text-input {:name "vendor/legal-entity-tin" :value (:vendor/legal-entity-tin data) :placeholder "12-3456789"}))] + [:div.col-span-2 + (com/validated-field + {:label "TIN Type" :errors (ferr :vendor/legal-entity-tin-type)} + (com/select {:name "vendor/legal-entity-tin-type" :allow-blank? true + :value (some-> (:vendor/legal-entity-tin-type data) name) + :options [["ein" "EIN"] ["ssn" "SSN"]]}))] + [:div.col-span-2 + (com/validated-field + {:label "1099 Type" :errors (ferr :vendor/legal-entity-1099-type)} + (com/select {:name "vendor/legal-entity-1099-type" :allow-blank? true + :value (some-> (:vendor/legal-entity-1099-type data) name) + :options (ref->select-options "legal-entity-1099-type")}))]]]})))) - (step-schema [this] - (mut/select-keys (mm/form-schema linear-wizard) #{:vendor/name :vendor/print-as :vendor/hidden})) +;; --------------------------------------------------------------------------- +;; done-fn + engine config + handlers. +;; --------------------------------------------------------------------------- - (render-step [this _] - (mm/default-render-step - linear-wizard this - :head [:div.flex [:div.p-2 "Basic Info"] [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 - [:span {:x-text "vendorName"}]]] - :body (mm/default-step-body {} - [:div.space-y-1.mt-4 {:class "w-[600px] h-[350px]"} - (fc/with-field :vendor/name - (com/validated-field {:label "Name" - :errors (fc/field-errors)} - (com/text-input {:name (fc/field-name) - :value (fc/field-value) - :x-model "vendorName" - :autofocus true - :class "w-96"}))) - (com/validated-field - {} - [:div (com/checkbox - {:x-model "showPrintAs" "@change" "if (!showPrintAs) { printAs = ''; } "} - "Use different name for checks")]) +(defn- blank-address? + "A new (db/id-less) address whose every field is nil -- the empty Address step. Upserting + it would create an attribute-less entity (datomic: \"tempid used only as value\"), so it + must be dropped rather than persisted." + [a] + (and (map? a) (not (:db/id a)) (every? nil? (vals a)))) - (fc/with-field :vendor/print-as - (com/validated-field (-> {:label "Print as" - :errors (fc/field-errors) - :x-show "showPrintAs"} - hx/alpine-appear - hx/alpine-disappear) - (com/text-input {:name (fc/field-name) - :x-model "printAs" - :value (fc/field-value) - :class "w-96"}))) +(defn save-vendor! + "Engine done-fn: combine the 5 steps, upsert the vendor, reindex Solr, return the row." + [all-data {:keys [identity] :as request}] + (let [decoded (mc/decode form-schema all-data mt/strip-extra-keys-transformer) + snapshot (cond-> decoded + (blank-address? (:vendor/address decoded)) (dissoc :vendor/address)) + new? (not (:db/id snapshot)) + entity (cond-> snapshot new? (assoc :db/id "new")) + {:keys [tempids]} (audit-transact [[:upsert-entity entity]] identity) + updated-vendor (dc/pull (dc/db conn) default-read + (or (get tempids (:db/id entity)) (:db/id entity)))] + (solr/index-documents-raw + solr/impl "vendors" + [{"id" (:db/id updated-vendor) + "name" (:vendor/name updated-vendor) + "hidden" (boolean (:vendor/hidden updated-vendor))}]) + (html-response + (row* identity updated-vendor {:flash? true}) + :headers (cond-> {"hx-trigger" "modalclose"} + new? (assoc "hx-retarget" "#entity-table tbody" "hx-reswap" "afterbegin") + (not new?) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-vendor)) + "hx-reswap" "outerHTML"))))) - (fc/with-field :vendor/hidden - (alog/peek (cursor/path fc/*current*)) - (com/checkbox {:name (fc/field-name) - :value (boolean (fc/field-value)) ; - :checked (alog/peek :checked (fc/field-value))} - "Admin-only"))]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) - :validation-route ::route/navigate))) +(defn vendor-init-fn + "New: empty steps. Edit: pull the entity, split it across the steps so each opens + populated. Vendor steps carry no dates, so step-data is trivially EDN-safe." + [request] + (if-let [id (->db-id (get-in request [:route-params :db/id]))] + (let [e (dc/pull (dc/db conn) default-read id)] + {:init-data {:info (select-keys e [:db/id :vendor/name :vendor/print-as :vendor/hidden]) + :terms (select-keys e [:vendor/terms :vendor/terms-overrides :vendor/automatically-paid-when-due]) + :account (select-keys e [:vendor/default-account :vendor/account-overrides]) + :address (select-keys e [:vendor/address]) + :legal (select-keys e [:vendor/legal-entity-name :vendor/legal-entity-first-name + :vendor/legal-entity-middle-name :vendor/legal-entity-last-name + :vendor/legal-entity-tin :vendor/legal-entity-tin-type + :vendor/legal-entity-1099-type])}}) + {:init-data {}})) -;; have a clear way to set up the form to handle the unexpected-errors -;; TODO feature flags -;; TODO move signature to client page -(defrecord VendorWizard [current-step] - mm/LinearModalWizard - (hydrate-from-request [this _] - this) - (navigate [this step-key] - (assoc this :current-step step-key)) - (get-current-step [this] - (if current-step - (mm/get-step this current-step) - (mm/get-step this :info))) - (render-wizard [this {:keys [multi-form-state] :as request}] - (mm/default-render-wizard this request - :form-params - (-> mm/default-form-props - (assoc (if (get-in multi-form-state [:snapshot :db/id]) - :hx-put - :hx-post) - (str (bidi/path-for ssr-routes/only-routes ::route/save)) - :x-data (hx/json {"vendorName" (:vendor/name (:snapshot multi-form-state)) - "showPrintAs" (boolean (not-empty (:vendor/print-as (:snapshot multi-form-state)))) - "printAs" (:vendor/print-as (:snapshot multi-form-state))}))))) - (steps [_] - [:info - :terms - :account - :address - :legal]) +(def vendor-wizard-config + {:name :vendor + :form-id "wizard-form" + :submit-route (bidi/path-for ssr-routes/only-routes ::route/save) + :form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"} + :open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) + :init-fn vendor-init-fn + :steps [{:key :info :decode (partial decode-with info-schema) :validate (partial validate-with info-schema) :render render-info :next (fn [_] :terms)} + {:key :terms :decode (partial decode-with terms-schema) :validate (partial validate-with terms-schema) :render render-terms :next (fn [_] :account)} + {:key :account :decode (partial decode-with account-schema) :validate (partial validate-with account-schema) :render render-account :next (fn [_] :address)} + {:key :address :decode (partial decode-with address-schema) :validate (partial validate-with address-schema) :render render-address :next (fn [_] :legal)} + {:key :legal :decode (partial decode-with legal-schema) :validate (partial validate-with legal-schema) :render render-legal :next (fn [_] :done)}] + :done-fn save-vendor!}) - (get-step [this step-key] - (let [step-key-result (mc/parse mm/step-key-schema step-key) - [step-key-type step-key] step-key-result] - (if (= :step step-key-type) - (get {:info (->InfoModal this) - :terms (->TermsModal this) - :account (->AccountModal this) - :address (->AddressModal this) - :legal (->LegalEntityModal this)} - step-key)))) - (form-schema [this] form-schema) - (submit [this {:keys [multi-form-state request-method identity entity] :as request}] - (let [snapshot (mc/decode - form-schema - (:snapshot multi-form-state) - mt/strip-extra-keys-transformer) - entity (cond-> snapshot - (= :post request-method) (assoc :db/id "new")) - {:keys [tempids]} (audit-transact [[:upsert-entity entity]] - (:identity request)) - updated-vendor (dc/pull (dc/db conn) - default-read - (or (get tempids (:db/id entity)) (:db/id entity)))] - (solr/index-documents-raw - solr/impl - "vendors" - [{"id" (:db/id updated-vendor) - "name" (:vendor/name updated-vendor) - "hidden" (boolean (:vendor/hidden updated-vendor))}]) - (html-response - (row* identity updated-vendor {:flash? true}) - :headers (cond-> {"hx-trigger" "modalclose"} - (= :post request-method) (assoc "hx-retarget" "#entity-table tbody" - "hx-reswap" "afterbegin") - (= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-vendor)) - "hx-reswap" "outerHTML")))))) +(defn vendor-step + "POST handler for every wizard transition. Surface create-time validation as a 4xx into + #form-errors so the modal stays open." + [request] + (try+ + (wizard2/handle-step-submit vendor-wizard-config request) + (catch #(#{:form-validation :schema-validation :field-validation :notification} (:type %)) e + (html-response + [:span.error-content.text-red-500 (or (:message e) "Could not save the vendor.")] + :status 400)))) -(def vendor-wizard (->VendorWizard :info)) +(defn- add-row-handler + "Append one fresh de-cursored row at the posted index." + [render request] + (let [idx (-> request :query-params :index) + idx (if (string? idx) (Integer/parseInt idx) idx)] + (html-response (render {:data (wizard2/blank-row) :index idx})))) (def key->handler (apply-middleware-to-all-handlers (->> - {::route/page (helper/page-route grid-page) - ::route/table (helper/table-route grid-page) - ::route/new (-> mm/open-wizard-handler - (mm/wrap-init-multi-form-state (fn [_] - (mm/->MultiStepFormState {} - [] - {}))) - (mm/wrap-wizard vendor-wizard)) - ::route/merge merge-dialog - ::route/merge-submit (-> merge-submit - (wrap-schema-enforce :form-schema merge-form-schema) - (wrap-form-4xx-2 merge-dialog)) - - ::route/save (-> mm/submit-handler - (mm/wrap-wizard vendor-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-entity [:form-params :db/id] default-read)) - ::route/navigate - (-> mm/next-handler - (mm/wrap-wizard vendor-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-entity [:route-params :db/id] default-read) - (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) - ::route/edit (-> mm/open-wizard-handler - (mm/wrap-wizard vendor-wizard) - (mm/wrap-init-multi-form-state (fn [request] - (mm/->MultiStepFormState (:entity request) - [] - (:entity request)))) - (wrap-entity [:route-params :db/id] default-read) + {::route/page (helper/page-route grid-page) + ::route/table (helper/table-route grid-page) + ::route/new (partial wizard2/open-wizard vendor-wizard-config) + ::route/edit (-> (partial wizard2/open-wizard vendor-wizard-config) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) - ::route/new-terms-override (add-new-entity-handler [:step-params :vendor/terms-overrides] - (fn [cursor _] (terms-override-row cursor))) + ::route/save vendor-step + + ::route/merge merge-dialog + ::route/merge-submit (-> merge-submit + (wrap-schema-enforce :form-schema merge-form-schema) + (wrap-form-4xx-2 merge-dialog)) ::route/account-typeahead (-> account-typeahead (wrap-schema-enforce :query-schema [:map [:name :string] - [:client-id {:optional true} - [:maybe entity-id]] - [:value {:optional true} - [:maybe entity-id]]])) - ::route/new-automatic-payment (add-new-primitive-handler [:step-params :vendor/automatically-paid-when-due] - {} - automatically-paid-when-due-row) - - ::route/new-account-override (add-new-entity-handler [:step-params :vendor/account-overrides] - (fn [cursor _] (account-override-row cursor)))}) + [:client-id {:optional true} [:maybe entity-id]] + [:value {:optional true} [:maybe entity-id]]])) + ::route/new-terms-override (partial add-row-handler terms-override-row) + ::route/new-automatic-payment (partial add-row-handler automatically-paid-when-due-row) + ::route/new-account-override (partial add-row-handler account-override-row)}) (fn [h] (-> h (wrap-copy-qp-pqp) @@ -914,4 +731,3 @@ (wrap-schema-enforce :hx-schema query-schema) (wrap-admin) (wrap-client-redirect-unauthenticated))))) - diff --git a/src/cljc/auto_ap/routes/admin/vendors.cljc b/src/cljc/auto_ap/routes/admin/vendors.cljc index f2f62eee..e8cfb511 100644 --- a/src/cljc/auto_ap/routes/admin/vendors.cljc +++ b/src/cljc/auto_ap/routes/admin/vendors.cljc @@ -9,7 +9,6 @@ "/account-override" ::new-account-override "/account-typeahead" ::account-typeahead "/validate" ::validate - "/navigat" ::navigate "/new" {:get ::new} "/merge" {:get ::merge :put ::merge-submit}