Bulk coding left the checked items selected after the table refreshed. Add a dedicated reset-selection event that the grid's Alpine state listens for, and fire it alongside refreshTable on bulk-code submit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
378 lines
22 KiB
Clojure
378 lines
22 KiB
Clojure
(ns auto-ap.ssr.grid-page-helper
|
|
(:require
|
|
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
|
[auto-ap.logging :as alog]
|
|
[auto-ap.query-params :as query-params]
|
|
[auto-ap.routes.utils
|
|
:refer [wrap-client-redirect-unauthenticated wrap-secure]]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[auto-ap.ssr.components :as com]
|
|
[auto-ap.ssr.hiccup-helper :as hh]
|
|
[auto-ap.ssr.hx :as hx]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.ui :refer [base-page]]
|
|
[auto-ap.ssr.utils :refer [html-response main-transformer]]
|
|
[auto-ap.time :as atime]
|
|
[bidi.bidi :as bidi]
|
|
[cemerick.url :as url]
|
|
[clojure.string :as str]
|
|
[hiccup.util :as hu]
|
|
[malli.core :as m]
|
|
[malli.transform :as mt2]
|
|
[taoensso.encore :refer [filter-vals]]
|
|
[clojure.java.io :as io]
|
|
[clojure.data.csv :as csv]))
|
|
|
|
(defn row* [{:keys [check-box-warning? check-boxes?] :as gridspec} user entity {:keys [flash? delete-after-settle? request class] :as options}]
|
|
(let [cells (if check-boxes?
|
|
[(com/data-grid-cell {:class "relative"}
|
|
(let [cb (com/checkbox {:name "id" :value ((:id-fn gridspec) entity)
|
|
:x-model "selected"})]
|
|
(if (and check-box-warning? (check-box-warning? entity))
|
|
(do
|
|
[:div.bg-yellow-100.absolute.inset-0.flex.items-center.px-4.py-2
|
|
[:div {:class "absolute inset-0 bg-yellow-50 z-0",
|
|
|
|
:style "background-image: linear-gradient(135deg, rgba(0, 0, 0, 0.1) 12.5%, transparent 12.5%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 62.5%, transparent 62.5%, transparent);\n background-size: 10px 10px;"}]
|
|
|
|
[:div {:class "z-10"} cb]])
|
|
cb)))]
|
|
[])
|
|
cells (->> gridspec
|
|
:headers
|
|
(filter (fn [h]
|
|
((:render-for h #{:html :csv}) :html)))
|
|
(filter (fn [h]
|
|
(if (and (:hide? h)
|
|
((:hide? h) request))
|
|
nil
|
|
h)))
|
|
(mapv (fn [header]
|
|
(com/data-grid-cell {:class (if-let [show-starting (:show-starting header)]
|
|
(format "hidden %s:table-cell" show-starting)
|
|
(:class header))}
|
|
((:render header) entity))))
|
|
(into cells))
|
|
cells (conj cells (com/data-grid-right-stack-cell {}
|
|
(into [:form.flex.space-x-2
|
|
[:input {:type :hidden :name "id" :value ((:id-fn gridspec) entity)}]]
|
|
((:row-buttons gridspec) request entity))))] ;; TODO double check usage of row buttons user and identity in callers
|
|
(apply com/data-grid-row
|
|
(cond-> {:class (cond-> (or class "")
|
|
flash? (hh/add-class "live-added group"))
|
|
:data-id ((:id-fn gridspec) entity)}
|
|
delete-after-settle?
|
|
(assoc "@htmx:after-settle.camel" "setTimeout(() => $el.remove(), 400)"))
|
|
cells)))
|
|
|
|
(defn sort-icon [sort key]
|
|
(->> sort
|
|
(filter (comp #(= key %) :sort-key))
|
|
first
|
|
:sort-icon))
|
|
|
|
(defn sort-by-list [grid-spec sort]
|
|
(if (seq sort)
|
|
(into
|
|
[:div.flex.gap-2.items-center
|
|
|
|
"sorted by"]
|
|
(for [{:keys [name sort-icon sort-key]} sort]
|
|
[:div.py-1.px-3.text-sm.rounded.bg-gray-100.dark:bg-gray-600.flex.items-center.gap-2.relative name [:div.h-4.w-4.mr-3 sort-icon]
|
|
[:div {:class "absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white hover:scale-110 transition-all duration-300 bg-gray-400 border-2 border-white rounded-full -top-2 -right-2 dark:border-gray-900"}
|
|
[:a {:hx-get (str (bidi/path-for ssr-routes/only-routes
|
|
(:route grid-spec)) "?remove-sort=" sort-key)
|
|
:href "#"
|
|
:hx-target (str "#" (:id grid-spec))}
|
|
[:div.h-4.w-4 svg/x]]]]))
|
|
"default sort"))
|
|
|
|
(defn create-break-table-fn [break-table grid-spec]
|
|
(let [last (atom nil)]
|
|
(fn [request entity]
|
|
(let [break-table-value (break-table request entity)]
|
|
(when (not= break-table-value @last)
|
|
(reset! last break-table-value)
|
|
(com/data-grid-row {}
|
|
(com/data-grid-cell {:colspan (cond-> (inc (count (:headers grid-spec)))
|
|
(:check-boxes? grid-spec) inc)}
|
|
[:span.font-bold.text-gray-600.text-lg break-table-value])))))))
|
|
|
|
(defn sort->query [s]
|
|
(str/join "," (map (fn [k] (format "%s:%s" (:sort-key k) (if (= true (:asc k))
|
|
"asc"
|
|
"desc")))
|
|
s)))
|
|
|
|
(defn table* [grid-spec user {{:keys [start per-page flash-id sort]} :parsed-query-params :as request}]
|
|
(alog/info ::TABLE-QP
|
|
:qp (:query-params request)
|
|
:pqp (:parsed-query-params request))
|
|
(let [sort (or (and (not (string? (:sort (:parsed-query-params request)))) (:sort (:parsed-query-params request)))
|
|
(:sort (:query-params request)))
|
|
start (or start 0)
|
|
per-page (or per-page 25)
|
|
[entities total :as page-results] ((:fetch-page grid-spec)
|
|
request)
|
|
request (assoc request :page-results page-results)]
|
|
|
|
(com/data-grid-card {:id (:id grid-spec)
|
|
:raw? (:raw? grid-spec)
|
|
:title [:div.flex.gap-2 (if (string? (:title grid-spec))
|
|
(:title grid-spec)
|
|
((:title grid-spec) request))]
|
|
:route (:route grid-spec)
|
|
:root-params {:x-data (hx/json {:sort (sort->query sort)})
|
|
"x-hx-val:sort" "sort"}
|
|
:start start
|
|
:per-page per-page
|
|
:total total
|
|
:subtitle [:div.flex.items-center.gap-2
|
|
[:span (format "Total %s: %d, " (:entity-name grid-spec) total)]
|
|
(sort-by-list grid-spec sort)]
|
|
:action-buttons (cond->> ((:action-buttons grid-spec) request)
|
|
(:check-boxes? grid-spec) (into [(com/pill {:color :primary
|
|
:x-show "selected.length > 0"}
|
|
[:div.flex.space-x-2.items-center
|
|
[:div
|
|
|
|
[:span {:x-text "selected.length" :x-show "!all_selected"}]
|
|
[:span {:x-show "all_selected"} "All"]
|
|
" selected"]
|
|
[:div.w-3.h-3
|
|
(com/link {"@click" "selected=[]; all_selected=false"}
|
|
svg/x)]])])
|
|
(:csv-route grid-spec) (cons (com/a-button {:href (hu/url (bidi/path-for ssr-routes/only-routes (:csv-route grid-spec))
|
|
(dissoc (update (filter-vals #(not (nil? %))
|
|
(m/encode (:query-schema grid-spec)
|
|
(:query-params request)
|
|
main-transformer))
|
|
"sort" sort->query)
|
|
"selected" "all-selected"))
|
|
:color :secondary-light}
|
|
[:div.w-4.h-4 svg/download])))
|
|
:rows
|
|
(let [break-table-fn (some-> grid-spec :break-table (create-break-table-fn grid-spec))]
|
|
(for [entity entities
|
|
row (if-let [break-table-row (when break-table-fn (break-table-fn request entity))]
|
|
|
|
[break-table-row (row* grid-spec user entity {:flash? (= flash-id ((:id-fn grid-spec) entity)) :request request})]
|
|
[(row* grid-spec user entity {:flash? (= flash-id ((:id-fn grid-spec) entity)) :request request})])]
|
|
row))
|
|
:thead-params {:hx-get (bidi/path-for ssr-routes/only-routes
|
|
(:route grid-spec))
|
|
:hx-target (str "#" (:id grid-spec))
|
|
:hx-indicator (str "#" (:id grid-spec))
|
|
:hx-trigger "sorted once"
|
|
:hx-vals "js:{\"toggle-sort\": event.detail.key || \"\"}"}
|
|
:headers
|
|
(conj
|
|
(->> grid-spec
|
|
:headers
|
|
(filter (fn [h]
|
|
((:render-for h #{:html :csv}) :html)))
|
|
(map
|
|
(fn [h]
|
|
(cond
|
|
(and (:hide? h)
|
|
((:hide? h) request))
|
|
nil
|
|
|
|
(:sort-key h)
|
|
(com/data-grid-sort-header {:class (if-let [show-starting (:show-starting h)]
|
|
(format "hidden %s:table-cell" show-starting)
|
|
(:class h))
|
|
:sort-key (:sort-key h)}
|
|
|
|
[:div.flex.gap-4.items-center
|
|
(:name h)
|
|
[:div.h-6.w-6.text-gray-400.dark:text-gray-500 (sort-icon sort (:sort-key h))]])
|
|
|
|
:else
|
|
(com/data-grid-header {:class (if-let [show-starting (:show-starting h)]
|
|
(format "hidden %s:table-cell" show-starting)
|
|
(:class h))
|
|
:sort-key (:sort-key h)}
|
|
(:name h)))))
|
|
(filter identity)
|
|
(into (if (:check-boxes? grid-spec)
|
|
[(com/data-grid-checkbox-header {:name "all" :value "all" :x-model "all_selected"})]
|
|
[])))
|
|
(com/data-grid-header {}))})))
|
|
|
|
(defn wrap-trim-client-ids [handler]
|
|
(fn trim-client-ids [request]
|
|
(let [valid-clients (extract-client-ids (:clients request)
|
|
(:client request)
|
|
(:client-id (:parsed-query-params request))
|
|
(when (:client-code (:parsed-query-params request))
|
|
[:client/code (:client-code (:parsed-query-params request))]))
|
|
valid-clients (->> valid-clients
|
|
(take 20)
|
|
set)]
|
|
(handler (assoc request :trimmed-clients valid-clients)))))
|
|
|
|
(defn table-route [grid-spec & {:keys [push-url?] :or {push-url? true}}]
|
|
(cond-> (fn table [{:keys [identity] :as request}]
|
|
|
|
(html-response (table*
|
|
grid-spec
|
|
identity
|
|
request)
|
|
:headers (when push-url?
|
|
{"hx-push-url" (str "?" (url/map->query
|
|
(dissoc (if (:query-schema grid-spec)
|
|
(update (filter-vals #(not (nil? %))
|
|
(m/encode (:query-schema grid-spec)
|
|
(:query-params request)
|
|
main-transformer))
|
|
"sort" sort->query)
|
|
{})
|
|
"selected" "all-selected")))}) ;; TODO seems hacky to special case selected and all-selected here
|
|
:oob (when-let [oob-render (:oob-render grid-spec)]
|
|
(oob-render request))))
|
|
true (wrap-trim-client-ids)
|
|
true (wrap-secure)
|
|
true (wrap-client-redirect-unauthenticated)))
|
|
|
|
;; TODO oob-render is wonky, requires thinking about accidental side effects. Really only used in a small handful of cases. probably best to just
|
|
;; use alpine for those cases, or make it so that date filters issue a change event when they render. for example, when you click on "month",
|
|
;; make it so that it rerenders the date range component, along with a hx-trigger change header
|
|
(defn csv-route [{:keys [fetch-page headers page->csv-entities]} & {:keys []}]
|
|
(cond-> (fn csv-route [{:keys [identity] :as request}]
|
|
|
|
(let [page-results (fetch-page (assoc-in request [:query-params :per-page] Long/MAX_VALUE))
|
|
csv-entities ((or page->csv-entities (fn [[entities]] entities)) page-results)
|
|
csv-content (with-open [i (java.io.StringWriter.)]
|
|
(csv/write-csv i
|
|
(into [(for [h headers
|
|
:when ((:render-for h #{:html :csv}) :csv)]
|
|
(:name h))]
|
|
(for [e csv-entities]
|
|
(for [h headers
|
|
:when ((:render-for h #{:html :csv})
|
|
:csv)]
|
|
((or (:render-csv h) (comp str (:render h))) e)))))
|
|
(.toString i))]
|
|
|
|
{:headers {"Content-Type" "text/csv"}
|
|
:body csv-content}))
|
|
true (wrap-trim-client-ids)
|
|
true (wrap-secure)
|
|
true (wrap-client-redirect-unauthenticated)))
|
|
|
|
(defn page-route [grid-spec & {:keys []}]
|
|
(cond-> (fn page [{:keys [identity] :as request}]
|
|
(alog/info ::page-route
|
|
:pqp (:parsed-query-params request)
|
|
:qp (:query-params request))
|
|
(base-page
|
|
request
|
|
(com/page {:nav (:nav grid-spec)
|
|
:page-specific (when-let [page-specific-nav (:page-specific-nav grid-spec)]
|
|
[:div#page-specific-nav (page-specific-nav request)])
|
|
:client-selection (:client-selection request)
|
|
:clients (:clients request)
|
|
:client (:client request)
|
|
:identity (:identity request)
|
|
:request request}
|
|
(apply com/breadcrumbs {} (:breadcrumbs grid-spec))
|
|
(when (:above-grid grid-spec)
|
|
((:above-grid grid-spec) request))
|
|
[:div {:x-data (hx/json {:selected [] :all_selected false :type (:entity-name grid-spec)})
|
|
"x-on:copy" "if (selected.length > 0) {$clipboard(JSON.stringify({'type': type, 'selected': selected}))}"
|
|
"x-on:client-selected.document" "selected=[]; all_selected=false"
|
|
"x-on:reset-selection.document" "selected=[]; all_selected=false"
|
|
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
|
:x-init "$watch('selected', s=> $dispatch('selectedChanged', {selected: s, all_selected: all_selected}) );
|
|
$watch('all_selected', a=>$dispatch('selectedChanged', {selected: selected, all_selected: a}))"}
|
|
|
|
(table* grid-spec
|
|
identity
|
|
request)])
|
|
(if (string? (:title grid-spec))
|
|
(:title grid-spec)
|
|
((:title grid-spec) request))))
|
|
true (wrap-trim-client-ids)
|
|
true (wrap-secure)
|
|
true (wrap-client-redirect-unauthenticated)))
|
|
|
|
(def request-spec (m/schema [:map]))
|
|
(def entity-spec (m/schema [:map]))
|
|
(def header-spec (m/schema [:map
|
|
[:key :string]
|
|
[:name :string]
|
|
[:header-class {:optional true} [:maybe :string]]
|
|
[:sort-key {:optional true} :string]
|
|
[:render {:optional true} [:=> [:cat entity-spec] :any]]
|
|
[:hide? {:optional true} [:=> [:cat entity-spec] :boolean]]]))
|
|
(def grid-spec (m/schema [:map
|
|
[:id :string]
|
|
[:nav [:=>
|
|
[:cat request-spec]
|
|
vector?]]
|
|
[:page-specific-nav
|
|
{:optional true
|
|
:default (fn [request])}
|
|
[:maybe [:=>
|
|
[:cat request-spec]
|
|
vector?]]]
|
|
[:id-fn {:default :db/id
|
|
:optional true}
|
|
[:=>
|
|
[:cat map?]
|
|
nat-int?]]
|
|
[:query-schema :any]
|
|
[:fetch-page [:=>
|
|
[:cat request-spec]
|
|
[:cat [:vector entity-spec] :int]]]
|
|
[:above-grid
|
|
{:optional true
|
|
:default (fn [request])}
|
|
[:=>
|
|
[:cat request-spec]
|
|
vector?]]
|
|
[:oob-render
|
|
{:optional true
|
|
:default (fn [request])}
|
|
[:=>
|
|
[:cat request-spec]
|
|
vector?]]
|
|
[:breadcrumbs [:vector vector?]]
|
|
[:title [:or :string
|
|
[:=> [:cat [:map-of :keyword :any]]
|
|
:string]]]
|
|
[:entity-name :string]
|
|
[:route :keyword]
|
|
[:csv-route {:optional true} [:maybe :keyword]]
|
|
[:action-buttons
|
|
{:default (fn [request])
|
|
:optional true}
|
|
[:=>
|
|
[:cat request-spec]
|
|
[:maybe [:vector vector?]]]]
|
|
[:row-buttons
|
|
{:default (fn [request entity])
|
|
:optional true}
|
|
[:=>
|
|
[:cat request-spec entity-spec]
|
|
[:maybe [:vector vector?]]]]
|
|
[:headers [:vector header-spec]]]))
|
|
|
|
(defn build [grid-page]
|
|
(when-not (m/validate grid-spec grid-page)
|
|
(throw (ex-info "Could not validate grid"
|
|
(m/explain grid-spec grid-page))))
|
|
(m/decode grid-spec grid-page (mt2/default-value-transformer {::mt2/add-optional-keys true})))
|
|
|
|
(defn wrap-apply-sort [handler grid-spec]
|
|
(fn apply-sort [request]
|
|
(handler (update request :query-params
|
|
(fn [qp]
|
|
((comp
|
|
(query-params/apply-remove-sort)
|
|
(query-params/apply-toggle-sort grid-spec)
|
|
(query-params/parse-key :sort #(query-params/parse-sort grid-spec %)))
|
|
qp))))))
|
|
|