(ns frontend.components.views "Different views of blocks" (:require [cljs-bean.core :as bean] [cljs-time.coerce :as tc] [cljs-time.core :as t] [cljs-time.format :as tf] [clojure.set :as set] [clojure.string :as string] [datascript.impl.entity :as de] [dommy.core :as dom] [frontend.common.missionary :as c.m] [frontend.components.dnd :as dnd] [frontend.components.icon :as icon-component] [frontend.components.property.config :as property-config] [frontend.components.property.value :as pv] [frontend.components.select :as select] [frontend.components.selection :as selection] [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.date :as date] [frontend.db :as db] [frontend.db-mixins :as db-mixins] [frontend.db.async :as db-async] [frontend.handler.db-based.export :as db-export-handler] [frontend.handler.db-based.property :as db-property-handler] [frontend.handler.editor :as editor-handler] [frontend.handler.property :as property-handler] [frontend.handler.property.util :as pu] [frontend.handler.route :as route-handler] [frontend.handler.ui :as ui-handler] [frontend.mixins :as mixins] [frontend.modules.outliner.op :as outliner-op] [frontend.modules.outliner.ui :as ui-outliner-tx] [frontend.state :as state] [frontend.ui :as ui] [frontend.util :as util] [goog.dom :as gdom] [goog.object :as gobj] [logseq.common.config :as common-config] [logseq.db :as ldb] [logseq.db.common.view :as db-view] [logseq.db.frontend.property :as db-property] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] [medley.core :as medley] [missionary.core :as m] [promesa.core :as p] [rum.core :as rum])) (rum/defc header-checkbox < rum/static [{:keys [selected-all? selected-some? toggle-selected-all!] :as table}] (let [[show? set-show!] (rum/use-state false)] [:label.h-8.w-8.flex.items-center.justify-center.cursor-pointer {:html-for "header-checkbox" :on-mouse-over #(set-show! true) :on-mouse-out #(set-show! false)} (shui/checkbox {:id "header-checkbox" :checked (or selected-all? (and selected-some? "indeterminate")) :on-checked-change (fn [value] (p/do (when value (db-async/ (shui/dropdown-menu-item {:key "asc" :on-click #(column-set-sorting! sorting column true)} [:div.flex.flex-row.items-center.gap-1 (ui/icon "arrow-up" {:size 15}) [:div "Sort ascending"]]) (shui/dropdown-menu-item {:key "desc" :on-click #(column-set-sorting! sorting column false)} [:div.flex.flex-row.items-center.gap-1 (ui/icon "arrow-down" {:size 15}) [:div "Sort descending"]]) (when property (shui/dropdown-menu-item {:on-click #(shui/popup-show! (.-target %) (fn [] [:div.ls-property-dropdown-editor.-m-1 (property-config/dropdown-editor property nil {})]) {:align "start"})} [:div.flex.flex-row.items-center.gap-1 (ui/icon "adjustments" {:size 15}) "Configure"])) (when (and db-based? property) (shui/dropdown-menu-item {:on-click (fn [_e] (if pinned? (db-property-handler/delete-property-value! (:db/id view-entity) :logseq.property.table/pinned-columns (:db/id property)) (property-handler/set-block-property! (state/get-current-repo) (:db/id view-entity) :logseq.property.table/pinned-columns (:db/id property))) (shui/popup-hide! id))} [:div.flex.flex-row.items-center.gap-1 (ui/icon "pin" {:size 15}) [:div (if pinned? "Unpin" "Pin")]]))])] (shui/button {:variant "text" :class "h-8 !pl-4 !px-2 !py-0 hover:text-foreground w-full justify-start" :on-mouse-up (fn [^js e] (when-let [^js el (some-> (.-target e) (.closest "[aria-roledescription=sortable]"))] (when (and (or (nil? @*last-header-action-target) (not= el @*last-header-action-target)) (string/blank? (some-> el (.-style) (.-transform)))) (shui/popup-show! el sub-content {:align "start" :as-dropdown? true :on-before-hide (fn [] (reset! *last-header-action-target el) (js/setTimeout #(reset! *last-header-action-target nil) 128))}))))} (let [title (str (:name column))] [:span {:title title :class "max-w-full overflow-hidden text-ellipsis"} title]) (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] (db-view/get-property-value-content (db/get-db) entity)) (rum/defc block-container [config row] (let [container (state/get-component :block/container) config' (cond-> config (not (:popup? config)) (assoc :view? true))] [:div.relative.w-full {:style {:min-height 24}} (if row (container config' row) [:div])])) (rum/defc block-title "Used on table view" [block {:keys [create-new-block property-ident width sidebar?]}] (let [inline-title (state/get-component :block/inline-title)] [:div.table-block-title.flex.items-center.w-full.h-full.cursor-pointer.items-center {:on-click (fn [e] (p/let [block (or block (and (fn? create-new-block) (create-new-block)))] (when block (cond (and (= property-ident :block/title) sidebar?) (route-handler/redirect-to-page! (:block/uuid block)) (= property-ident :block/title) (let [selection-type (some-> (js/window.getSelection) (gobj/get "type"))] (when-not (= selection-type "Range") (state/sidebar-add-block! (state/get-current-repo) (:db/id block) :block))) :else (p/do! (shui/popup-show! (.-target e) (fn [] [:div {:style {:min-width (max 160 width)}} (block-container {:popup? true} block)]) {:align :start}) (editor-handler/edit-block! block :max {:container-id :unknown-container}))))))} (if block [:div (inline-title (some->> (:block/title block) string/trim string/split-lines first))] [:div])])) (defn build-columns [config properties & {:keys [with-object-name? with-id? add-tags-column?] :or {with-object-name? true with-id? true add-tags-column? true}}] (let [;; FIXME: Shouldn't file graphs have :block/tags? add-tags-column?' (and (config/db-based-graph? (state/get-current-repo)) add-tags-column?) properties' (->> (if (or (some #(= (:db/ident %) :block/tags) properties) (not add-tags-column?')) properties (conj properties (db/entity :block/tags))) (remove nil?))] (->> (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 :resizable? false} (when with-id? {:id :id :name "ID" :header (fn [_table _column] (header-index)) :cell (fn [table row _column] (inc (.indexOf (:rows table) (:db/id row)))) :resizable? false}) (when with-object-name? {:id :block/title :name "Name" :type :string :header header-cp :cell (fn [_table row _column] (block-title row {:property-ident :block/title :sidebar? (:sidebar? config)})) :disable-hide? true})] (keep (fn [property] (when-let [ident (or (:db/ident property) (:id property))] ;; Hide properties that shouldn't ever be editable or that do not display well in a table (when-not (or (contains? #{:logseq.property/built-in? :logseq.property.asset/checksum :logseq.property.class/properties :block/created-at :block/updated-at :block/order :block/collapsed? :logseq.property/created-from-property} ident) (and with-object-name? (= :block/title ident)) (contains? #{:map :entity} (:logseq.property/type property))) (let [property (if (de/entity? property) property (or (merge (db/entity ident) property) property)) ; otherwise, :cell/:header/etc. will be removed get-value (when (de/entity? property) (fn [row] (db-view/get-property-value-for-search row property)))] {:id ident :name (or (:name property) (:block/title property)) :header (or (:header property) header-cp) :cell (or (:cell property) (when (de/entity? property) (fn [_table row _column style] (pv/property-value row property {:view? true :table-view? true :table-text-property-render (fn [block opts] (block-title block (assoc opts :width (:width style) :sidebar? (:sidebar? config))))})))) :get-value get-value :type (:type property)})))) properties') [{:id :block/created-at :name (t :page/created-at) :type :datetime :header header-cp :cell timestamp-cell-cp} {:id :block/updated-at :name (t :page/updated-at) :type :datetime :header header-cp :cell timestamp-cell-cp}]) (remove nil?)))) (defn- sort-columns [columns ordered-column-ids] (if (seq ordered-column-ids) (let [id->columns (zipmap (map :id columns) columns) ordered-id-set (set ordered-column-ids)] (concat (keep (fn [id] (get id->columns id)) ordered-column-ids) (remove (fn [column] (ordered-id-set (:id column))) columns))) columns)) (rum/defc more-actions [view-entity columns {:keys [column-visible? rows column-toggle-visibility]}] (let [display-type (:db/ident (:logseq.property.view/type view-entity)) table? (= display-type :logseq.property.view/type.table) group-by-columns (concat (filter (fn [column] (when (:id column) (when-let [p (db/entity (:id column))] (and (not (db-property/many? p)) (contains? #{:default :number :checkbox :url :node :date} (:logseq.property/type p)))))) columns) (when (contains? #{:linked-references :unlinked-references} (:logseq.property.view/feature-type view-entity)) [{:id :block/page :name "Block Page"}]))] (shui/dropdown-menu (shui/dropdown-menu-trigger {:asChild true} (shui/button {:variant "ghost" :class "text-muted-foreground !px-1" :size :sm} (ui/icon "dots" {:size 15}))) (shui/dropdown-menu-content {:align "end"} (shui/dropdown-menu-group (when table? (shui/dropdown-menu-sub (shui/dropdown-menu-sub-trigger "Columns visibility") (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-visibility column %) :onSelect (fn [e] (.preventDefault e))} (:name column)))))) (when (seq group-by-columns) (shui/dropdown-menu-sub (shui/dropdown-menu-sub-trigger "Group by") (shui/dropdown-menu-sub-content (for [column group-by-columns] (shui/dropdown-menu-checkbox-item {:key (str (:id column)) :className "capitalize" :checked (= (:id column) (:db/ident (:logseq.property.view/group-by-property view-entity))) :onCheckedChange (fn [result] (if result (db-property-handler/set-block-property! (:db/id view-entity) :logseq.property.view/group-by-property (:db/id (db/entity (:id column)))) (db-property-handler/remove-block-property! (:db/id view-entity) :logseq.property.view/group-by-property))) :onSelect (fn [e] (.preventDefault e))} (:name column)))))) (shui/dropdown-menu-item {:key "export-edn" :on-click #(db-export-handler/export-view-nodes-data rows)} "Export EDN")))))) (defn- get-column-size [column sized-columns] (let [id (:id column) size (get sized-columns id)] (cond (= id :id) 48 (number? size) size (= id :logseq.property/query) 400 :else (case id :select 32 :add-property 160 (:block/title :block/name) 360 (:block/created-at :block/updated-at) 160 180)))) (rum/defc add-property-button < rum/static [] [:div.ls-table-header-cell.!border-0 (shui/button {:variant "text" :class "h-8 !pl-4 !px-2 !py-0 hover:text-foreground w-full justify-start"} (ui/icon "plus") "New property")]) (rum/defc action-bar < rum/static [table selected-rows {:keys [on-delete-rows]}] (shui/table-actions {} [:div (str (count selected-rows) " selected")] (selection/action-bar {:on-cut #(on-delete-rows table selected-rows) :selected-blocks selected-rows :hide-dots? true :button-border? true}))) (rum/defc column-resizer [_column on-sized!] (let [*el (rum/use-ref nil) [dx set-dx!] (rum/use-state nil) [width set-width!] (rum/use-state nil) add-resizing-class #(dom/add-class! js/document.documentElement "is-resizing-buf") remove-resizing-class #(dom/remove-class! js/document.documentElement "is-resizing-buf")] (hooks/use-effect! (fn [] (when (number? dx) (some-> (rum/deref *el) (dom/set-style! :transform (str "translate3D(" dx "px , 0, 0)"))))) [dx]) (hooks/use-effect! (fn [] (when-let [el (and (fn? js/window.interact) (rum/deref *el))] (let [*field-rect (atom nil) min-width 40 max-width 500] (-> (js/interact el) (.draggable (bean/->js {:listeners {:start (fn [] (let [{:keys [width right] :as rect} (bean/->clj (.toJSON (.getBoundingClientRect (.closest el ".ls-table-header-cell")))) left-dx (if (>= width min-width) (- min-width width) 0) right-dx (if (<= width max-width) (- max-width width) 0)] (reset! *field-rect rect) (swap! *field-rect assoc ;; calculate left/right boundary :left-dx left-dx :right-dx right-dx :left-b (inc (+ left-dx right)) :right-b (inc (+ right-dx right))) (dom/add-class! el "is-active"))) :move (fn [^js e] (let [dx (.-dx e) pointer-x (js/Math.floor (.-clientX e)) {:keys [left-b right-b]} @*field-rect left-b (js/Math.floor left-b) right-b (js/Math.floor right-b)] (when (and (> pointer-x left-b) (< pointer-x right-b)) (set-dx! (fn [dx'] (if (contains? #{min-width max-width} (abs dx')) dx' (let [to-dx (+ (or dx' 0) dx) {:keys [left-dx right-dx]} @*field-rect] (cond ;; left (neg? to-dx) (if (> (abs left-dx) (abs to-dx)) to-dx left-dx) ;; right (pos? to-dx) (if (> right-dx to-dx) to-dx right-dx))))))))) :end (fn [] (set-dx! (fn [dx] (let [w (js/Math.round (+ dx (:width @*field-rect)))] (set-width! (cond (< w min-width) min-width (> w max-width) max-width :else w))) (reset! *field-rect nil) (dom/remove-class! el "is-active") 0)))}})) (.styleCursor false) (.on "dragstart" add-resizing-class) (.on "dragend" remove-resizing-class) (.on "mousedown" util/stop-propagation))))) []) (hooks/use-effect! (fn [] (when (number? width) (on-sized! width))) [width]) [:a.ls-table-resize-handle {:data-no-dnd true :ref *el}])) (defn- table-header-cell [table column] (let [header-fn (:header column) sized-columns (get-in table [:state :sized-columns]) set-sized-columns! (get-in table [:data-fns :set-sized-columns!]) width (get-column-size column sized-columns) select? (= :select (:id column))] [:div.ls-table-header-cell {:style {:width width :min-width width} :class (when select? "!border-0")} (if (fn? header-fn) (header-fn table column) header-fn) ;; resize handle (when-not (false? (:resizable? column)) (column-resizer column (fn [size] (set-sized-columns! (assoc sized-columns (:id column) size)))))])) (defn- on-delete-rows [view-parent view-feature-type table selected-ids] (let [selected-rows (->> (map db/entity selected-ids) (remove :logseq.property/built-in?)) pages (filter ldb/page? selected-rows) blocks (remove ldb/page? selected-rows) page-ids (map :db/id pages) {:keys [set-data! set-row-selection!]} (:data-fns table) update-table-state! (fn [] (let [data (:full-data table) selected-ids (set (map :db/id selected-rows)) new-data (if (every? number? data) (remove selected-ids data) ;; group (map (fn [[by-value col]] [by-value (remove selected-ids col)]) data))] (set-data! new-data) (set-row-selection! {})))] (p/do! (ui-outliner-tx/transact! {:outliner-op :delete-blocks} (when (seq blocks) (outliner-op/delete-blocks! blocks nil)) (case view-feature-type :class-objects (when (seq page-ids) (let [tx-data (map (fn [pid] [:db/retract pid :block/tags (:db/id view-parent)]) page-ids)] (when (seq tx-data) (outliner-op/transact! tx-data {:outliner-op :save-block})))) :property-objects ;; Relationships with built-in properties must not be deleted e.g. built-in? or parent (when-not (:logseq.property/built-in? view-parent) (let [tx-data (map (fn [pid] [:db/retract pid (:db/ident view-parent)]) page-ids)] (when (seq tx-data) (outliner-op/transact! tx-data {:outliner-op :save-block})))) :query-result (doseq [page pages] (when-let [id (:block/uuid page)] (outliner-op/delete-page! id))) :all-pages (state/pub-event! [:page/show-delete-dialog selected-rows update-table-state!]) nil)) (when-not (or (= view-feature-type :all-pages) (and (= view-feature-type :property-objects) (:logseq.property/built-in? view-parent))) (update-table-state!))))) (defn- table-header [table {:keys [show-add-property? add-property! view-parent view-feature-type] :as option} selected-rows] (let [set-ordered-columns! (get-in table [:data-fns :set-ordered-columns!]) pinned (get-in table [:state :pinned-columns]) unpinned (get-in table [:state :unpinned-columns]) build-item (fn [column] {:id (:name column) :value (:id column) :content (table-header-cell table column) :disabled? (= (:id column) :select)}) pinned-items (mapv build-item pinned) unpinned-items (if show-add-property? (conj (mapv build-item unpinned) {:id "add property" :prop {:style {:width "-webkit-fill-available" :min-width 160} :on-click (fn [] (when (fn? add-property!) (add-property!)))} :value :add-new-property :content (add-property-button) :disabled? true}) (mapv build-item unpinned)) selection-rows-count (count selected-rows)] (shui/table-header (when (seq pinned-items) [:div.sticky-columns.flex.flex-row (dnd/items pinned-items {:vertical? false :on-drag-end (fn [ordered-columns _m] (set-ordered-columns! ordered-columns))})]) (when (seq unpinned-items) [:div.flex.flex-row (dnd/items unpinned-items {:vertical? false :on-drag-end (fn [ordered-columns _m] (set-ordered-columns! ordered-columns))})]) (when (pos? selection-rows-count) [:div.table-action-bar.absolute.top-0.left-8 (action-bar table selected-rows (assoc option :on-delete-rows (fn [table selected-ids] (on-delete-rows view-parent view-feature-type table selected-ids))))])))) (rum/defc lazy-table-cell [cell-render-f cell-placeholder] (let [^js state (ui/useInView #js {:rootMargin "0px"}) in-view? (.-inView state)] [:div.h-full {:ref (.-ref state)} (if in-view? (cell-render-f) cell-placeholder)])) (rum/defc table-row-inner < rum/static [{:keys [row-selected?] :as table} row props {:keys [show-add-property? scrolling?]}] (let [pinned-columns (get-in table [:state :pinned-columns]) unpinned (get-in table [:state :unpinned-columns]) unpinned-columns (if show-add-property? (conj (vec unpinned) {:id :add-property :cell (fn [_table _row _column])}) unpinned) sized-columns (get-in table [:state :sized-columns]) row-cell-f (fn [column {:keys [_lazy?]}] (let [id (str (:id row) "-" (:id column)) width (get-column-size column sized-columns) select? (= (:id column) :select) add-property? (= (:id column) :add-property) style {:width width :min-width width} cell-opts {:key id :select? select? :add-property? add-property? :style style} cell-placeholder (shui/table-cell cell-opts nil)] (if (and scrolling? (not (:block/title row))) cell-placeholder (when-let [render (get column :cell)] (lazy-table-cell (fn [] (shui/table-cell cell-opts (render table row column style))) cell-placeholder)))))] (shui/table-row (merge props {:key (str (:db/id row)) :data-state (when (row-selected? row) "selected") :on-pointer-down (fn [_e] (db-async/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 (datetime-property? property) [:before :after] (concat [:is :is-not] (case (:logseq.property/type property) (:default :url :node) [: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 :filters (fn [col] (update col 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 :filters (fn [col] (update col 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 :filters (fn [col] (update col 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 ^:large-vars/cleanup-todo filter-value-select < rum/static [view-entity {:keys [data-fns] :as table} property value operator idx opts] (let [type (:logseq.property/type property) property-ident (:db/ident property) [values set-values!] (hooks/use-state nil) [dropdown-open? set-dropdown-open!] (hooks/use-state false)] (hooks/use-effect! (fn [] (p/do! (let [values (if (coll? value) value [value]) ids (filter #(and (uuid? %) (nil? (db/entity [:block/uuid %]))) values)] (when (seq ids) (db-async/ {:input-default-placeholder (:block/title property) :input-opts {:class "!px-3 !py-1"} :items items :extract-fn :label :extract-chosen-fn :value :on-chosen (fn [value _selected? selected e] (when-not many? (shui/popup-hide!)) (let [value' (if many? selected value) set-filters-fn (fn [value'] (let [new-filters (update filters :filters (fn [col] (update col idx (fn [[property operator _value]] [property operator value']))))] (set-filters! new-filters)))] (if (= value "Custom date") (shui/popup-show! (.-target e) (ui/nlp-calendar {:initial-focus true :datetime? false :on-day-click (fn [value] (set-filters-fn value) (shui/popup-hide!))}) {}) (set-filters-fn value'))))} 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 :on-click #(set-dropdown-open! (not dropdown-open?))} (let [value (cond (uuid? value) (db/entity [:block/uuid value]) (instance? js/Date value) (some->> (tc/to-date value) (t/to-default-time-zone) (tf/unparse (tf/formatter "yyyy-MM-dd"))) (and (coll? value) (every? uuid? value)) (keep #(db/entity [:block/uuid %]) value) :else value)] [: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 "All")]))) (shui/dropdown-menu-content {:align "start"} (select/select option)))))) (rum/defc filter-value < rum/static [view-entity table property operator value filters set-filters! idx opts] (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 false :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 :filters (fn [col] (update col 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 view-entity table property value operator idx opts)))) (rum/defc filters-row < rum/static ; [view-entity {:keys [data-fns columns] :as table} opts] (let [filters (get-in table [:state :filters]) {:keys [set-filters!]} data-fns] (when (seq (:filters filters)) [:div.filters-row.flex.flex-row.items-center.gap-4.justify-between.flex-wrap.py-2 [:div.flex.flex-row.items-center.gap-2 (map-indexed (fn [idx filter'] (let [[property-ident operator value] filter' property (if (= property-ident :block/title) {:db/ident property-ident :block/title "Name"} (or (db/entity property-ident) (some (fn [column] (when (= (:id column) property-ident) {:db/ident (:id column) :block/title (:name column)})) columns)))] [: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/title property)]) (filter-operator property operator filters set-filters! idx) (filter-value view-entity table property operator value filters set-filters! idx opts) (shui/button {:class "!px-1 rounded-none text-muted-foreground" :variant "ghost" :size :sm :on-click (fn [_e] (let [new-filters (update filters :filters (fn [col] (vec (remove #{filter'} col))))] (set-filters! new-filters)))} (ui/icon "x"))])) (:filters filters))] (when (> (count (:filters filters)) 1) [:div (shui/select {:default-value (if (:or? filters) "or" "and") :on-value-change (fn [v] (set-filters! (assoc filters :or? (= v "or"))))} (shui/select-trigger {:class "opacity-75 hover:opacity-100 !px-2 !py-0 !h-6"} (shui/select-value {:placeholder "Match"})) (shui/select-content (shui/select-group (shui/select-item {:value "and"} "Match all filters") (shui/select-item {:value "or"} "Match any filter"))))])]))) (rum/defc new-record-button < rum/static [table view-entity] (let [asset? (and (:logseq.property/built-in? view-entity) (= (:block/name view-entity) "asset"))] (ui/tooltip (shui/button {:variant "ghost" :class "!px-1 text-muted-foreground" :size :sm :on-click (fn [_] (let [f (get-in table [:data-fns :add-new-object!])] (f view-entity table)))} (ui/icon (if asset? "upload" "plus"))) [:div "New node"]))) (rum/defc add-new-row < rum/static [view-entity table] [:div.py-1.px-2.cursor-pointer.flex.flex-row.items-center.gap-1.text-muted-foreground.hover:text-foreground.w-full.text-sm.border-b {:on-click (fn [_] (let [f (get-in table [:data-fns :add-new-object!])] (f view-entity table)))} (ui/icon "plus" {:size 14}) [:div "New"]]) (defn- table-filters->persist-state [filters] (mapv (fn [[property operator matches]] (let [matches' (cond (de/entity? matches) (:block/uuid matches) (and (coll? matches) (every? de/entity? matches)) (set (map :block/uuid matches)) :else matches)] (if (some? matches') [property operator matches'] [property operator]))) filters)) (defn- db-set-table-state! [entity {:keys [set-sorting! set-filters! set-visible-columns! set-ordered-columns! set-sized-columns!]}] (let [repo (state/get-current-repo) db-based? (config/db-based-graph?)] {:set-sorting! (fn [sorting] (p/do! (when db-based? (property-handler/set-block-property! repo (:db/id entity) :logseq.property.table/sorting sorting)) (set-sorting! sorting))) :set-filters! (fn [filters] (let [filters (-> (update filters :filters table-filters->persist-state) (update :or? boolean))] (p/do! (when db-based? (property-handler/set-block-property! repo (:db/id entity) :logseq.property.table/filters filters)) (set-filters! filters)))) :set-visible-columns! (fn [columns] (let [hidden-columns (vec (keep (fn [[column visible?]] (when (false? visible?) column)) columns))] (p/do! (when db-based? (property-handler/set-block-property! repo (:db/id entity) :logseq.property.table/hidden-columns hidden-columns)) (set-visible-columns! columns)))) :set-ordered-columns! (fn [ordered-columns] (let [ids (vec (remove #{:select} ordered-columns))] (p/do! (when db-based? (property-handler/set-block-property! repo (:db/id entity) :logseq.property.table/ordered-columns ids)) (set-ordered-columns! ordered-columns)))) :set-sized-columns! (fn [sized-columns] (p/do! (when db-based? (property-handler/set-block-property! repo (:db/id entity) :logseq.property.table/sized-columns sized-columns)) (set-sized-columns! sized-columns)))})) (rum/defc lazy-item [data idx {:keys [properties list-view? scrolling?]} item-render] (let [item (util/nth-safe data idx) db-id (cond (map? item) (:db/id item) (number? item) item :else nil) [item set-item!] (hooks/use-state nil) opts (if list-view? {:skip-refresh? true :children? false} {:children? false :properties properties :skip-transact? true :skip-refresh? true})] (hooks/use-effect! #(c.m/run-task* (m/sp (when (and db-id (not item) (not scrolling?)) (let [block (c.m/> (:logseq.property/_view-for entity) (filter (fn [view] (= view-feature-type (:logseq.property.view/feature-type view)))))] (ldb/sort-by-order views))) (defn- create-view! [view-parent view-feature-type] (when-let [page (db/get-case-page common-config/views-page-name)] (p/let [properties (cond-> {:logseq.property/view-for (:db/id view-parent) :logseq.property.view/feature-type view-feature-type} (contains? #{:linked-references :unlinked-references} view-feature-type) (assoc :logseq.property.view/type (:db/id (db/entity :logseq.property.view/type.list)) :logseq.property.view/group-by-property (:db/id (db/entity :block/page)))) view-exists? (seq (get-views view-parent view-feature-type)) view-title (if view-exists? "" (case view-feature-type :linked-references "Linked references" :unlinked-references "Unlinked references" :class-objects "All" :property-objects "All" :all-pages "All pages" "")) result (editor-handler/api-insert-new-block! view-title {:page (:block/uuid page) :properties properties :edit-block? false})] (db/entity [:block/uuid (:block/uuid result)])))) (rum/defc views-tab < rum/reactive db-mixins/query [view-parent current-view {:keys [views data items-count set-view-entity! set-data! set-views! view-feature-type show-items-count? references? opacity]}] [:div.views (for [view* views] (let [view (db/sub-block (:db/id view*)) current-view? (= (:db/id current-view) (:db/id view))] (shui/button {:variant :text :size :sm :class (str "text-sm px-0 py-0 h-6 " (when-not current-view? "text-muted-foreground")) :on-click (fn [e] (if (and current-view? (not= (:db/id view) (:db/id view-parent))) (shui/popup-show! (.-target e) (fn [] [:<> (shui/dropdown-menu-sub (shui/dropdown-menu-sub-trigger "Rename") (shui/dropdown-menu-sub-content (when-let [block-container-cp (state/get-component :block/container)] (block-container-cp {} view)))) (shui/dropdown-menu-item {:key "Delete" :on-click (fn [] (p/do! (editor-handler/delete-block-aux! view) (let [views' (remove (fn [v] (= (:db/id v) (:db/id view))) views)] (set-views! views') (set-view-entity! (first views')) (shui/popup-hide!))))} "Delete")]) {:as-dropdown? true :align "start" :content-props {:onClick shui/popup-hide!}}) (do (set-view-entity! view) (set-data! nil))))} (when-not references? (let [display-type (or (:db/ident (get view :logseq.property.view/type)) :logseq.property.view/type.table)] (when-let [icon (:logseq.property/icon (db/entity display-type))] (icon-component/icon icon {:color? true :size 15})))) (let [title (:block/title view)] (if (= title "") "New view" title)) (when (and current-view? show-items-count? (> items-count 0) (seq data)) [:span.text-muted-foreground.text-xs items-count])))) (shui/button {:variant :text :size :sm :title "Add new view" :class (str "!px-1 -ml-1 text-muted-foreground hover:text-foreground transition-opacity ease-in duration-300 " opacity) :on-click (fn [] (p/let [view (create-view! view-parent view-feature-type)] (set-views! (concat views [view]))))} (ui/icon "plus" {:size 15}))]) (rum/defc view-head < rum/static [view-parent view-entity table columns input sorting set-input! add-new-object! {:keys [view-feature-type title-key additional-actions] :as option}] (let [[hover? set-hover?] (hooks/use-state nil) db-based? (config/db-based-graph? (state/get-current-repo)) references? (contains? #{:linked-references :unlinked-references} view-feature-type) opacity (cond (and references? (not hover?)) "opacity-0" hover? "opacity-100" :else "opacity-75")] [:div.flex.flex-1.flex-nowrap.items-center.justify-between.gap-1.overflow-hidden {:on-mouse-over #(set-hover? true) :on-mouse-out #(set-hover? false)} [:div.flex.flex-row.items-center.gap-2 (if db-based? (if (= view-feature-type :query-result) [:div.font-medium.opacity-50.text-sm (t (or title-key :views.table/default-title) (count (:rows table)))] (views-tab view-parent view-entity (assoc option :hover? hover? :opacity opacity :references? references?))) [:div.font-medium.text-sm [:span (case view-feature-type :all-pages "All pages" :linked-references "Linked references" :unlinked-references "Unlinked references" "Nodes")] [:span.ml-1 (count (:rows table))]])] [:div.view-actions.flex.items-center.gap-1.transition-opacity.ease-in.duration-300 {:class opacity} (when (seq additional-actions) [:<> (for [action additional-actions] (if (fn? action) (action option) action))]) (when (and db-based? (seq sorting)) (view-sorting table columns sorting)) (when db-based? (filter-properties view-entity columns table option)) (search input {:on-change set-input! :set-input! set-input!}) (when db-based? [:div.text-muted-foreground.text-sm (pv/property-value view-entity (db/entity :logseq.property.view/type) {})]) (when db-based? (more-actions view-entity columns table)) (when (and db-based? add-new-object!) (new-record-button table view-entity))]])) (rum/defc ^:large-vars/cleanup-todo view-inner < rum/static [view-entity {:keys [view-parent data full-data set-data! columns add-new-object! foldable-options input set-input! sorting set-sorting! filters set-filters! view-feature-type] :as option*} *scroller-ref] (let [db-based? (config/db-based-graph?) option (assoc option* :properties (-> (remove #{:id :select} (map :id columns)) (conj :block/uuid :block/name) vec)) default-visible-columns (if-let [hidden-columns (conj (:logseq.property.table/hidden-columns view-entity) :id)] (zipmap hidden-columns (repeat false)) ;; This case can happen for imported tables (if (seq (:logseq.property.table/ordered-columns view-entity)) (zipmap (set/difference (set (map :id columns)) (set (:logseq.property.table/ordered-columns view-entity)) #{:select :block/created-at :block/updated-at}) (repeat false)) {})) [visible-columns set-visible-columns!] (rum/use-state default-visible-columns) ordered-columns (vec (concat [:select] (:logseq.property.table/ordered-columns view-entity))) sized-columns (:logseq.property.table/sized-columns view-entity) [ordered-columns set-ordered-columns!] (rum/use-state ordered-columns) [sized-columns set-sized-columns!] (rum/use-state sized-columns) {:keys [set-sorting! set-filters! set-visible-columns! set-ordered-columns! set-sized-columns!]} (db-set-table-state! view-entity {:set-sorting! set-sorting! :set-filters! set-filters! :set-visible-columns! set-visible-columns! :set-sized-columns! set-sized-columns! :set-ordered-columns! set-ordered-columns!}) [row-selection set-row-selection!] (rum/use-state {}) columns (sort-columns columns ordered-columns) select? (first (filter (fn [item] (= (:id item) :select)) columns)) id? (first (filter (fn [item] (= (:id item) :id)) columns)) pinned-properties (set (cond->> (map :db/ident (:logseq.property.table/pinned-columns view-entity)) id? (cons :id) select? (cons :select))) {pinned true unpinned false} (group-by (fn [item] (contains? pinned-properties (:id item))) (remove (fn [column] (false? (get visible-columns (:id column)))) columns)) group-by-property (:logseq.property.view/group-by-property view-entity) table-map {:view-entity view-entity :data data :full-data full-data :columns columns :state {:sorting sorting :filters filters :row-selection row-selection :visible-columns visible-columns :sized-columns sized-columns :ordered-columns ordered-columns :pinned-columns pinned :unpinned-columns unpinned :group-by-property group-by-property} :data-fns {:set-data! set-data! :set-filters! set-filters! :set-sorting! set-sorting! :set-visible-columns! set-visible-columns! :set-ordered-columns! set-ordered-columns! :set-sized-columns! set-sized-columns! :set-row-selection! set-row-selection! :add-new-object! add-new-object!}} table (shui/table-option table-map) *view-ref (rum/use-ref nil) display-type (if (config/db-based-graph?) (or (:db/ident (get view-entity :logseq.property.view/type)) (when (= (:view-type option) :linked-references) :logseq.property.view/type.list) :logseq.property.view/type.table) (if (= view-feature-type :all-pages) :logseq.property.view/type.table :logseq.property.view/type.list)) gallery? (= display-type :logseq.property.view/type.gallery) list-view? (= display-type :logseq.property.view/type.list) group-by-property-ident (or (:db/ident group-by-property) (when (and list-view? (nil? group-by-property)) :block/page) (when (and (not db-based?) (contains? #{:linked-references :unlinked-references} view-feature-type)) :block/page))] (run-effects! option table-map *scroller-ref gallery?) [:div.flex.flex-col.gap-2.grid {:ref *view-ref} (ui/foldable (view-head view-parent view-entity table columns input sorting set-input! add-new-object! option) [:div.ls-view-body.flex.flex-col.gap-2.grid.mt-1 (filters-row view-entity table option) (let [view-opts {:*scroller-ref *scroller-ref :display-type display-type :row-selection row-selection :add-new-object! add-new-object!}] (if (and group-by-property-ident (not (number? (first (:rows table))))) (when (seq (:rows table)) [:div.flex.flex-col.border-t.pt-2.gap-2 (map-indexed (fn [idx [value group]] (let [add-new-object! (when (fn? add-new-object!) (fn [_] (add-new-object! view-entity table {:properties {(:db/ident group-by-property) (or (and (map? value) (:db/id value)) value)}}))) table' (shui/table-option (-> table-map (assoc-in [:data-fns :add-new-object!] add-new-object!) (assoc :data group ; data for this group ))) readable-property-value #(if (and (map? %) (or (:block/title %) (:logseq.property/value %))) (db-property/property-value-content %) (str %)) group-by-page? (or (= :block/page group-by-property-ident) (and (not db-based?) (contains? #{:linked-references :unlinked-references} display-type)))] (rum/with-key (ui/foldable [:div (cond group-by-page? (if value (let [c (state/get-component :block/page-cp)] (c {:disable-preview? true} value)) [:div.text-muted-foreground.text-sm "Pages"]) (some? value) (let [icon (pu/get-block-property-value value :logseq.property/icon)] [:div.flex.flex-row.gap-1.items-center (when icon (icon-component/icon icon {:color? true})) (readable-property-value value)]) :else (str "No " (:block/title group-by-property)))] (let [render (view-cp view-entity (assoc table' :rows group) option view-opts)] (if list-view? [:div.-ml-2 render] render)) {:title-trigger? false}) (str (:db/id view-entity) "-group-idx-" idx)))) (:rows table))]) (view-cp view-entity table option view-opts)))] (merge {:title-trigger? false} foldable-options))])) (rum/defcs view-container "Provides a view for data like query results and tagged objects, multiple layouts such as table and list are supported. Args: * view-entity: a db Entity * option: * title-key: dict key defaults to `:views.table/default-title` * data: a collections of entities * set-data!: `fn` to update `data` * columns: view columns including properties and db attributes, which could be built by `build-columns` * add-new-object!: `fn` to create a new object (or row) * show-add-property?: whether to show `Add property` * add-property!: `fn` to add a new property (or column)" < (rum/local nil ::scroller-ref) [state view-entity option] (rum/with-key (view-inner view-entity (cond-> option (or config/publishing? (:logseq.property.view/group-by-property view-entity)) (dissoc :add-new-object!)) (::scroller-ref state)) (str "view-" (:db/id view-entity)))) (defn {:view-for-id (or (:db/id (:logseq.property/view-for view-entity)) (:db/id view-parent)) :view-feature-type view-feature-type :input input :filters filters :sorting sorting} query? (assoc :query-entity-ids query-entity-ids))))] (set-data! data) (when ref-pages-count (set-ref-pages-count! ref-pages-count))) (finally (set-loading! false)))))))))] (let [sorting-filters {:sorting sorting :filters filters}] (hooks/use-effect! load-view-data [(:db/id view-entity) (hooks/use-debounced-value input 300) sorting-filters (:db/id (:logseq.property.view/group-by-property view-entity)) (:db/id (:logseq.property.view/type view-entity)) ;; page filters (:logseq.property.linked-references/includes view-parent) (:logseq.property.linked-references/excludes view-parent) (:filters view-parent) query-entity-ids])) (if loading? [:div.flex.flex-col.space-2.gap-2.my-2 (repeat 3 (shui/skeleton {:class "h-6 w-full"}))] [:div.flex.flex-col.gap-2 (view-container view-entity (assoc option :data data :full-data data :filters filters :sorting sorting :set-filters! set-filters! :set-sorting! set-sorting! :set-data! set-data! :set-input! set-input! :input input :items-count (if (every? number? data) (count data) ;; grouped (reduce (fn [total [_ col]] (+ total (count col))) 0 data)) :ref-pages-count ref-pages-count :load-view-data load-view-data :set-view-entity! set-view-entity!))]))) (rum/defc sub-view < rum/reactive db-mixins/query [view-entity option] (let [view (or (some-> (:db/id view-entity) db/sub-block) view-entity)] (view-aux view option))) (rum/defc view < rum/static [{:keys [view-parent view-feature-type view-entity] :as option}] (let [[views set-views!] (hooks/use-state nil) [view-entity set-view-entity!] (hooks/use-state view-entity) query? (= view-feature-type :query-result) db-based? (config/db-based-graph?)] (hooks/use-effect! #(c.m/run-task* (m/sp (when-not query? ; TODO: move query logic to worker (let [repo (state/get-current-repo)] (when (and db-based? (not view-entity)) (c.m/