feat(ledger): remove a whole ledger entry from the external import grid

The external ledger import review grid is one row per line item, so a
journal entry spans two or more rows. When one entry fails validation the
whole paste is rejected, and the only way forward was to re-paste without
it.

Each row now carries its entry id (client-source-externalId, the same key
table->entries groups on) and the last column gets a trash button that
drops every row sharing that id. The rows are the form inputs, so removing
them from the DOM removes them from the next import post -- which also
works for rows that fail schema validation. Index gaps left behind are
compacted by coerce-vector on the way back in.

Also drops two leftover pprint calls that dumped form-errors to stdout on
every render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 22:44:52 -07:00
parent e6507d5387
commit a17e16e31b
2 changed files with 190 additions and 79 deletions

View File

@@ -148,14 +148,15 @@
(= ::route/external-page matched-current-page-route) (assoc-in [:route-params :external?] true))] (= ::route/external-page matched-current-page-route) (assoc-in [:route-params :external?] true))]
(handler request)))) (handler request))))
(defn line->id [{:keys [source external-id client-code]}]
(str client-code "-" source "-" external-id))
(defn external-import-table-form* [request] (defn external-import-table-form* [request]
[:div#table-form [:div#table-form
(clojure.pprint/pprint (:form-errors request))
(fc/start-form (fc/start-form
(:form-params request) (:form-params request)
(:form-errors request) (:form-errors request)
(fc/with-field :table (fc/with-field :table
(clojure.pprint/pprint (fc/field-errors))
(when (seq (fc/field-value)) (when (seq (fc/field-value))
[:div {:x-data (hx/json {"showTable" false})} [:div {:x-data (hx/json {"showTable" false})}
@@ -198,7 +199,9 @@
:rows :rows
(fc/cursor-map (fc/cursor-map
(fn [r] (fn [r]
(com/data-grid-row {} (com/data-grid-cell {} (let [entry-id (line->id (fc/field-value r))]
(com/data-grid-row {:data-entry-id entry-id}
(com/data-grid-cell {}
(fc/with-field :external-id (fc/with-field :external-id
(com/validated-field (com/validated-field
{:errors (fc/field-errors)} {:errors (fc/field-errors)}
@@ -253,7 +256,7 @@
:name (fc/field-name) :name (fc/field-name)
:class "w-24"})))) :class "w-24"}))))
(com/data-grid-cell {:class "align-top"} (com/data-grid-cell {:class "align-top"}
[:div.p-2 [:div.p-2.flex.items-start.gap-2
(let [errors (seq (fc/field-errors))] (let [errors (seq (fc/field-errors))]
(cond errors (cond errors
[:div [:div
@@ -272,7 +275,14 @@
(for [[m] errors] (for [[m] errors]
[:li m])]]] [:li m])]]]
:else :else
nil))]))))} nil))
(com/icon-button {:type "button"
:color :danger-light
:title (str "Remove ledger entry " entry-id)
:aria-label (str "Remove ledger entry " entry-id)
:data-remove-entry-id entry-id
"@click.prevent.stop" "removeImportEntryRows($el)"}
svg/trash)])))))}
[:div.flex.m-4.flex-row-reverse [:div.flex.m-4.flex-row-reverse
(com/button {:color :primary} "Import")])]]])))]) (com/button {:color :primary} "Import")])]]])))])
@@ -332,6 +342,19 @@
var r = await c[0].getType('text/plain') var r = await c[0].getType('text/plain')
console.log(r) console.log(r)
return await r.text() return await r.text()
}
// Removes every row belonging to the same ledger entry (client-source-id)
// as the clicked button. Rows are plain form inputs, so dropping them from
// the DOM drops them from the next import post.
function removeImportEntryRows(el) {
var row = el.closest('tr');
if (!row) { return; }
var id = row.getAttribute('data-entry-id');
var body = row.closest('tbody') || row.parentNode;
Array.prototype.slice.call(body.querySelectorAll('tr[data-entry-id]'))
.forEach(function (r) {
if (r.getAttribute('data-entry-id') === id) { r.remove(); }
});
}")] }")]
(external-import-form* request) (external-import-form* request)
@@ -408,9 +431,6 @@
(html-response (html-response
(external-import-form* (assoc request :just-parsed? true)))) (external-import-form* (assoc request :just-parsed? true))))
(defn line->id [{:keys [source external-id client-code]}]
(str client-code "-" source "-" external-id))
(defn add-errors [entry all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations] (defn add-errors [entry all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
(let [vendor (all-vendors (:vendor-name entry)) (let [vendor (all-vendors (:vendor-name entry))
locked-until (client-locked-lookup (:client-code entry)) locked-until (client-locked-lookup (:client-code entry))

View File

@@ -13,6 +13,7 @@
[clojure.data.csv :as csv] [clojure.data.csv :as csv]
[clojure.string :as str] [clojure.string :as str]
[datomic.api :as dc] [datomic.api :as dc]
[hiccup2.core :as hiccup]
[malli.core :as mc])) [malli.core :as mc]))
(use-fixtures :each wrap-setup) (use-fixtures :each wrap-setup)
@@ -619,3 +620,93 @@
(testing "Non-admin cannot bulk-delete" (testing "Non-admin cannot bulk-delete"
(is (thrown? Exception (sut/bulk-delete {:identity (user-token) (is (thrown? Exception (sut/bulk-delete {:identity (user-token)
:form-params {:selected [1]}}))))) :form-params {:selected [1]}})))))
;; =============================================================================
;; External Import - removing an entry from the review grid
;; =============================================================================
(defn- import-row [external-id account-code debit credit]
{:external-id external-id
:client-code "REMOVE-TEST"
:source "manual"
:vendor-name "Remove Vendor"
:date (coerce/to-date-time #inst "2021-01-01")
:account-code account-code
:location "HQ"
:debit debit
:credit credit})
(defn- entry-rows
"The two rows a balanced ledger entry is typically pasted as."
[external-id debit-account amount]
[(import-row external-id debit-account amount 0.0)
(import-row external-id 2000 0.0 amount)])
(deftest external-import-remove-button-test
(testing "Every row is tagged with the entry id the importer groups on"
(let [table (vec (concat (entry-rows "ext-a" 1100 100.0)
(entry-rows "ext-b" 1100 50.0)))
html (str (hiccup/html (sut/external-import-table-form*
{:form-params {:table table}
:form-errors {}})))]
(is (= 2 (count (re-seq #"data-entry-id=\"REMOVE-TEST-manual-ext-a\"" html))))
(is (= 2 (count (re-seq #"data-entry-id=\"REMOVE-TEST-manual-ext-b\"" html))))
(testing "and offers a remove button that drops the whole entry"
(is (= 4 (count (re-seq #"removeImportEntryRows\(\$el\)" html))))
(is (= 2 (count (re-seq #"data-remove-entry-id=\"REMOVE-TEST-manual-ext-a\"" html))))
(is (= 2 (count (re-seq #"aria-label=\"Remove ledger entry REMOVE-TEST-manual-ext-a\"" html))))))))
(deftest external-import-remove-entry-then-import-test
(testing "Removing the failing entry lets the remaining entries import"
(let [_ (setup-test-data [(test-client :db/id "remove-client"
:client/code "REMOVE-TEST"
:client/locations ["HQ"])
(test-vendor :db/id "remove-vendor"
:vendor/name "Remove Vendor")
{:db/id "remove-account-1100"
:account/numeric-code 1100
:account/account-set "default"
:account/name "Cash"}
{:db/id "remove-account-2000"
:account/numeric-code 2000
:account/account-set "default"
:account/name "Accounts Payable"}])
;; Three entries, six rows. The middle one posts to an account that
;; does not exist, which is the kind of error a user has to resolve.
table (vec (concat (entry-rows "ext-good-1" 1100 100.0)
(entry-rows "ext-bad" 99999 75.0)
(entry-rows "ext-good-2" 1100 25.0)))
admin (admin-token)
external-id (fn [id] (str "REMOVE-TEST-manual-" id))
imported? (fn [db id]
(boolean (dc/q '[:find ?je .
:in $ ?ext
:where [?je :journal-entry/external-id ?ext]]
db (external-id id))))]
(testing "the bad entry blocks the whole paste"
(is (thrown? Exception (sut/import-ledger {:form-params {:table table}
:identity admin})))
(let [db (dc/db conn)]
(is (not (imported? db "ext-good-1")))
(is (not (imported? db "ext-good-2")))))
(testing "removing it drops both of its rows"
(let [remaining (vec (remove #(= (external-id "ext-bad") (sut/line->id %)) table))]
(is (= 4 (count remaining)))
(testing "and the other two entries import"
(let [result (sut/import-ledger {:form-params {:table remaining}
:identity admin})
db (dc/db conn)]
(is (= 2 (:successful result)))
(is (imported? db "ext-good-1"))
(is (imported? db "ext-good-2"))
(is (not (imported? db "ext-bad")))
(let [entry (dc/pull db
[:journal-entry/amount
{:journal-entry/line-items [:journal-entry-line/debit
:journal-entry-line/credit]}]
[:journal-entry/external-id (external-id "ext-good-1")])]
(is (= 100.0 (:journal-entry/amount entry)))
(is (= 2 (count (:journal-entry/line-items entry))))))))))))