mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
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:
@@ -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"}
|
||||
|
||||
@@ -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
clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj
Normal file
96
clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj
Normal 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)))
|
||||
18
deps/db/src/logseq/db/frontend/property.cljs
vendored
18
deps/db/src/logseq/db/frontend/property.cljs
vendored
@@ -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
|
||||
|
||||
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
@@ -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.
|
||||
|
||||
33
deps/outliner/src/logseq/outliner/property.cljs
vendored
33
deps/outliner/src/logseq/outliner/property.cljs
vendored
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
Reference in New Issue
Block a user