From 26ba2554d244441782c79e90e75c4caaedf133f1 Mon Sep 17 00:00:00 2001 From: Kimo Knowles Date: Sun, 21 Jan 2024 20:01:58 +0100 Subject: [PATCH] Improve simple-v-table sorting For https://github.com/day8/apps-lib/issues/130 --- CHANGELOG.md | 6 + run/resources/public/assets/css/re-com.css | 10 +- src/re_com/simple_v_table.cljs | 151 ++++++++++++--------- src/re_com/util.cljs | 5 + src/re_com/v_table.cljs | 28 ++-- src/re_demo/simple_v_table.cljs | 8 +- 6 files changed, 132 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2e8e5e..f77faa9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ > Committed but unreleased changes are put here, at the top. Older releases are detailed chronologically below. +## 2.17.0 (2024-01-23) + +#### Changed + +- `simple-v-table` - Shift-click multiple columns to sort hierarchically. + ## 2.16.4 (2024-01-19) #### Fixed diff --git a/run/resources/public/assets/css/re-com.css b/run/resources/public/assets/css/re-com.css index 121a07e6..ea9c2334 100644 --- a/run/resources/public/assets/css/re-com.css +++ b/run/resources/public/assets/css/re-com.css @@ -1428,6 +1428,10 @@ code { simple-v-table ----------------------------------------------------------------------------------------*/ -.rc-simple-v-table-wrapper .rc-v-table-column-headers:hover svg path { - fill: #ddd; -} \ No newline at end of file +.rc-v-table-column-headers .rc-simple-v-table-column-header-sort-label { + opacity: 0; +} +dts +.rc-v-table-column-headers:hover .rc-simple-v-table-column-header-sort-label { + opacity: 0.25; +} diff --git a/src/re_com/simple_v_table.cljs b/src/re_com/simple_v_table.cljs index 20efdcc5..1e2a791e 100644 --- a/src/re_com/simple_v_table.cljs +++ b/src/re_com/simple_v_table.cljs @@ -6,26 +6,32 @@ [reagent.core :as reagent] [re-com.config :refer [include-args-desc?]] [re-com.box :refer [box h-box gap]] - [re-com.util :refer [px deref-or-value assoc-in-if-empty]] + [re-com.util :refer [px deref-or-value assoc-in-if-empty ->v position-for-id item-for-id remove-id-item]] + [re-com.text :refer [label]] [re-com.validate :refer [vector-of-maps? vector-atom? parts?]] [re-com.v-table :as v-table])) -(defn swap!-sort-by-column - [{:keys [key-fn order]} new-key-fn new-comp] - (if (= key-fn new-key-fn) - (if (= :asc order) - {:key-fn key-fn - :comp new-comp - :order :desc} - nil) - {:key-fn new-key-fn - :comp new-comp - :order :asc})) +(def default-sort-criterion {:keyfn :label :order :asc}) + +(defn update-sort-criteria + [criteria new-criterion] + (let [{:keys [id order] :as new-criterion} (merge default-sort-criterion new-criterion) + this? (comp #{id} :id) + this-criterion (item-for-id id criteria) + operation (cond + (nil? this-criterion) :add + (= order (:order this-criterion)) :flip + :else :drop) + flip #(update % :order {:asc :desc :desc :asc})] + (case operation + :flip (mapv #(cond-> % (this? %) flip) criteria) + :drop (remove-id-item id criteria) + :add (vec (conj criteria (merge default-sort-criterion new-criterion)))))) (defn sort-icon [{:keys [size fill] :or {size "16px" - fill "transparent"}}] + fill "black"}}] [:svg {:width size :height size :viewBox "0 0 24 24"} @@ -35,7 +41,7 @@ (defn arrow-down-icon [{:keys [size fill] :or {size "24px" - fill "transparent"}}] + fill "black"}}] [:svg {:width size :height size :viewBox "0 0 24 24"} @@ -45,7 +51,7 @@ (defn arrow-up-icon [{:keys [size fill] :or {size "24px" - fill "transparent"}}] + fill "black"}}] [:svg {:width size :height size :viewBox "0 0 24 24"} @@ -58,41 +64,57 @@ :center :center}) (defn column-header-item - [{:keys [id row-label-fn width height align header-label sort-by]} parts sort-by-column] - (let [{:keys [key-fn comp] :or {key-fn row-label-fn comp compare}} sort-by - {current-key-fn :key-fn order :order} @sort-by-column] - (let [on-click #(swap! sort-by-column swap!-sort-by-column key-fn comp) - justify (get align->justify (keyword align) :start)] - [h-box - :class (str "rc-simple-v-table-column-header-item " (get-in parts [:simple-column-header-item :class])) - :width (px width) - :justify justify - :align :center - :style (merge - {:padding "0px 12px" - :min-height "24px" - :height (px height) - :font-weight "bold" - :white-space "nowrap" - :overflow "hidden" - :text-overflow "ellipsis"} - (when sort-by - {:cursor "pointer"}) - (get-in parts [:simple-column-header-item :style])) - :attr (merge - {} - (when sort-by - {:on-click on-click}) - (get-in parts [:simple-column-header-item :attr])) - :children [header-label - (when sort-by - [:<> - [gap :size "16px"] - (if (not= current-key-fn key-fn) - [sort-icon] - (if (= order :desc) - [arrow-down-icon] - [arrow-up-icon]))])]]))) + [& _] + (let [hover? (reagent/atom false)] + (fn [{:keys [id row-label-fn width height align header-label sort-by]} parts sort-by-column] + (let [sort-by (cond (true? sort-by) {} :else sort-by) + default-sort-by {:key-fn row-label-fn :comp compare :id id :order :asc} + ps (position-for-id id @sort-by-column) + {current-order :order} (item-for-id id @sort-by-column) + add-criteria! #(swap! sort-by-column update-sort-criteria (merge default-sort-by sort-by)) + replace-criteria! #(reset! sort-by-column [(merge default-sort-by sort-by)]) + on-click #(if (or (.-shiftKey %) (empty? (remove (clojure.core/comp #{id} :id) @sort-by-column))) + (add-criteria!) + (replace-criteria!)) + justify (get align->justify (keyword align) :start) + multiple-columns-sorted? (> (count @sort-by-column) 1)] + [h-box + :class (str "rc-simple-v-table-column-header-item " (get-in parts [:simple-column-header-item :class])) + :width (px width) + :justify justify + :align :center + :style (merge + {:padding "0px 12px" + :min-height "24px" + :height (px height) + :font-weight "bold" + :white-space "nowrap" + :overflow "hidden" + :text-overflow "ellipsis"} + (when sort-by + {:cursor "pointer"}) + (get-in parts [:simple-column-header-item :style])) + :attr (merge + {:on-mouse-enter #(reset! hover? true) + :on-mouse-leave #(reset! hover? false)} + (when sort-by + {:on-click on-click}) + (get-in parts [:simple-column-header-item :attr])) + :children [header-label + (when sort-by + [h-box + :class (str "rc-simple-v-table-column-header-sort-label " (when current-order "rc-simple-v-table-column-header-sort-active")) + :min-width "35px" + :style (when current-order {:opacity 0.3}) + :justify :center + :align :center + :children + [(case current-order + :asc [arrow-up-icon] + :desc [arrow-down-icon] + [sort-icon]) + (when ps + [label :style {:visibility (when-not multiple-columns-sorted? "hidden")} :label (inc ps)])]])]])))) (defn column-header-renderer ":column-header-renderer AND :top-left-renderer - Render the table header" @@ -182,7 +204,7 @@ (def simple-v-table-args-desc (when include-args-desc? - [{:name :model :required true :type "r/atom containing vec of maps" :validate-fn vector-atom? :description "one element for each row in the table."} + [{:name :model :required true :type "r/atom containing vec of maps" #_#_:validate-fn vector-atom? :description "one element for each row in the table."} {:name :columns :required true :type "vector of maps" :validate-fn vector-of-maps? :description [:span "one element for each column in the table. Must contain " [:code ":id"] "," [:code ":header-label"] "," [:code ":row-label-fn"] "," [:code ":width"] ", and " [:code ":height"] ". Optionally contains " [:code ":sort-by"] ", " [:code ":align"] " and " [:code ":vertical-align"] ". " [:code ":sort-by"] " can be " [:code "true"] " or a map optionally containing " [:code ":key-fn"] " and " [:code ":comp"] " ala " [:code "cljs.core/sort-by"] "."]} {:name :fixed-column-count :required false :default 0 :type "integer" :validate-fn number? :description "the number of fixed (non-scrolling) columns on the left."} {:name :fixed-column-border-color :required false :default "#BBBEC0" :type "string" :validate-fn string? :description [:span "The CSS color of the horizontal border between the fixed columns on the left, and the other columns on the right. " [:code ":fixed-column-count"] " must be > 0 to be visible."]} @@ -204,6 +226,18 @@ {:name :src :required false :type "map" :validate-fn map? :description [:span "Used in dev builds to assist with debugging. Source code coordinates map containing keys" [:code ":file"] "and" [:code ":line"] ". See 'Debugging'."]} {:name :debug-as :required false :type "map" :validate-fn map? :description [:span "Used in dev builds to assist with debugging, when one component is used implement another component, and we want the implementation component to masquerade as the original component in debug output, such as component stacks. A map optionally containing keys" [:code ":component"] "and" [:code ":args"] "."]}])) +(defn criteria-compare [a b {:keys [key-fn comp-fn order] + :or {key-fn :label order :asc comp-fn compare}}] + (cond-> (comp-fn (key-fn a) (key-fn b)) + (= :desc order) -)) + +(defn multi-comparator [criteria] + (fn [a b] + (or (->> criteria + (map (partial criteria-compare a b)) + (remove zero?) + (first)) 0))) + (defn simple-v-table "Render a v-table and introduce the concept of columns (provide a spec for each). Of the nine possible sections of v-table, this table only supports four: @@ -244,16 +278,7 @@ content-width v-table/scrollbar-tot-thick (* 2 table-padding) - 2) ;; 2 border widths - internal-model (reagent/track - (fn [] - (if-let [{:keys [key-fn comp order] :or {comp compare}} @sort-by-column] - (do - (let [sorted (sort-by key-fn comp (deref-or-value model))] - (if (= order :desc) - (vec (reverse sorted)) - (vec sorted)))) - (deref-or-value model))))] + 2)] ;; 2 border widths [box :src src :debug-as (or debug-as (reflect-current-component)) @@ -273,8 +298,8 @@ :attr (get-in parts [:simple-wrapper :attr]) :child [v-table/v-table :src (at) - :model internal-model - + :model model + :sort-comp (multi-comparator (->v @sort-by-column)) ;; ===== Column header (section 4) :column-header-renderer (partial column-header-renderer content-cols parts sort-by-column) :column-header-height column-header-height diff --git a/src/re_com/util.cljs b/src/re_com/util.cljs index b410cfe6..026eff56 100644 --- a/src/re_com/util.cljs +++ b/src/re_com/util.cljs @@ -113,6 +113,11 @@ [vect index item] (apply merge (subvec vect 0 index) item (subvec vect index))) +(defn ->v [x] (cond (vector? x) x + (sequential? x) (vec x) + (nil? x) nil + :else [x])) + ;; ---------------------------------------------------------------------------- ;; Utilities for vectors of maps containing :id ;; ---------------------------------------------------------------------------- diff --git a/src/re_com/v_table.cljs b/src/re_com/v_table.cljs index ab4fcc09..a4cf3617 100644 --- a/src/re_com/v_table.cljs +++ b/src/re_com/v_table.cljs @@ -495,6 +495,8 @@ (when include-args-desc? [{:name :model :required true :type "r/atom containing vec of maps" :validate-fn vector-atom? :description [:span "One element for each row displayed in the table. Typically, a vector of maps, but can be a seq of anything, with your functions like " [:code ":key-fn"] " extracting values."]} {:name :key-fn :required false :default "nil" :type "map -> anything" :validate-fn ifn-or-nil? :description [:span "A function/keyword or nil. Given an element of " [:code ":model"] ", it should return its unique identifier which is used by Reagent as a unique id. If not specified or nil passed, the element's 0-based row-index will be used"]} + {:name :sort-comp :required false :default "nil" :type "map, map -> number" :validate-fn ifn-or-nil? :description [:span "Sorts " [:code ":model"] " using " [:code ":sort-comp"] "as a comparatison function. Can be combined with " [:code ":sort-keyfn"] "."]} + {:name :sort-keyfn :required false :default "nil" :type "map -> anything" :validate-fn ifn-or-nil? :description [:span "Sorts " [:code ":model"] " using " [:code ":sort-keyfn"] "as a sort-key function. Can be combined with " [:code ":sort-comp"] "."]} {:name :virtual? :required false :default true :type "boolean" :description [:span "when true, only those rows that are visible are rendered to the DOM. Otherwise DOM will be generated for all rows, which might be prohibitive if there are a large number of rows."]} {:name :row-height :required true :type "integer" :validate-fn number? :description "px height of each row, in sections 2, 5 and 8."} @@ -828,11 +830,15 @@ top-row-index (reaction (int (/ @scroll-y row-height))) ;; The row number (zero-based) of the row currently rendered at the top of the table bot-row-index (reaction (min (+ @top-row-index (dec @rows-per-viewport)) @m-size)) ;; The row number of the row currently rendered at the bottom of the table virtual-scroll-y (reaction (mod @scroll-y row-height)) ;; Virtual version of scroll-y but this is a very small number (between 0 and the row-height) + row-sort-fn (reagent/atom nil) + sorted-model (reaction (if-let [row-sort-fn @row-sort-fn] + (vec (row-sort-fn @model)) + @model)) virtual-rows (reaction (when (pos? @m-size) - (subvec @model + (subvec @sorted-model (min @top-row-index @m-size) (min (+ @top-row-index @rows-per-viewport 2) @m-size)))) - + rows (reaction (if virtual? @virtual-rows @sorted-model)) on-h-scroll-change #(reset! scroll-x %) ;; The on-change handler for the horizontal scrollbar on-v-scroll-change #(reset! scroll-y %) ;; The on-change handler for the vertical scrollbar @@ -1036,7 +1042,7 @@ :reagent-render (fn v-table-render - [& {:keys [virtual? remove-empty-row-space? key-fn max-width + [& {:keys [virtual? remove-empty-row-space? key-fn sort-fn sort-comp sort-keyfn max-width ;; Section 1 top-left-renderer ;; Section 2 @@ -1060,15 +1066,19 @@ class parts src debug-as] :or {virtual? true remove-empty-row-space? true - key-fn nil} + key-fn nil + sort-fn nil} :as args}] (or (validate-args-macro v-table-args-desc args) (do (reset! content-rows-width row-content-width) (reset! content-rows-height (* @m-size row-height)) - - ;; Scroll rows into view handling + (cond + (and sort-keyfn sort-comp) (reset! row-sort-fn (partial sort-by sort-keyfn sort-comp)) + sort-keyfn (reset! row-sort-fn (partial sort-by sort-keyfn)) + sort-comp (reset! row-sort-fn (partial sort sort-comp))) + ;; Scroll rows into view handling (when (not= (deref-or-value scroll-rows-into-view) @internal-scroll-rows-into-view) ;; TODO: Ideally allow non-atom nil but exception if it's not an atom when there's a value (let [{:keys [start-row end-row]} (deref-or-value scroll-rows-into-view) @@ -1159,7 +1169,7 @@ row-header-renderer key-fn @top-row-index - (if virtual? @virtual-rows @model) ;; rows + @rows ;; rows (if virtual? @virtual-scroll-y @scroll-y) ;; scroll-y ;----------------- row-header-selection-fn @@ -1233,7 +1243,7 @@ row-renderer key-fn @top-row-index - (if virtual? @virtual-rows @model) ;; rows + @rows @scroll-x (if virtual? @virtual-scroll-y @scroll-y) ;; scroll-y ;----------------- @@ -1312,7 +1322,7 @@ row-footer-renderer key-fn @top-row-index - (if virtual? @virtual-rows @model) ;; rows + @rows (if virtual? @virtual-scroll-y @scroll-y) ;; scroll-y ;----------------- row-viewport-height diff --git a/src/re_demo/simple_v_table.cljs b/src/re_demo/simple_v_table.cljs index 8c2f9162..db6d25f3 100644 --- a/src/re_demo/simple_v_table.cljs +++ b/src/re_demo/simple_v_table.cljs @@ -27,7 +27,8 @@ [:li "Primary use case involves showing a rectangular visual structure, with entities in rows and attributes of those entities in columns. Typically, read-only."] [:li "Unlimited columns with a fixed column header at the top"] [:li "Unlimited (virtualised) rows with an (optional) fixed row header at the left by simply specifying the number of columns to fix"] - [:li "Click on a column header to sort the rows when enabled via a " [:code ":sort-by"] " key in the column map."] + [:li "Click on a column header to sort the rows in a column"] + [:li "Shift-click on a column header to sort multiple columns hierarchically."] [:li "Most aspects of the table are stylable using the " [:code ":parts"] " argument that can set " [:code ":class"] " or " [:code ":style"] " attributes"] [:li "Individual rows can be dynamically styled based on row data"] [:li "Individual cells can be dynamically styled based on row data"] @@ -57,6 +58,11 @@ [:li "If the height provided by the table's parent container is less than this extent, then vertical scrollbars will appear"] [:li "Where you wish to be explicit about the table's viewable height, use the " [:code ":max-rows"] " arg"]]] [:li "Even if you are explicit via " [:code ":max-width"] " or " [:code ":max-rows"] ", the parent's dimensions will always dominate, if they are set"]] + [title3 "Sorting"] + [:ul + [:li "Items in " [:code ":columns"] " have an optional " [:code ":sort-by"] " key."] + [:li "If the value is " [:code "true"] ", clicking the column header will sort all the rows, using the result of the column's " [:code ":row-label-fn"] " as a sort key."] + [:li [:code ":sort-by"] " can also be map, with optional keys " [:code ":comp"] " and " [:code ":keyfn"] ", corresponding to the parameters of " [:code "clojure.core/sort-by"] "."]] [p "The \"Sales Table Demo\" (to the right) allows you to experiment with these concepts."]]]) (defn dependencies