Browse Source

feat: tag-scoped property choices (#12295)

* feat: tag-scoped property choices

* Able to hide global choices per tag

* add e2e tests
Tienson Qin 2 months ago
parent
commit
a9a9905b05

+ 3 - 1
clj-e2e/deps.edn

@@ -2,7 +2,9 @@
  :deps {org.clojure/clojure {:mvn/version "1.12.0"}
         ;; io.github.pfeodrippe/wally {:local/root "../../../wally"}
         io.github.pfeodrippe/wally {:git/url "https://github.com/logseq/wally"
-                                    :sha "8571fae7c51400ac61c8b1026cbfba68279bc461"}
+                                    :sha "8571fae7c51400ac61c8b1026cbfba68279bc461"
+                                    :exclusions [com.microsoft.playwright/playwright]}
+        com.microsoft.playwright/playwright {:mvn/version "1.57.0"}
         ;; io.github.zmedelis/bosquet {:mvn/version "2025.03.28"}
         org.clj-commons/claypoole          {:mvn/version "1.2.2"}
         metosin/jsonista                   {:mvn/version "0.3.13"}

+ 6 - 0
clj-e2e/dev/user.clj

@@ -13,6 +13,7 @@
             [logseq.e2e.outliner-basic-test]
             [logseq.e2e.plugins-basic-test]
             [logseq.e2e.property-basic-test]
+            [logseq.e2e.property-scoped-choices-test]
             [logseq.e2e.reference-basic-test]
             [logseq.e2e.rtc-basic-test]
             [logseq.e2e.rtc-extra-part2-test]
@@ -45,6 +46,11 @@
   (->> (future (run-tests 'logseq.e2e.property-basic-test))
        (swap! *futures assoc :property-test)))
 
+(defn run-property-scoped-choices-test
+  []
+  (->> (future (run-tests 'logseq.e2e.property-scoped-choices-test))
+       (swap! *futures assoc :property-scoped-choices-test)))
+
 (defn run-outliner-test
   []
   (->> (future (run-tests 'logseq.e2e.outliner-basic-test))

+ 96 - 0
clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj

@@ -0,0 +1,96 @@
+(ns logseq.e2e.property-scoped-choices-test
+  (:require [clojure.test :refer [deftest use-fixtures]]
+            [logseq.e2e.assert :as assert]
+            [logseq.e2e.block :as b]
+            [logseq.e2e.fixtures :as fixtures]
+            [logseq.e2e.keyboard :as k]
+            [logseq.e2e.locator :as loc]
+            [logseq.e2e.page :as page]
+            [logseq.e2e.util :as util]
+            [wally.main :as w]
+            [wally.repl :as repl]))
+
+(use-fixtures :once fixtures/open-page)
+
+(use-fixtures :each
+  fixtures/new-logseq-page
+  fixtures/validate-graph)
+
+(defn- add-property
+  [property-name]
+  (b/new-blocks ["setup"])
+  (w/click (util/get-by-text "setup" true))
+  (k/press "Control+e")
+  (util/input-command "Add new property")
+  (w/click "input[placeholder]")
+  (util/input property-name)
+  (w/click (w/get-by-text "New option:"))
+  (w/click (loc/and "span" (util/get-by-text "Text" true)))
+  (k/esc)
+  (assert/assert-is-visible (format ".property-k:text('%s')" property-name)))
+
+(defn- add-tag-property
+  [property-name]
+  (w/click "button:has-text('Add tag property')")
+  (w/click "input[placeholder='Add or change property']")
+  (util/input property-name)
+  (w/click (loc/filter "a.menu-link" :has-text property-name))
+  (assert/assert-is-visible (format ".property-k:text('%s')" property-name)))
+
+(defn- open-choices-pane
+  [property-name]
+  (w/click (loc/filter ".property-k" :has-text property-name))
+  (w/click (loc/filter "div[role='menuitem']" :has-text "Available choices")))
+
+(defn- add-choice
+  [property-name choice]
+  (open-choices-pane property-name)
+  (w/click (loc/filter "div[role='menuitem']" :has-text "Add choice"))
+  (w/fill "input[placeholder='title']" choice)
+  (w/click "button:has-text('Save')")
+  (k/esc))
+
+(defn- hide-choice-for-tag
+  [property-name choice tag]
+  (open-choices-pane property-name)
+  (util/wait-timeout 100)
+  (w/click (format ".choices-list li:has-text('%s') button[title='More settings']" choice))
+  (util/wait-timeout 100)
+  (w/click (loc/filter "div[role='menuitem']" :has-text (str "Hide for #" tag)))
+  (k/esc))
+
+(defn- open-property-value-select
+  [property-name]
+  (w/click "div.jtrigger span:has-text('Empty')")
+  (assert/assert-is-visible (format "input[placeholder='Set %s']" property-name))
+  (w/click (format "input[placeholder='Set %s']" property-name))
+  (assert/assert-is-visible ".cp__select-results"))
+
+(deftest tag-scoped-property-choices-test
+  (let [tag "Device"
+        property-name "device-type"
+        scoped-choice "wired"
+        global-choice "wireless"]
+    (add-property property-name)
+    (page/new-page tag)
+    (page/convert-to-tag tag)
+    (add-tag-property property-name)
+    (add-choice property-name scoped-choice)
+    (util/wait-timeout 100)
+    (k/esc)
+    (page/goto-page property-name)
+    (add-choice property-name global-choice)
+    (util/wait-timeout 100)
+    (k/esc)
+    (page/goto-page tag)
+    ;; open tag properties
+    (w/click (.first (w/-query "a.block-control")))
+    (hide-choice-for-tag property-name global-choice tag)
+    (util/wait-timeout 100)
+    (k/esc)
+    (page/new-page "scoped-choices-test")
+    (b/new-block "Device item")
+    (util/set-tag tag)
+    (open-property-value-select property-name)
+    (assert/assert-is-visible (loc/filter ".cp__select-results" :has-text scoped-choice))
+    (assert/assert-have-count (loc/filter ".cp__select-results" :has-text global-choice) 0)))

+ 18 - 0
deps/db/src/logseq/db/frontend/property.cljs

@@ -275,6 +275,24 @@
       :schema {:type :checkbox
                :hide? true}
       :queryable? false}
+     ;; tag-scoped choice, a choice can be specified locally for specified tags
+     :logseq.property/choice-classes
+     {:title "Choice classes"
+      :schema {:type :class
+               :cardinality :many
+               :public? false
+               :hide? true
+               :view-context :never}
+      :queryable? false}
+     ;; tag can define which global choices are hidden for its objects
+     :logseq.property/choice-exclusions
+     {:title "Choice exclusions"
+      :schema {:type :node
+               :cardinality :many
+               :public? false
+               :hide? true
+               :view-context :never}
+      :queryable? false}
      :logseq.property/checkbox-display-properties
      {:title "Properties displayed as checkbox"
       :schema {:type :property

+ 1 - 1
deps/db/src/logseq/db/frontend/schema.cljs

@@ -37,7 +37,7 @@
          (map (juxt :major :minor)
               [(parse-schema-version x) (parse-schema-version y)])))
 
-(def version (parse-schema-version "65.18"))
+(def version (parse-schema-version "65.19"))
 
 (defn major-version
   "Return a number.

+ 20 - 13
deps/outliner/src/logseq/outliner/property.cljs

@@ -526,6 +526,17 @@
         :else
         (batch-remove-property! conn [eid] property-id)))))
 
+(defn- set-block-db-attribute!
+  [conn db block property property-id v]
+  (throw-error-if-invalid-property-value db property v)
+  (when-not (and (= property-id :block/alias) (= v (:db/id block))) ; alias can't be itself
+    (let [tx-data (cond->
+                   [{:db/id (:db/id block) property-id v}]
+                    (= property-id :logseq.property.class/extends)
+                    (conj [:db/retract (:db/id block) :logseq.property.class/extends :logseq.class/Root]))]
+      (ldb/transact! conn tx-data
+                     {:outliner-op :save-block}))))
+
 (defn set-block-property!
   "Updates a block property's value for an existing property-id and block.  If
   property is a ref type, automatically handles a raw property value i.e. you
@@ -559,15 +570,8 @@
           (outliner-validate/validate-extends-property @conn v' [block]))
         (cond
           db-attribute?
-          (do
-            (throw-error-if-invalid-property-value db property v')
-            (when-not (and (= property-id :block/alias) (= v' (:db/id block))) ; alias can't be itself
-              (let [tx-data (cond->
-                             [{:db/id (:db/id block) property-id v'}]
-                              (= property-id :logseq.property.class/extends)
-                              (conj [:db/retract (:db/id block) :logseq.property.class/extends :logseq.class/Root]))]
-                (ldb/transact! conn tx-data
-                               {:outliner-op :save-block}))))
+          (set-block-db-attribute! conn db block property property-id v)
+
           :else
           (let [_ (assert (some? property) (str "Property " property-id " doesn't exist yet"))
                 ref? (db-property-type/all-ref-property-types property-type)
@@ -580,6 +584,7 @@
                                  (= existing-value v'))]
             (throw-error-if-self-value block v' ref?)
 
+            (prn :debug :value-matches? value-matches?)
             (when-not value-matches?
               (raw-set-block-property! conn block property v'))))))))
 
@@ -729,7 +734,7 @@
          (ldb/sort-by-order))))
 
 (defn- build-closed-value-tx
-  [db property resolved-value {:keys [id icon]}]
+  [db property resolved-value {:keys [id icon scoped-class-id]}]
   (let [block (when id (d/entity db [:block/uuid id]))
         block-id (or id (ldb/new-block-id))
         icon (when-not (and (string? icon) (string/blank? icon)) icon)
@@ -754,11 +759,13 @@
         tx-data' (if (and (:db/id block) (nil? icon))
                    (conj tx-data [:db/retract (:db/id block) :logseq.property/icon])
                    tx-data)]
-    tx-data'))
+    (cond-> (vec tx-data')
+      scoped-class-id
+      (conj [:db/add [:block/uuid block-id] :logseq.property/choice-classes scoped-class-id]))))
 
 (defn upsert-closed-value!
   "id should be a block UUID or nil"
-  [conn property-id {:keys [id value description] :as opts}]
+  [conn property-id {:keys [id value description _scoped-class-id] :as opts}]
   (assert (or (nil? id) (uuid? id)))
   (let [db @conn
         property (d/entity db property-id)
@@ -797,8 +804,8 @@
 
           :else
           (let [tx-data (build-closed-value-tx @conn property resolved-value opts)]
+            (prn :debug :tx-data tx-data)
             (ldb/transact! conn tx-data {:outliner-op :save-block})
-
             (when (seq description)
               (if-let [desc-ent (and id (:logseq.property/description (d/entity db [:block/uuid id])))]
                 (ldb/transact! conn

+ 84 - 22
src/main/frontend/components/property/config.cljs

@@ -204,7 +204,7 @@
                      "Save")])]))
 
 (rum/defc choice-base-edit-form
-  [own-property block]
+  [own-property block owner-block]
   (let [create? (:create? block)
         uuid (:block/uuid block)
         *form-data (rum/use-ref
@@ -242,7 +242,11 @@
                       :disabled (not dirty?)
                       :on-click (fn []
                                   (-> (<upsert-closed-value! own-property
-                                                             (cond-> form-data uuid (assoc :id uuid)))
+                                                             (cond-> form-data
+                                                               uuid
+                                                               (assoc :id uuid)
+                                                               (ldb/class? owner-block)
+                                                               (assoc :scoped-class-id (:db/id owner-block))))
                                       (p/then #(shui/popup-hide!))
                                       (p/catch #(shui/toast! (str %) :error))))
                       :variant (if dirty? :default :secondary)}
@@ -307,7 +311,7 @@
          (when disabled? (shui/tabler-icon "forbid-2" {:size 15}))])])))
 
 (rum/defc choice-item-content < rum/reactive db-mixins/query
-  [property block {:keys [disabled?]}]
+  [property block {:keys [disabled? owner-block]}]
   (let [delete-choice! (fn []
                          (p/do!
                           (db-property-handler/delete-closed-value! (:db/id property) (:db/id block))
@@ -317,7 +321,12 @@
                         (:block/uuid block) :logseq.property/icon
                         (select-keys icon [:id :type :color])))
         icon (:logseq.property/icon block)
-        value (db-property/closed-value-content block)]
+        value (db-property/closed-value-content block)
+        owner-class? (ldb/class? owner-block)
+        owner-block' (when (and owner-class? (:db/id owner-block))
+                       (db/sub-block (:db/id owner-block)))
+        excluded-ids (set (keep :db/id (:logseq.property/choice-exclusions owner-block')))
+        global-choice? (empty? (:logseq.property/choice-classes block))]
     [:li
      (shui/button {:size :sm :variant :ghost :title "Drag && Drop to reorder"}
                   (shui/tabler-icon "grip-vertical" {:size 14}))
@@ -328,7 +337,7 @@
                                        :button-opts {:title "Set Icon"}})
      [:strong {:on-click (fn [^js e]
                            (shui/popup-show! (.-target e)
-                                             (fn [] (choice-base-edit-form property block))
+                                             (fn [] (choice-base-edit-form property block {}))
                                              {:id :ls-base-edit-form
                                               :align "start"}))}
       value]
@@ -360,12 +369,30 @@
                             :checked default-value?})
             "Set as default choice")))
 
-       (shui/dropdown-menu-item
-        {:key "delete"
-         :class "del"
-         :on-click delete-choice!}
-        (ui/icon "x" {:class "scale-90 pr-1 opacity-80"})
-        "Delete")))]))
+       (when (and owner-class? owner-block' global-choice?)
+         (let [excluded? (contains? excluded-ids (:db/id block))
+               tag-title (:block/title owner-block')
+               toggle-exclusion! (fn []
+                                   (if excluded?
+                                     (db-property-handler/delete-property-value! (:db/id owner-block) :logseq.property/choice-exclusions (:db/id block))
+                                     (db-property-handler/set-block-property! (:db/id owner-block) :logseq.property/choice-exclusions (:db/id block))))]
+           (shui/dropdown-menu-item
+            {:key "exclude for tag"
+             :on-click toggle-exclusion!}
+            (shui/checkbox {:id "exclude for tag"
+                            :size :sm
+                            :title "Hide choice for this tag"
+                            :class "mr-1 opacity-50 hover:opacity-100"
+                            :checked excluded?})
+            (str "Hide for #" tag-title))))
+
+       (when-not (and owner-class? global-choice?)
+         (shui/dropdown-menu-item
+          {:key "delete"
+           :class "del"
+           :on-click delete-choice!}
+          (ui/icon "x" {:class "scale-90 pr-1 opacity-80"})
+          "Delete"))))]))
 
 (rum/defc add-existing-values
   [property values {:keys [toggle-fn]}]
@@ -383,24 +410,53 @@
                    (toggle-fn)))}
     "Add choices")])
 
