(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))))))