|
|
@@ -0,0 +1,411 @@
|
|
|
+(ns frontend.components.objects
|
|
|
+ "Tagged objects"
|
|
|
+ (:require [logseq.shui.ui :as shui]
|
|
|
+ [rum.core :as rum]
|
|
|
+ [frontend.util :as util]
|
|
|
+ [frontend.ui :as ui]
|
|
|
+ [clojure.string :as string]
|
|
|
+ [frontend.components.block :as component-block]
|
|
|
+ [frontend.components.property.value :as pv]
|
|
|
+ [frontend.components.select :as select]
|
|
|
+ [frontend.state :as state]
|
|
|
+ [frontend.date :as date]
|
|
|
+ [goog.object :as gobj]
|
|
|
+ [goog.dom :as gdom]
|
|
|
+ [cljs-bean.core :as bean]
|
|
|
+ [promesa.core :as p]
|
|
|
+ [logseq.db :as ldb]
|
|
|
+ [frontend.db :as db]
|
|
|
+ [frontend.search.fuzzy :as fuzzy-search]
|
|
|
+ [logseq.outliner.property :as outliner-property]
|
|
|
+ [frontend.mixins :as mixins]
|
|
|
+ [logseq.db.frontend.property :as db-property]
|
|
|
+ [logseq.db.frontend.property.type :as db-property-type]
|
|
|
+ [clojure.set :as set]))
|
|
|
+
|
|
|
+(defn header-checkbox [{:keys [selected-all? selected-some? toggle-selected-all!]}]
|
|
|
+ (shui/checkbox
|
|
|
+ {:checked (or selected-all? (and selected-some? "indeterminate"))
|
|
|
+ :on-checked-change toggle-selected-all!
|
|
|
+ :aria-label "Select all"}))
|
|
|
+
|
|
|
+(defn row-checkbox [{:keys [row-selected? row-toggle-selected!]} row _column]
|
|
|
+ (shui/checkbox
|
|
|
+ {:checked (row-selected? row)
|
|
|
+ :on-checked-change (fn [v] (row-toggle-selected! row v))
|
|
|
+ :aria-label "Select row"}))
|
|
|
+
|
|
|
+(defn- header-cp
|
|
|
+ [{:keys [column-toggle-sorting! state]} column]
|
|
|
+ (let [sorting (:sorting state)
|
|
|
+ [asc?] (some (fn [item] (when (= (:id item) (:id column))
|
|
|
+ (when-some [asc? (:asc? item)]
|
|
|
+ [asc?]))) sorting)]
|
|
|
+ (shui/button
|
|
|
+ {:variant "text"
|
|
|
+ :class "!pl-0 !py-0 hover:text-foreground"
|
|
|
+ :onClick #(column-toggle-sorting! column)}
|
|
|
+ (:name column)
|
|
|
+ (case asc?
|
|
|
+ true
|
|
|
+ (ui/icon "arrow-up")
|
|
|
+ false
|
|
|
+ (ui/icon "arrow-down")
|
|
|
+ nil))))
|
|
|
+
|
|
|
+(defn- timestamp-cell-cp
|
|
|
+ [_table row column]
|
|
|
+ (some-> (get row (:id column))
|
|
|
+ date/int->local-time-2))
|
|
|
+
|
|
|
+(defn- get-property-value-for-search
|
|
|
+ [block property]
|
|
|
+ (let [type (get-in property [:block/schema :type])
|
|
|
+ many? (= :db.cardinality/many (get property :db/cardinality))
|
|
|
+ ref-types (into db-property-type/value-ref-property-types #{:entity})
|
|
|
+ number-type? (= :number type)
|
|
|
+ v (get block (:db/ident property))
|
|
|
+ v' (if many? v [v])
|
|
|
+ col (->> (if (ref-types type) (map db-property/property-value-content v') v')
|
|
|
+ (remove nil?))]
|
|
|
+ (if number-type?
|
|
|
+ (reduce + (filter number? col))
|
|
|
+ (string/join ", " col))))
|
|
|
+
|
|
|
+(defn- build-columns
|
|
|
+ [class config]
|
|
|
+ (let [properties (outliner-property/get-class-properties class)]
|
|
|
+ (concat
|
|
|
+ [{:id :select
|
|
|
+ :name "Select"
|
|
|
+ :header (fn [table _column] (header-checkbox table))
|
|
|
+ :cell (fn [table row column] (row-checkbox table row column))
|
|
|
+ :column-list? false}
|
|
|
+ {:id :object/name
|
|
|
+ :name "Name"
|
|
|
+ :header header-cp
|
|
|
+ :cell (fn [_table row _column]
|
|
|
+ [:div.primary-cell
|
|
|
+ (component-block/block-container (assoc config :table? true) row)])}]
|
|
|
+ (map
|
|
|
+ (fn [property]
|
|
|
+ {:id (:db/ident property)
|
|
|
+ :name (:block/original-name property)
|
|
|
+ :header header-cp
|
|
|
+ :cell (fn [_table row _column]
|
|
|
+ (pv/property-value row property (get row (:db/ident property)) {}))
|
|
|
+ :get-value (fn [row] (get-property-value-for-search row property))})
|
|
|
+ properties)
|
|
|
+
|
|
|
+ [{:id :block/created-at
|
|
|
+ :name "Created At"
|
|
|
+ :header header-cp
|
|
|
+ :cell timestamp-cell-cp}
|
|
|
+ {:id :block/updated-at
|
|
|
+ :name "Updated At"
|
|
|
+ :header header-cp
|
|
|
+ :cell timestamp-cell-cp}])))
|
|
|
+
|
|
|
+(defn- get-all-objects
|
|
|
+ [class]
|
|
|
+ ;; FIXME: async
|
|
|
+ (:block/_tags class))
|
|
|
+
|
|
|
+(rum/defc more-actions
|
|
|
+ [columns {:keys [column-visible? column-toggle-visiblity]}]
|
|
|
+ (shui/dropdown-menu
|
|
|
+ (shui/dropdown-menu-trigger
|
|
|
+ {:asChild true}
|
|
|
+ (shui/button
|
|
|
+ {:variant "ghost"
|
|
|
+ :class "text-muted-foreground !px-1"
|
|
|
+ :size :sm
|
|
|
+ :on-click #()}
|
|
|
+ (ui/icon "dots")))
|
|
|
+ (shui/dropdown-menu-content
|
|
|
+ {:align "end"}
|
|
|
+ (shui/dropdown-menu-group
|
|
|
+ (shui/dropdown-menu-sub
|
|
|
+ (shui/dropdown-menu-sub-trigger
|
|
|
+ "Properties")
|
|
|
+ (shui/dropdown-menu-sub-content
|
|
|
+ (for [column (remove #(false? (:column-list? %)) columns)]
|
|
|
+ (shui/dropdown-menu-checkbox-item
|
|
|
+ {:key (str (:id column))
|
|
|
+ :className "capitalize"
|
|
|
+ :checked (column-visible? column)
|
|
|
+ :onCheckedChange #(column-toggle-visiblity column %)}
|
|
|
+ (:name column)))))))))
|
|
|
+
|
|
|
+(defn table-header
|
|
|
+ [table columns]
|
|
|
+ (shui/table-row
|
|
|
+ {:class "bg-gray-01 shadow"}
|
|
|
+ (for [column columns]
|
|
|
+ (let [style (case (:id column)
|
|
|
+ :block/original-name
|
|
|
+ {}
|
|
|
+ :select
|
|
|
+ {:width 32}
|
|
|
+ {:width 180})]
|
|
|
+ (shui/table-head
|
|
|
+ {:key (str (:id column))
|
|
|
+ :style style}
|
|
|
+ (let [header-fn (:header column)]
|
|
|
+ (if (fn? header-fn)
|
|
|
+ (header-fn table column)
|
|
|
+ header-fn)))))))
|
|
|
+
|
|
|
+(defn table-row
|
|
|
+ [{:keys [row-selected?] :as table} rows columns props]
|
|
|
+ (let [idx (gobj/get props "data-index")
|
|
|
+ row (nth rows idx)]
|
|
|
+ (shui/table-row
|
|
|
+ (merge
|
|
|
+ (bean/->clj props)
|
|
|
+ {:key (str (:id row))
|
|
|
+ :data-state (when (row-selected? row) "selected")})
|
|
|
+ (for [column columns]
|
|
|
+ (let [id (str (:id row) "-" (:id column))
|
|
|
+ render (get column :cell)]
|
|
|
+ (shui/table-cell
|
|
|
+ {:key id}
|
|
|
+ (render table row column)))))))
|
|
|
+
|
|
|
+(rum/defc search
|
|
|
+ [input {:keys [on-change]}]
|
|
|
+ (let [[show-input? set-show-input!] (rum/use-state false)]
|
|
|
+ (if show-input?
|
|
|
+ (shui/input
|
|
|
+ {:placeholder "Type to search"
|
|
|
+ :auto-focus true
|
|
|
+ :value input
|
|
|
+ :onChange (fn [e]
|
|
|
+ (let [value (util/evalue e)]
|
|
|
+ (on-change value)))
|
|
|
+ :on-key-down (fn [e]
|
|
|
+ (when (= "Escape" (util/ekey e))
|
|
|
+ (set-show-input! false)))
|
|
|
+ :class "max-w-sm !h-7 !py-0 border-none"})
|
|
|
+ (shui/button
|
|
|
+ {:variant "ghost"
|
|
|
+ ;; FIXME: remove ring when focused
|
|
|
+ :class "text-muted-foreground !px-1"
|
|
|
+ :size :sm
|
|
|
+ :on-click #(set-show-input! true)}
|
|
|
+ (ui/icon "search")))))
|
|
|
+
|
|
|
+(defn- property-ref-type?
|
|
|
+ [property]
|
|
|
+ (let [schema (:block/schema property)
|
|
|
+ type (:type schema)]
|
|
|
+ (db-property-type/ref-property-types type)))
|
|
|
+
|
|
|
+(defn- get-property-value-content
|
|
|
+ [e property]
|
|
|
+ (if (property-ref-type? property)
|
|
|
+ (db-property/property-value-content e)
|
|
|
+ (str e)))
|
|
|
+
|
|
|
+(rum/defc filter-property < rum/static
|
|
|
+ [columns {:keys [rows data-fns] :as table}]
|
|
|
+ (let [[property-ident set-property-ident!] (rum/use-state nil)
|
|
|
+ set-filters! (:set-filters! data-fns)
|
|
|
+ property (when property-ident (db/entity property-ident))
|
|
|
+ filters (get-in table [:state :filters])
|
|
|
+ columns (remove #(false? (:column-list? %)) columns)
|
|
|
+ items (map (fn [column]
|
|
|
+ {:label (:name column)
|
|
|
+ :value (:id column)}) columns)
|
|
|
+ option {:input-default-placeholder "Filter"
|
|
|
+ :input-opts {:class "!px-3 !py-1"}
|
|
|
+ :items items
|
|
|
+ :extract-fn :label
|
|
|
+ :extract-chosen-fn :value
|
|
|
+ :on-chosen (fn [value]
|
|
|
+ (set-property-ident! value))}
|
|
|
+ option (if property
|
|
|
+ (let [items (let [values (->> (mapcat (fn [e] (let [v (get e property-ident)]
|
|
|
+ (if (set? v) v #{v}))) rows)
|
|
|
+ (remove nil?)
|
|
|
+ (distinct))]
|
|
|
+ (->>
|
|
|
+ (map (fn [e]
|
|
|
+ {:label (get-property-value-content e property)
|
|
|
+ :value e})
|
|
|
+ values)
|
|
|
+ (sort-by :label)))]
|
|
|
+ (merge option
|
|
|
+ {:items items
|
|
|
+ :input-default-placeholder "Select"
|
|
|
+ :multiple-choices? true
|
|
|
+ :on-chosen (fn [_value _selected? selected]
|
|
|
+ (when (seq selected)
|
|
|
+ (let [filters' (conj filters [property :is selected])]
|
|
|
+ (set-filters! filters'))))}))
|
|
|
+ option)]
|
|
|
+ (select/select option)))
|
|
|
+
|
|
|
+(rum/defc filter-properties < rum/static
|
|
|
+ [columns table]
|
|
|
+ (shui/button
|
|
|
+ {:variant "ghost"
|
|
|
+ :class "text-muted-foreground !px-1"
|
|
|
+ :size :sm
|
|
|
+ :on-click (fn [e]
|
|
|
+ (shui/popup-show! (.-target e)
|
|
|
+ (fn []
|
|
|
+ (filter-property columns table))
|
|
|
+ {:align :end
|
|
|
+ :as-dropdown? true
|
|
|
+ :auto-focus? true}))}
|
|
|
+ (ui/icon "filter")))
|
|
|
+
|
|
|
+(rum/defc filters-row < rum/static
|
|
|
+ [{:keys [data-fns] :as table}]
|
|
|
+ (let [filters (get-in table [:state :filters])
|
|
|
+ {:keys [set-filters!]} data-fns]
|
|
|
+ (when (seq filters)
|
|
|
+ [:div.filters-row.flex.flex-row.items-center.gap-4.flex-wrap.pb-2
|
|
|
+ (map-indexed
|
|
|
+ (fn [idx filter]
|
|
|
+ (let [[property operator value] filter]
|
|
|
+ (when (seq value)
|
|
|
+ (let [is? (= :is operator)]
|
|
|
+ [:div.flex.flex-row.items-center.border.rounded
|
|
|
+ (shui/button
|
|
|
+ {:class "!px-2 rounded-none border-r"
|
|
|
+ :variant "ghost"
|
|
|
+ :size :sm
|
|
|
+ :disabled true}
|
|
|
+ [:span.text-xs (:block/original-name property)])
|
|
|
+ (shui/button
|
|
|
+ {:class "!px-2 rounded-none border-r"
|
|
|
+ :variant "ghost"
|
|
|
+ :size :sm
|
|
|
+ :on-click (fn [_e]
|
|
|
+ (let [new-filters (update filters idx
|
|
|
+ (fn [[property operator value]]
|
|
|
+ ;; TODO: support more operators like `contains`, `between` for different types
|
|
|
+ ;; and switch to use dropdown instead of toggle
|
|
|
+ (let [operator' (if (= operator :is) :not :is)]
|
|
|
+ [property operator' value])))]
|
|
|
+ (set-filters! new-filters)))}
|
|
|
+ [:span.text-xs (if is? "is" "is not")])
|
|
|
+ (shui/button
|
|
|
+ {:class "!px-2 rounded-none border-r"
|
|
|
+ :variant "ghost"
|
|
|
+ :size :sm}
|
|
|
+ [:div.flex.flex-row.items-center.gap-1.text-xs
|
|
|
+ (->> (map (fn [v] [:div (get-property-value-content v property)]) value)
|
|
|
+ (interpose [:div "or"]))])
|
|
|
+ (shui/button
|
|
|
+ {:class "!px-1 rounded-none"
|
|
|
+ :variant "ghost"
|
|
|
+ :size :sm
|
|
|
+ :on-click (fn [e]
|
|
|
+ (let [new-filters (vec (remove #{filter} filters))]
|
|
|
+ (set-filters! new-filters)))}
|
|
|
+ (ui/icon "x"))]))))
|
|
|
+ filters)])))
|
|
|
+
|
|
|
+(defn- row-matched?
|
|
|
+ [row input filters]
|
|
|
+ (and
|
|
|
+ ;; full-text-search match
|
|
|
+ (if (string/blank? input)
|
|
|
+ true
|
|
|
+ (when row
|
|
|
+ (pos? (fuzzy-search/score (string/lower-case input) (:object/name row)))))
|
|
|
+ ;; filters check
|
|
|
+ (every?
|
|
|
+ (fn [[property operator match]]
|
|
|
+ (let [value (get row (:db/ident property))
|
|
|
+ value' (cond
|
|
|
+ (set? value) value
|
|
|
+ (nil? value) #{}
|
|
|
+ :else #{value})]
|
|
|
+ (case operator
|
|
|
+ :is
|
|
|
+ (boolean (seq (set/intersection value' match)))
|
|
|
+ :not
|
|
|
+ (boolean (empty? (set/intersection value' match)))
|
|
|
+ true)))
|
|
|
+ filters)))
|
|
|
+
|
|
|
+(rum/defc objects-inner < rum/static
|
|
|
+ [config class]
|
|
|
+ (let [[input set-input!] (rum/use-state "")
|
|
|
+ ;; TODO: block.temp/tagged-at
|
|
|
+ [sorting set-sorting!] (rum/use-state [{:id :block/updated-at, :asc? false}])
|
|
|
+ [filters set-filters!] (rum/use-state [])
|
|
|
+ [row-filter set-row-filter!] (rum/use-state nil)
|
|
|
+ [visible-columns set-visible-columns!] (rum/use-state {})
|
|
|
+ [row-selection set-row-selection!] (rum/use-state {})
|
|
|
+ [data set-data!] (rum/use-state (get-all-objects class))
|
|
|
+ _ (rum/use-effect!
|
|
|
+ (fn []
|
|
|
+ ;; (when-let [^js worker @state/*db-worker]
|
|
|
+ ;; (p/let [result-str (.get-page-refs-count worker (state/get-current-repo))
|
|
|
+ ;; result (ldb/read-transit-str result-str)
|
|
|
+ ;; data (map (fn [row] (assoc row :block.temp/refs-count (get result (:db/id row) 0))) data)]
|
|
|
+ ;; (set-data! data)))
|
|
|
+ )
|
|
|
+ [])
|
|
|
+ columns (build-columns class config)
|
|
|
+ table (shui/table-option {:data data
|
|
|
+ :columns columns
|
|
|
+ :state {:sorting sorting
|
|
|
+ :filters filters
|
|
|
+ :row-filter row-filter
|
|
|
+ :row-selection row-selection
|
|
|
+ :visible-columns visible-columns}
|
|
|
+ :data-fns {:set-filters! set-filters!
|
|
|
+ :set-sorting! set-sorting!
|
|
|
+ :set-visible-columns! set-visible-columns!
|
|
|
+ :set-row-selection! set-row-selection!}})
|
|
|
+ selected-rows (shui/table-get-selection-rows row-selection (:rows table))
|
|
|
+ selected-rows-count (count selected-rows)
|
|
|
+ selected? (pos? selected-rows-count)]
|
|
|
+ (rum/use-effect!
|
|
|
+ (fn []
|
|
|
+ (set-row-filter! (fn []
|
|
|
+ (fn [row]
|
|
|
+ (row-matched? row input filters)))))
|
|
|
+ [input filters])
|
|
|
+ [:div.w-full.flex.flex-col.gap-2
|
|
|
+ [:div.flex.items-center.justify-between
|
|
|
+ [:div.flex.flex-row.items-center.gap-2
|
|
|
+ [:div.font-medium (str (count data) " Objects")]]
|
|
|
+ [:div.flex.items-center.gap-1
|
|
|
+
|
|
|
+ (filter-properties columns table)
|
|
|
+
|
|
|
+ (search input {:on-change set-input!})
|
|
|
+
|
|
|
+ (more-actions columns table)]]
|
|
|
+
|
|
|
+ (filters-row table)
|
|
|
+
|
|
|
+ (let [columns' (:columns table)
|
|
|
+ rows (:rows table)]
|
|
|
+ [:div.rounded-md.border.content
|
|
|
+ (ui/virtualized-table
|
|
|
+ {:custom-scroll-parent (gdom/getElement "main-content-container")
|
|
|
+ :total-count (count rows)
|
|
|
+ :fixedHeaderContent (fn [] (table-header table columns'))
|
|
|
+ :components {:Table (fn [props]
|
|
|
+ (shui/table {}
|
|
|
+ (.-children props)))
|
|
|
+ :TableRow (fn [props] (table-row table rows columns' props))}})])
|
|
|
+
|
|
|
+ (let [rows-count (count (:rows table))]
|
|
|
+ [:div.flex.items-center.justify-end.space-x-2.py-4
|
|
|
+ [:div.flex-1.text-sm.text-muted-foreground
|
|
|
+ (if (pos? selected-rows-count)
|
|
|
+ (str selected-rows-count " of " rows-count " row(s) selected.")
|
|
|
+ (str "Total: " rows-count))]])]))
|
|
|
+
|
|
|
+(rum/defcs objects < mixins/container-id
|
|
|
+ [state class]
|
|
|
+ (objects-inner {:container-id (:container-id state)} class))
|