feat: tag-scoped property choices (#12295)

* feat: tag-scoped property choices

* Able to hide global choices per tag

* add e2e tests
This commit is contained in:
Tienson Qin
2026-01-04 13:42:27 +08:00
committed by GitHub
parent fd7e685829
commit a9a9905b05
9 changed files with 255 additions and 39 deletions

View File

@@ -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"}

View File

@@ -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))

View File

@@ -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)))

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)]

View File

@@ -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]

View File

@@ -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)))]