(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] [hiccup2.core :as hiccup] [malli.core :as m] [malli.transform :as mt2] [malli.transform :as mt] [taoensso.encore :refer [filter-vals]])) (defn row* [gridspec user entity {:keys [flash? delete-after-settle? request class] :as options}] (let [cells (if (:check-boxes? gridspec) [(com/data-grid-cell {} (com/checkbox {:name "id" :value ((:id-fn gridspec) entity) :x-model "selected"}))] []) cells (->> gridspec :headers (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 {:class (cond-> (or class "") flash? (hh/add-class "live-added")) "_" (hiccup/raw (when delete-after-settle? " on htmx:afterSettle wait 400ms then remove me")) :data-id ((:id-fn gridspec) entity)} 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 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) :sort sort) (let [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) :title (if (string? (:title grid-spec)) (:title grid-spec) ((:title grid-spec) request)) :route (:route grid-spec) :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)]])])) :rows (for [entity entities] (row* grid-spec user entity {:flash? (= flash-id ((:id-fn grid-spec) entity)) :request request})) :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 (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 sort->query [s] (str/join "," (map (fn [k] (format "%s:%s" (:sort-key k) (if (= true (:asc k)) "asc" "desc"))) s))) (defn default-unparse-query-params [query-params] (reduce (fn [query-params [k value]] (assoc query-params k (cond (= k :sort) (sort->query value) (instance? org.joda.time.base.AbstractInstant value) (atime/unparse-local value atime/normal-date) (instance? Long value) (str value) (instance? Double value) (format "%.2f" value) (instance? Float value) (format "%.2f" value) (keyword? value) (name value) (and (map? value) (:db/id value)) (:db/id value) :else value))) query-params query-params)) (defn default-parse-query-params [grid-spec] (comp (query-params/apply-remove-sort) (query-params/apply-toggle-sort grid-spec) (query-params/apply-date-range :date-range :start-date :end-date) (query-params/parse-key :exact-match-id query-params/parse-long) (query-params/parse-key :sort #(query-params/parse-sort grid-spec %)) (query-params/parse-key :per-page query-params/parse-long) (query-params/parse-key :start query-params/parse-long) (query-params/parse-key :start-date query-params/parse-date) (query-params/parse-key :end-date query-params/parse-date))) (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 [parse-query-params?] :or {parse-query-params? true}}] (cond-> (fn table [{:keys [identity] :as request}] (let [unparse-query-params (or (:unparse-query grid-spec) default-unparse-query-params)] (html-response (table* grid-spec identity request) :headers {"hx-push-url" (str "?" (url/map->query (dissoc (if (:query-schema grid-spec) (do (alog/peek ::setup4 (pr-str (update (filter-vals #(not (nil? %)) (m/encode (:query-schema grid-spec) (:query-params request) main-transformer)) "sort" sort->query))) (update (filter-vals #(not (nil? %)) (m/encode (:query-schema grid-spec) (:query-params request) main-transformer)) "sort" sort->query)) (unparse-query-params (:parsed-query-params request))) "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) parse-query-params? (query-params/wrap-parse-query-params (or (:parse-query-params grid-spec) (default-parse-query-params grid-spec))) true (wrap-secure) true (wrap-client-redirect-unauthenticated))) (defn page-route [grid-spec & {:keys [parse-query-params?] :or {parse-query-params? true}}] (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)) [:div {:x-data (hx/json {: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) parse-query-params? (query-params/wrap-parse-query-params (or (:parse-query-params grid-spec) (default-parse-query-params grid-spec))) 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 [:=> [: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?]] [:fetch-page [:=> [:cat request-spec] [:cat [:vector entity-spec] :int]]] [:parse-query-params {:optional true} [:=> [:cat [:map-of :keyword :any]] [:map-of :keyword :any]]] [: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] [: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))))))