| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732 |
- (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]
- [datascript.impl.entity :as de]
- [cljs-time.core :as t]
- [cljs-time.coerce :as tc]))
- (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-content
- [entity]
- (when entity
- (if (map? entity)
- (db-property/property-value-content entity)
- (str entity))))
- (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"
- :type :string
- :header header-cp
- :cell (fn [_table row _column]
- [:div.primary-cell
- (component-block/block-container (assoc config :table? true) row)])
- :disable-hide? true}]
- (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"
- :type :date-time
- :header header-cp
- :cell timestamp-cell-cp}
- {:id :block/updated-at
- :name "Updated At"
- :type :date-time
- :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 #(or (false? (:column-list? %))
- (:disable-hide? %)) columns)]
- (shui/dropdown-menu-checkbox-item
- {:key (str (:id column))
- :className "capitalize"
- :checked (column-visible? column)
- :onCheckedChange #(column-toggle-visiblity column %)
- :onSelect (fn [e] (.preventDefault e))}
- (: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)))))))
- (rum/defc table-row < rum/reactive
- [{:keys [row-selected?] :as table} rows columns props]
- (let [idx (gobj/get props "data-index")
- row (nth rows idx)
- row (if-let [db-id (:db/id row)]
- (db/sub-block db-id)
- row)]
- (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 focus-visible:ring-0 focus-visible:ring-offset-0"})
- (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-values
- [rows property]
- (let [property-ident (:db/ident property)
- 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)
- :value e})
- values)
- (sort-by :label))))
- (defn timestamp-property?
- [ident]
- (contains? #{:block/created-at :block/updated-at} ident))
- (def timestamp-options
- [{:value "1 week ago"
- :label "1 week ago"}
- {:value "1 month ago"
- :label "1 month ago"}
- {:value "3 months ago"
- :label "3 months ago"}
- {:value "1 year ago"
- :label "1 year ago"}
- ;; TODO: support date picker
- ;; {:value "Custom time"
- ;; :label "Custom time"}
- ])
- (defn- get-timestamp
- [value]
- (let [now (t/now)
- f t/minus]
- (case value
- "1 week ago"
- (tc/to-long (f now (t/weeks 1)))
- "1 month ago"
- (tc/to-long (f now (t/months 1)))
- "3 months ago"
- (tc/to-long (f now (t/months 3)))
- "1 year ago"
- (tc/to-long (f now (t/years 1)))
- nil)))
- (rum/defc filter-property < rum/static
- [columns {:keys [data-fns] :as table}]
- (let [[property set-property!] (rum/use-state nil)
- timestamp? (timestamp-property? (:db/ident property))
- set-filters! (:set-filters! data-fns)
- filters (get-in table [:state :filters])
- columns (remove #(false? (:column-list? %)) columns)
- items (map (fn [column]
- {:label (:name column)
- :value 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 [column]
- (let [id (:id column)
- property (db/entity id)
- internal-property {:db/ident (:id column)
- :block/original-name (:name column)}]
- (if (or property (timestamp-property? id))
- (set-property! (or property internal-property))
- (do
- (shui/popup-hide!)
- (let [property internal-property
- new-filter [property :text-contains]
- filters' (if (seq filters)
- (conj filters new-filter)
- [new-filter])]
- (set-filters! filters'))))))}
- option (cond
- timestamp?
- (merge option
- {:items timestamp-options
- :input-default-placeholder (if property (:block/original-name property) "Select")
- :on-chosen (fn [value]
- (shui/popup-hide!)
- (let [filters' (conj filters [property :after value])]
- (set-filters! filters')))})
- property
- (if (= :checkbox (get-in property [:block/schema :type]))
- (let [items [{:value true :label "true"}
- {:value false :label "false"}]]
- (merge option
- {:items items
- :input-default-placeholder (if property (:block/original-name property) "Select")
- :on-chosen (fn [value]
- (let [filters' (conj filters [property :is value])]
- (set-filters! filters')))}))
- (let [items (get-property-values (:data table) property)]
- (merge option
- {:items items
- :input-default-placeholder (if property (:block/original-name property) "Select")
- :multiple-choices? true
- :on-chosen (fn [_value _selected? selected]
- (let [filters' (if (seq selected)
- (conj filters [property :is selected])
- filters)]
- (set-filters! filters')))})))
- :else
- 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")))
- (defn operator->text
- [operator]
- (case operator
- :is "is"
- :is-not "is not"
- :text-contains "text contains"
- :text-not-contains "text not contains"
- :date-before "date before"
- :date-after "date after"
- :before "before"
- :after "after"
- :number-gt ">"
- :number-lt "<"
- :number-gte ">="
- :number-lte "<="
- :between "between"))
- (defn get-property-operators
- [property]
- (if (contains? #{:block/created-at :block/updated-at} (:db/ident property))
- [:before :after]
- (concat
- [:is :is-not]
- (case (get-in property [:block/schema :type])
- (:default :url :page :object)
- [:text-contains :text-not-contains]
- :date
- [:date-before :date-after]
- :number
- [:number-gt :number-lt :number-gte :number-lte :between]
- nil))))
- (defn- get-filter-with-changed-operator
- [property operator value]
- (case operator
- (:is :is-not)
- (when (set? value) value)
- (:text-contains :text-not-contains)
- (when (string? value) value)
- (:number-gt :number-lt :number-gte :number-lte)
- (when (number? value) value)
- :between
- (when (and (vector? value) (every? number? value))
- value)
- (:date-before :date-after :before :after)
- ;; FIXME: should be a valid date number
- (when (number? value) value)))
- (rum/defc filter-operator < rum/static
- [property operator filters set-filters! idx]
- (shui/dropdown-menu
- (shui/dropdown-menu-trigger
- {:asChild true}
- (shui/button
- {:class "!px-2 rounded-none border-r"
- :variant "ghost"
- :size :sm}
- [:span.text-xs (operator->text operator)]))
- (shui/dropdown-menu-content
- {:align "start"}
- (let [operators (get-property-operators property)]
- (for [operator operators]
- (shui/dropdown-menu-item
- {:on-click (fn []
- (let [new-filters (update filters idx
- (fn [[property _old-operator value]]
- (let [value' (get-filter-with-changed-operator property operator value)]
- (if value'
- [property operator value']
- [property operator]))))]
- (set-filters! new-filters)))}
- (operator->text operator)))))))
- (rum/defc between < rum/static
- [property [start end] filters set-filters! idx]
- [:<>
- (shui/input
- {:auto-focus true
- :placeholder "from"
- :value (str start)
- :onChange (fn [e]
- (let [input-value (util/evalue e)
- number-value (when-not (string/blank? input-value)
- (util/safe-parse-float input-value))
- value [number-value end]
- value (if (every? nil? value) nil value)]
- (let [new-filters (update filters idx
- (fn [[property operator _old_value]]
- (if (nil? value)
- [property operator]
- [property operator value])))]
- (set-filters! new-filters))))
- :class "w-24 !h-6 !py-0 border-none focus-visible:ring-0 focus-visible:ring-offset-0"})
- (shui/input
- {:value (str end)
- :placeholder "to"
- :onChange (fn [e]
- (let [input-value (util/evalue e)
- number-value (when-not (string/blank? input-value)
- (util/safe-parse-float input-value))
- value [start number-value]
- value (if (every? nil? value) nil value)]
- (let [new-filters (update filters idx
- (fn [[property operator _old_value]]
- (if (nil? value)
- [property operator]
- [property operator value])))]
- (set-filters! new-filters))))
- :class "w-24 !h-6 !py-0 border-none focus-visible:ring-0 focus-visible:ring-offset-0"})])
- (rum/defc filter-value-select < rum/static
- [{:keys [data-fns] :as table} property value operator idx]
- (let [type (get-in property [:block/schema :type])
- items (cond
- (contains? #{:before :after} operator)
- timestamp-options
- (= type :checkbox)
- [{:value true :label "true"} {:value false :label "false"}]
- :else
- (get-property-values (:data table) property))
- filters (get-in table [:state :filters])
- set-filters! (:set-filters! data-fns)
- many? (if (or (contains? #{:date-before :date-after :before :after} operator)
- (contains? #{:checkbox} type))
- false
- true)
- option (cond->
- {:input-default-placeholder (:block/original-name property)
- :input-opts {:class "!px-3 !py-1"}
- :items items
- :extract-fn :label
- :extract-chosen-fn :value
- :on-chosen (fn [value _selected? selected]
- (when-not many?
- (shui/popup-hide!))
- (let [value' (if many? selected value)
- new-filters (update filters idx
- (fn [[property operator _value]]
- [property operator value']))]
- (set-filters! new-filters)))}
- many?
- (assoc
- :multiple-choices? true
- :selected-choices value))]
- (shui/dropdown-menu
- (shui/dropdown-menu-trigger
- {:asChild true}
- (shui/button
- {:class "!px-2 rounded-none border-r"
- :variant "ghost"
- :size :sm}
- [:div.flex.flex-row.items-center.gap-1.text-xs
- (cond
- (de/entity? value)
- [:div (get-property-value-content value)]
- (string? value)
- [:div value]
- (boolean? value)
- [:div (str value)]
- (seq value)
- (->> (map (fn [v] [:div (get-property-value-content v)]) value)
- (interpose [:div "or"]))
- :else
- "Empty")]))
- (shui/dropdown-menu-content
- {:align "start"}
- (select/select option)))))
- (rum/defc filter-value < rum/static
- [table property operator value filters set-filters! idx]
- (let [number-operator? (string/starts-with? (name operator) "number-")]
- (case operator
- :between
- (between property value filters set-filters! idx)
- (:text-contains :text-not-contains :number-gt :number-lt :number-gte :number-lte)
- (shui/input
- {:auto-focus true
- :value (or value "")
- :onChange (fn [e]
- (let [value (util/evalue e)
- number-value (and number-operator? (when-not (string/blank? value)
- (util/safe-parse-float value)))]
- (let [new-filters (update filters idx
- (fn [[property operator _value]]
- (if (and number-operator? (nil? number-value))
- [property operator]
- [property operator (or number-value value)])))]
- (set-filters! new-filters))))
- :class "w-24 !h-6 !py-0 border-none focus-visible:ring-0 focus-visible:ring-offset-0"})
- (filter-value-select table property value operator idx))))
- (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]
- [: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)])
- (filter-operator property operator filters set-filters! idx)
- (filter-value table property operator value filters set-filters! idx)
- (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- fuzzy-matched?
- [input s]
- (pos? (fuzzy-search/score (string/lower-case (str input))
- (string/lower-case (str s)))))
- (defn- row-matched?
- [row input filters]
- (and
- ;; full-text-search match
- (if (string/blank? input)
- true
- (when row
- (fuzzy-matched? input (:object/name row))))
- ;; filters check
- (every?
- (fn [[property operator match]]
- (let [property-ident (:db/ident property)
- value (get row property-ident)
- value' (cond
- (set? value) value
- (nil? value) #{}
- :else #{value})
- result
- (case operator
- :is
- (if (boolean? match)
- (= (boolean (get-property-value-content (get row property-ident))) match)
- (when (coll? value)
- (boolean (seq (set/intersection value' match)))))
- :is-not
- (if (boolean? match)
- (not= (boolean (get-property-value-content (get row property-ident))) match)
- (when (coll? value)
- (boolean (empty? (set/intersection value' match)))))
- :text-contains
- (some #(fuzzy-matched? match (get-property-value-content %)) value')
- :text-not-contains
- (not-any? #(string/includes? (str (get-property-value-content %)) match) value')
- :number-gt
- (if match (some #(> (get-property-value-content %) match) value') true)
- :number-gte
- (if match (some #(>= (get-property-value-content %) match) value') true)
- :number-lt
- (if match (some #(< (get-property-value-content %) match) value') true)
- :number-lte
- (if match (some #(<= (get-property-value-content %) match) value') true)
- :between
- (if (seq match)
- (some (fn [value-entity]
- (let [[start end] match
- value (get-property-value-content value-entity)
- conditions [(if start (<= start value) true)
- (if end (<= value end) true)]]
- (if (seq match) (every? true? conditions) true))) value')
- true)
- :date-before
- (if match (some #(< (:block/journal-day %) (:block/journal-day match)) value') true)
- :date-after
- (if match (some #(> (:block/journal-day %) (:block/journal-day match)) value') true)
- :before
- (let [search-value (get-timestamp match)]
- (if search-value (<= (get row property-ident) search-value) true))
- :after
- (let [search-value (get-timestamp match)]
- (if search-value (>= (get row property-ident) search-value) true))
- true)]
- result))
- 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))
|