(ns frontend.components.property "Block properties management." (:require [clojure.set :as set] [clojure.string :as string] [frontend.components.property.value :as pv] [frontend.components.select :as select] [frontend.components.svg :as svg] [frontend.config :as config] [frontend.db :as db] [frontend.db.async :as db-async] [frontend.db-mixins :as db-mixins] [frontend.db.model :as model] [frontend.handler.db-based.property :as db-property-handler] [frontend.handler.notification :as notification] [frontend.handler.property :as property-handler] [frontend.handler.page :as page-handler] [frontend.handler.db-based.property.util :as db-pu] [frontend.modules.shortcut.core :as shortcut] [frontend.search :as search] [frontend.state :as state] [frontend.ui :as ui] [frontend.util :as util] [logseq.shui.ui :as shui] [logseq.db.frontend.property :as db-property] [logseq.db.frontend.property.type :as db-property-type] [rum.core :as rum] [frontend.handler.route :as route-handler] [frontend.components.icon :as icon-component] [frontend.components.dnd :as dnd] [frontend.components.property.closed-value :as closed-value] [frontend.components.property.util :as components-pu] [promesa.core :as p] [logseq.db :as ldb])) (defn- > (map (fn [[name id]] {:label name :value id}) classes) (not= :template (:type @*property-schema)) (concat [{:label "Logseq Class" :value :logseq.class}])) opts (cond-> {:items options :input-default-placeholder (if multiple-choices? "Choose classes" "Choose class") :dropdown? false :close-modal? false :multiple-choices? multiple-choices? :selected-choices schema-classes :extract-fn :label :extract-chosen-fn :value :show-new-when-not-exact-match? true :input-opts {:on-blur toggle-fn :on-key-down (fn [e] (case (util/ekey e) "Escape" (do (util/stop e) (toggle-fn)) nil))}} multiple-choices? (assoc :on-apply (fn [choices] (p/let [choices' (p/all (map (fn [value] (p/let [result ( block :block/type (contains? "class")))] (when (or (not (and class? class-schema?)) ;; Only ask for confirmation on class schema properties (js/confirm "Are you sure you want to delete this property?")) (let [repo (state/get-current-repo) f (if (and class? class-schema?) db-property-handler/class-remove-property! property-handler/remove-block-property!) property-id (if (config/db-based-graph? repo) (:db/ident property) (:block/uuid property))] (f repo (:block/uuid block) property-id))))) (rum/defc schema-type < shortcut/disable-all-shortcuts [property {:keys [*property-name *property-schema built-in-property? disabled? show-type-change-hints? in-block-container? block *show-new-property-config? default-open?]}] (let [property-name (or (and *property-name @*property-name) (:block/original-name property)) property-schema (or (and *property-schema @*property-schema) (:block/schema property)) schema-types (->> (concat db-property-type/user-built-in-property-types (when built-in-property? db-property-type/internal-built-in-property-types)) (map (fn [type] {:label (property-type-label type) :disabled disabled? :value type :selected (= type (:type property-schema))})))] [:div {:class (if in-block-container? "flex flex-1" "flex items-center col-span-2")} (shui/select {:default-open (boolean default-open?) :on-value-change (fn [v] (let [type (keyword (string/lower-case v)) update-schema-fn (apply comp #(assoc % :type type) ;; always delete previous closed values as they ;; are not valid for the new type #(dissoc % :values) (keep (fn [attr] (when-not (db-property-type/property-type-allows-schema-attribute? type attr) #(dissoc % attr))) [:cardinality :classes :position]))] (when *property-schema (swap! *property-schema update-schema-fn)) (let [schema (or (and *property-schema @*property-schema) (update-schema-fn property-schema)) repo (state/get-current-repo)] (p/do! (when block (pv/exit-edit-property)) (reset! *show-new-property-config? false) (components-pu/update-property! property property-name schema) (when block (let [id (str "ls-property-" (:db/id block) "-" (:db/id property) "-editor")] (state/set-state! :editor/editing-property-value-id {id true})) (property-handler/set-block-property! repo (:block/uuid block) property-name (if (= type :default) "" :property/empty-placeholder)))))))} (shui/select-trigger {:class "!px-2 !py-0 !h-8"} (shui/select-value {:placeholder "Select a schema type"})) (shui/select-content (shui/select-group (for [{:keys [label value disabled]} schema-types] (shui/select-item {:value value :disabled disabled} label))))) (when show-type-change-hints? (ui/tippy {:html "Changing the property type clears some property configurations." :class "tippy-hover ml-2" :interactive true :disabled false} (svg/info)))])) (rum/defcs ^:large-vars/cleanup-todo property-config "All changes to a property must update the db and the *property-schema. Failure to do so can result in data loss" < shortcut/disable-all-shortcuts rum/reactive db-mixins/query (rum/local nil ::property-name) (rum/local nil ::property-schema) {:init (fn [state] (let [*values (atom :loading)] (p/let [result (db-async/ {:disabled config/publishing? :on-value-change (fn [v] (swap! *property-schema assoc :position v) (save-property-fn))} (string? position) (assoc :default-value position)) (shui/select-trigger {:class "!px-2 !py-0 !h-8"} (shui/select-value {:placeholder "Select a position mode"})) (shui/select-content (shui/select-group (for [{:keys [label value]} choices] (shui/select-item {:value value} label)))))]])) (let [hide? (:hide? @*property-schema)] [:div.grid.grid-cols-4.gap-1.items-center.leading-8 [:label "Hide by default:"] (shui/checkbox {:checked hide? :disabled config/publishing? :on-checked-change (fn [] (swap! *property-schema assoc :hide? (not hide?)) (save-property-fn))})]) [:div.grid.grid-cols-4.gap-1.items-start.leading-8 [:label "Description:"] [:div.col-span-3 (if (and disabled? inline-text) (inline-text {} :markdown (:description @*property-schema)) [:div.mt-1 (shui/textarea {:on-change (fn [e] (swap! *property-schema assoc :description (util/evalue e))) :on-blur save-property-fn :disabled disabled? :default-value (:description @*property-schema)})])]]]])))) (defn- get-property-from-db [name] (when-not (string/blank? name) (db/entity [:block/name (util/page-name-sanity-lc name)]))) (defn- add-property-from-dropdown "Adds an existing or new property from dropdown. Used from a block or page context. For pages, used to add both schema properties or properties for a page" [entity property-name {:keys [class-schema? page-configure?]}] (let [repo (state/get-current-repo)] ;; existing property selected or entered (if-let [property (get-property-from-db property-name)] (if (contains? db-property/hidden-built-in-properties (keyword property-name)) (do (notification/show! "This is a built-in property that can't be used." :error) (pv/exit-edit-property)) ;; Both conditions necessary so that a class can add its own page properties (when (and (contains? (:block/type entity) "class") class-schema?) (pv/> (keys (:block/properties entity)) (map #(:block/original-name (db/entity [:block/uuid %]))) (remove nil?) (set)) existing-tag-alias (reduce (fn [acc prop] (if (seq (get entity (get-in db-property/built-in-properties [prop :attribute]))) (if-let [name (get-in db-property/built-in-properties [prop :original-name])] (conj acc name) acc) acc)) #{} [:tags :alias]) exclude-properties* (set/union entity-properties existing-tag-alias) exclude-properties (set/union exclude-properties* (set (map string/lower-case exclude-properties*)))] [:div.ls-property-input.flex.flex-1.flex-row.items-center.flex-wrap.gap-1 (if in-block-container? {:style {:padding-left 22}} {}) (if @*property-key (let [property (get-property-from-db @*property-key)] [:div.ls-property-add.grid.grid-cols-5.gap-1.flex.flex-1.flex-row.items-center [:div.flex.flex-row.items-center.col-span-2 [:span.bullet-container.cursor [:span.bullet]] [:div {:style {:padding-left 6}} @*property-key]] (when property [:div.col-span-3.flex.flex-row {:on-pointer-down (fn [e] (util/stop-propagation e))} (when-not class-schema? (if @*show-new-property-config? (schema-type property {:default-open? true :in-block-container? in-block-container? :block entity :*show-new-property-config? *show-new-property-config?}) (pv/property-value entity property @*property-value (assoc opts :editing? true))))])]) (let [on-chosen (fn [{:keys [value]}] (reset! *property-key value) (p/let [result (add-property-from-dropdown entity value opts)] (when (and (true? result) *show-new-property-config?) (reset! *show-new-property-config? true)))) input-opts {:on-blur (fn [] (pv/exit-edit-property)) :on-key-down (fn [e] (case (util/ekey e) "Escape" (pv/exit-edit-property) nil))}] (property-select exclude-properties on-chosen input-opts)))])) (rum/defcs new-property < rum/reactive (rum/local false ::new-property?) (rum/local nil ::property-key) (rum/local nil ::property-value) {:will-unmount (fn [state] (state/set-state! :editor/new-property-key nil) state)} [state block id keyboard-triggered? opts] (let [*new-property? (::new-property? state) container-id (state/sub :editor/properties-container) new-property? (or keyboard-triggered? (and @*new-property? (= container-id id)))] (when-not (and (:in-block-container? opts) (not keyboard-triggered?)) [:div.ls-new-property (let [global-property-key (:editor/new-property-key @state/state) *property-key (if @global-property-key global-property-key (::property-key state)) *property-value (::property-value state)] (cond new-property? (property-input block *property-key *property-value opts) (and (or (db-property-handler/block-has-viewable-properties? block) (:page-configure? opts)) (not config/publishing?) (not (:in-block-container? opts))) [:a.fade-link.flex.add-property {:on-click (fn [] (reset! *property-key nil) (reset! *property-value nil) (state/set-state! :editor/block block) (state/set-state! :editor/properties-container id) (reset! *new-property? true))} [:div.flex.flex-row.items-center {:style {:padding-left 1}} (ui/icon "plus" {:size 15}) [:div.ml-1.text-sm {:style {:padding-left 2}} "Add property"]]] :else [:div {:style {:height 28}}]))]))) (defn- property-collapsed? [block property] (boolean? (some (fn [p] (= (:db/id property) (:db/id p))) (:block/collapsed-properties block)))) (rum/defcs property-key < (rum/local false ::hover?) [state block property {:keys [class-schema? block? collapsed? page-cp inline-text]}] (let [*hover? (::hover? state) repo (state/get-current-repo) icon (:logseq.property/icon property) property-name (:block/original-name property)] [:div.flex.flex-row.items-center {:on-mouse-over #(reset! *hover? true) :on-mouse-leave #(reset! *hover? false) :on-context-menu (fn [^js e] (util/stop e) (shui/popup-show! e [(shui/dropdown-menu-item {:on-click (fn [] (when-let [schema (some-> property :block/schema)] (components-pu/update-property! property property-name (assoc schema :hide? true)) (shui/popup-hide!)))} "Hide property") (when-not (ldb/built-in-class-property? (db/get-db) block property) (shui/dropdown-menu-item {:on-click (fn [] (handle-delete-property! block property {:class-schema? class-schema?}) (shui/popup-hide!))} [:span.w-full.text-red-rx-07.hover:text-red-rx-09 "Delete property"]))] {:as-dropdown? true :content-props {:class "w-48"}}))} (when block? [:a.block-control {:on-click (fn [event] (util/stop event) (db-property-handler/collapse-expand-property! repo block property (not collapsed?)))} [:span {:class (cond (or collapsed? @*hover?) "control-show cursor-pointer" :else "control-hide")} (ui/rotating-arrow collapsed?)]]) ;; icon picker (let [content-fn (fn [{:keys [id]}] (icon-component/icon-search {:on-chosen (fn [_e icon] (when icon (p/let [_ (db-property-handler/ (when-not config/publishing? {:on-click #(shui/popup-show! (.-target %) content-fn {:as-dropdown? true :auto-focus? true})}) (assoc :class "flex items-center")) (if icon (icon-component/icon icon) [:span.bullet-container.cursor (when collapsed? {:class "bullet-closed"}) [:span.bullet]]))) (if config/publishing? [:a.property-k.flex.select-none.jtrigger.pl-2 {:on-click #(route-handler/redirect-to-page! (:block/name property))} (:block/original-name property)] (shui/trigger-as :a {:tabIndex 0 :title (str "Configure property: " (:block/original-name property)) :class "property-k flex select-none jtrigger pl-2" :on-pointer-down (fn [^js e] (when (util/meta-key? e) (route-handler/redirect-to-page! (:block/name property)) (.preventDefault e))) :on-click (fn [^js e] (shui/popup-show! (.-target e) (fn [_] [:div.p-2 [:h2.text-lg.font-medium.mb-2.p-1 "Configure property"] (property-config property {:inline-text inline-text :page-cp page-cp})]) {:content-props {:class "property-configure-popup-content" :collisionPadding {:bottom 10 :top 10} :avoidCollisions true :align "start"} :auto-side? true :auto-focus? true}))} (:block/original-name property)))])) (defn- resolve-linked-block-if-exists "Properties will be updated for the linked page instead of the refed block. For example, the block below has a reference to the page \"How to solve it\", we'd like the properties of the class \"book\" (e.g. Authors, Published year) to be assigned for the page `How to solve it` instead of the referenced block. Block: - [[How to solve it]] #book " [block] (if-let [linked-block (:block/link block)] (db/sub-block (:db/id linked-block)) (db/sub-block (:db/id block)))) (rum/defc property-cp < rum/reactive db-mixins/query [block k v {:keys [inline-text page-cp] :as opts}] (when (keyword? k) (when-let [property (db/sub-block (:db/id (db/entity k)))] (let [type (get-in property [:block/schema :type] :default) closed-values? (seq (get-in property [:block/schema :values])) v-block (when (integer? v) (db/entity v)) block? (and v-block (not closed-values?) (:block/page v-block) (contains? #{:default :template} type)) collapsed? (when block? (property-collapsed? block property)) date? (= type :date) checkbox? (= type :checkbox)] [:div {:class (cond (and block? (not closed-values?)) "flex flex-1 flex-col gap-1 property-block" (or date? checkbox?) "property-pair items-center" :else "property-pair items-start")} [:div.property-key {:class "col-span-2"} (property-key block property (assoc (select-keys opts [:class-schema?]) :block? block? :collapsed? collapsed? :inline-text inline-text :page-cp page-cp))] (if (and (:class-schema? opts) (:page-configure? opts)) [:div.property-description.text-sm.opacity-70 {:class "col-span-3"} (inline-text {} :markdown (get-in property [:block/schema :description]))] (when-not collapsed? [:div.property-value {:class (if block? "block-property-value" "col-span-3 inline-grid")} (pv/property-value block property v opts)]))])))) (rum/defc properties-section < rum/reactive db-mixins/query [block properties opts] (let [class? (:class-schema? opts)] (when (seq properties) (if class? (let [choices (map (fn [[k v]] {:id (str k) :value k :content (property-cp block k v opts)}) properties)] (dnd/items choices {:on-drag-end (fn [properties] (let [schema (assoc (:block/schema block) :properties properties)] (when (seq properties) (db-property-handler/class-set-schema! (state/get-current-repo) (:block/uuid block) schema))))})) (for [[k v] properties] (property-cp block k v opts)))))) ;; TODO: Remove :page-configure? as it only ever seems to be set to true (rum/defcs ^:large-vars/cleanup-todo properties-area < rum/reactive {:init (fn [state] (assoc state ::id (str (random-uuid))))} [state target-block edit-input-id {:keys [in-block-container? page? page-configure? class-schema?] :as opts}] (let [id (::id state) block (resolve-linked-block-if-exists target-block) block-properties (:block/properties block) properties (if (and class-schema? page-configure?) (let [properties (:properties (:block/schema block))] (map (fn [k] [k nil]) properties)) (sort-by first block-properties)) alias (set (map :db/id (:block/alias block))) alias-properties (when (seq alias) [[:block/alias alias]]) remove-built-in-properties (fn [properties] (remove (fn [property-id] (contains? db-property/hidden-built-in-properties property-id)) properties)) {:keys [classes all-classes classes-properties]} (db-property-handler/get-block-classes-properties (:db/id block)) one-class? (= 1 (count classes)) block-own-properties (->> (concat (seq alias-properties) (seq properties)) remove-built-in-properties (remove (fn [[id _]] ((set classes-properties) id)))) root-block? (= (:id opts) (str (:block/uuid block))) ;; This section produces own-properties and full-hidden-properties hide-with-property-id (fn [property-id] (if (or root-block? page? page-configure?) false (boolean (:hide? (:block/schema (db/entity property-id)))))) property-hide-f (cond config/publishing? ;; Publishing is read only so hide all blank properties as they ;; won't be edited and distract from properties that have values (fn [[property-id property-value]] (or (nil? property-value) (hide-with-property-id property-id))) (:ui/hide-empty-properties? (state/get-config)) (fn [[property-id property-value]] ;; User's selection takes precedence over config (if (contains? (:block/schema (db/entity property-id)) :hide?) (hide-with-property-id property-id) (nil? property-value))) :else (comp hide-with-property-id first)) {_block-hidden-properties true block-own-properties' false} (group-by property-hide-f block-own-properties) {_class-hidden-properties true class-own-properties false} (group-by property-hide-f (map (fn [id] [id (get block-properties id)]) classes-properties)) own-properties (->> (if one-class? (concat block-own-properties' class-own-properties) block-own-properties')) class->properties (loop [classes all-classes properties #{} result []] (if-let [class (first classes)] (let [cur-properties (->> (:properties (:block/schema class)) (remove properties) (remove hide-with-property-id))] (recur (rest classes) (set/union properties (set cur-properties)) (if (seq cur-properties) (conj result [class cur-properties]) result))) result)) keyboard-triggered? (= (state/sub :editor/new-property-input-id) edit-input-id)] (when-not (and (empty? block-own-properties') (empty? class->properties) (not (:page-configure? opts)) (not keyboard-triggered?)) [:div.ls-properties-area (cond-> (if in-block-container? {:id id} {:id id :class (when class-schema? "class-properties")}) (:selected? opts) (update :class conj "select-none") true (assoc :tab-index 0 :on-key-up #(when-let [block (and (= "Escape" (.-key %)) (.closest (.-target %) "[blockid]"))] (state/set-selection-blocks! [block]) (some-> js/document.activeElement (.blur))))) (properties-section block (if class-schema? properties own-properties) opts) (rum/with-key (new-property block id keyboard-triggered? opts) (str id "-add-property")) (when (and (seq class->properties) (not one-class?)) (let [page-cp (:page-cp opts)] [:div.parent-properties.flex.flex-1.flex-col.gap-1 (for [[class class-properties] class->properties] (let [id-properties (->> class-properties remove-built-in-properties (map (fn [id] [id (get block-properties id)])))] (when (seq id-properties) [:div (when page-cp [:span.text-sm (page-cp {} class)]) (properties-section block id-properties opts)])))]))])))