-(rum/defc choices-sub-pane < rum/reactive db-mixins/query
-  [property {:keys [disabled?] :as opts}]
-  (let [values (:property/closed-values property)
-        choices (doall
-                 (keep (fn [value]
-                         (db/sub-block (:db/id value)))
-                       values))
+(rum/defcs choices-sub-pane < rum/reactive db-mixins/query
+  (rum/local false ::show-hidden?)
+  [state property {:keys [disabled? owner-block] :as opts}]
+  (let [*show-hidden? (::show-hidden? state)
+        values (:property/closed-values property)
+        choices (->> values
+                     (keep (fn [value]
+                             (db/sub-block (:db/id value))))
+                     (filter (fn [block]
+                               (let [classes (set (map :db/id (:logseq.property/choice-classes block)))]
+                                 (if (and (seq classes) (ldb/class? owner-block))
+                                   (contains? classes (:db/id owner-block))
+                                   true)))))
+        excluded-ids (set (keep :db/id (:logseq.property/choice-exclusions owner-block)))
+        default-class-ids (when (ldb/class? owner-block)
+                            [(:db/id owner-block)])
+        hidden-choices (filter (fn [block]
+                                 (and (empty? (:logseq.property/choice-classes block))
+                                      (contains? excluded-ids (:db/id block))))
+                               choices)
+        visible-choices (remove (fn [block]
+                                  (and (empty? (:logseq.property/choice-classes block))
+                                       (contains? excluded-ids (:db/id block))))
+                                choices)
+        list-choices (if @*show-hidden?
+                       (concat visible-choices hidden-choices)
+                       visible-choices)
         choice-items (map
                       (fn [block]
                         (let [id (:block/uuid block)]
                           {:id (str id)
                            :value id
-                           :content (choice-item-content property block opts)}))
-                      choices)]
+                           :content (choice-item-content property block
+                                                         (assoc opts :owner-block owner-block))}))
+                      list-choices)]
 
     [:div.ls-property-dropdown.ls-property-choices-sub-pane
      (when (seq choices)
        [:<>
+        (when (and (seq hidden-choices) (ldb/class? owner-block))
+          (shui/button
+           {:size :sm
+            :variant :ghost
+            :class "text-muted-foreground"
+            :on-click (fn []
+                        (swap! *show-hidden? not))}
+           (if @*show-hidden? "Hide hidden choices" "Show hidden choices")))
         [:ul.choices-list
          (dnd/items choice-items
                     {:sort-by-inner-element? false
@@ -449,7 +505,9 @@
                                                (let [opts {:toggle-fn (fn [] (shui/popup-hide! id))}]
                                                  (if (seq values')
                                                    (add-existing-values property values' opts)
-                                                   (choice-base-edit-form property {:create? true}))))
+                                                   (choice-base-edit-form property
+                                                                          {:create? true}
+                                                                          {:default-class-ids default-class-ids}))))
                                              {:id :ls-base-edit-form
                                               :align "start"}))))}}))]))
 
