diff --git a/clj-e2e/deps.edn b/clj-e2e/deps.edn index 3377a1d7cc..4e197e994a 100644 --- a/clj-e2e/deps.edn +++ b/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"} diff --git a/clj-e2e/dev/user.clj b/clj-e2e/dev/user.clj index c0e9187b76..fbcbe1534b 100644 --- a/clj-e2e/dev/user.clj +++ b/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)) diff --git a/clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj b/clj-e2e/test/logseq/e2e/property_scoped_choices_test.clj new file mode 100644 index 0000000000..c06ebb5b93 --- /dev/null +++ b/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))) diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index ad4cd5d397..c81c8f32ee 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/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 diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs index 2ae81a97b0..f37924f9d3 100644 --- a/deps/db/src/logseq/db/frontend/schema.cljs +++ b/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. diff --git a/deps/outliner/src/logseq/outliner/property.cljs b/deps/outliner/src/logseq/outliner/property.cljs index 5cbc8009a4..f76a79ff40 100644 --- a/deps/outliner/src/logseq/outliner/property.cljs +++ b/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 diff --git a/src/main/frontend/components/property/config.cljs b/src/main/frontend/components/property/config.cljs index 0542716787..c3644ae4e8 100644 --- a/src/main/frontend/components/property/config.cljs +++ b/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 [] (-> ( 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)] diff --git a/src/main/frontend/components/property/value.cljs b/src/main/frontend/components/property/value.cljs index 78104711e8..1f9b9225cf 100644 --- a/src/main/frontend/components/property/value.cljs +++ b/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] diff --git a/src/main/frontend/worker/db/migrate.cljs b/src/main/frontend/worker/db/migrate.cljs index b1dcad24da..b72727aec1 100644 --- a/src/main/frontend/worker/db/migrate.cljs +++ b/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)))]