@@ -641,7 +699,11 @@
         (let [values (:property/closed-values property)]
           (dropdown-editor-menuitem {:icon :list :title "Available choices"
                                      :desc (when (seq values) (str (count values) " choices"))
-                                     :submenu-content (fn [] (choices-sub-pane property {:disabled? config/publishing?}))})))
+                                     :submenu-content (fn []
+                                                        (choices-sub-pane property
+                                                                          {:disabled? config/publishing?
+                                                                           :owner-block owner-block
+                                                                           :class-schema? class-schema?}))})))
 
       (when enable-closed-values?
         (let [values (:property/closed-values property)]

+ 25 - 1
src/main/frontend/components/property/value.cljs

@@ -595,6 +595,30 @@
       :else
       id)))
 
+(defn- normalize-choice-ids
+  [values]
+  (set (keep :db/id values)))
+
+(defn- scoped-closed-values
+  [property block]
+  (let [values (:property/closed-values property)
+        classes (:block/tags block)
+        class-ids (set (keep :db/id classes))
+        excluded-ids (normalize-choice-ids
+                      (mapcat :logseq.property/choice-exclusions classes))]
+    (filter (fn [value]
+              (let [scope-ids (set (keep :db/id (:logseq.property/choice-classes value)))]
+                (cond
+                  (empty? scope-ids)
+                  (not (contains? excluded-ids (:db/id value)))
+
+                  (seq class-ids)
+                  (seq (set/intersection scope-ids class-ids))
+
+                  :else
+                  false)))
+            values)))
+
 (defn- sort-select-items
   [property selected-choices items]
   (if (:property/closed-values property)
@@ -935,7 +959,7 @@
                     (let [date? (and
                                  (= (:db/ident property) :logseq.property.repeat/recur-unit)
                                  (= :date (:logseq.property/type (:property opts))))
-                          values (cond->> (:property/closed-values property)
+                          values (cond->> (scoped-closed-values property block)
                                    date?
                                    (remove (fn [b] (contains? #{:logseq.property.repeat/recur-unit.minute :logseq.property.repeat/recur-unit.hour} (:db/ident b)))))]
                       (keep (fn [block]

+ 2 - 1
src/main/frontend/worker/db/migrate.cljs

@@ -184,7 +184,8 @@
                                {})]
    ["65.16" {:properties [:logseq.property.asset/external-file-name]}]
    ["65.17" {:properties [:logseq.property.publish/published-url]}]
-   ["65.18" {:fix deprecated-ensure-graph-uuid}]])
+   ["65.18" {:fix deprecated-ensure-graph-uuid}]
+   ["65.19" {:properties [:logseq.property/choice-classes :logseq.property/choice-exclusions]}]])
 
 (let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
                                      schema-version->updates)))]