Compare commits

...

13 Commits

Author SHA1 Message Date
Pablo Alba
094ad897ab 🐛 Fix migration sync-component-id-with-near-main (loop added) 2026-01-07 16:24:16 +01:00
Pablo Alba
b2c45f6e1d 🐛 Fix migration and repair for nil ids 2026-01-07 16:24:16 +01:00
Pablo Alba
7a377258d3 🐛 Fix detach fails on nested components 2026-01-07 16:24:16 +01:00
Andrés Moya
dd4a18a3ff 🔧 Validate only after propagation in tests 2026-01-07 16:24:16 +01:00
Andrés Moya
bff005a951 🔧 Avoid copies with wrong component-id and repair if needed 2026-01-07 16:24:16 +01:00
Andrés Moya
776c62051d 🔧 Validate components with unneeded objects 2026-01-07 15:27:11 +01:00
Andrey Antukh
952f622ce9 🔧 Add 'Reapply` prefix to valid commit checker prefixes 2026-01-07 11:56:38 +01:00
Andrey Antukh
a6c6f97f47 Reapply "💄 Group tokens by name path (#7775)"
This reverts commit eff572d3bb.
2026-01-07 11:55:56 +01:00
Andrey Antukh
88424eb54a Merge branch 'staging' into develop 2026-01-07 11:55:40 +01:00
Alejandro Alonso
de9a21121a Merge remote-tracking branch 'origin/staging' into develop 2026-01-05 13:22:14 +01:00
Alejandro Alonso
cea10308b7 Merge remote-tracking branch 'origin/staging' into develop 2026-01-05 11:52:15 +01:00
David Barragán Merino
5223c9c881 🔧 Fix a typo in an interpolation 2026-01-05 09:13:14 +01:00
Alejandro Alonso
be62fa10c4 📎 Bump new version on changelog 2026-01-05 08:42:57 +01:00
25 changed files with 782 additions and 220 deletions

View File

@@ -33,7 +33,7 @@ jobs:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🐳 *[PENPOT] Docker image available: {{ github.ref_name }}*
🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}*
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check Commit Type
uses: gsactions/commit-message-checker@v2
with:
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
flags: 'gm'
error: 'Commit should match CONTRIBUTING.md guideline'
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request

View File

@@ -1,5 +1,18 @@
# CHANGELOG
## 2.14.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
### :bug: Bugs fixed
## 2.13.0 (Unreleased)
### :boom: Breaking changes & Deprecations

View File

@@ -1766,6 +1766,59 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0017-remove-unneeded-objects-from-components"
[data _]
;; Some components have an `:objects` attribute, despite not being
;; deleted. This migration removes it.
(letfn [(check-component [component]
(if (and (not (:deleted component))
(contains? component :objects))
(dissoc component :objects)
component))]
(d/update-when data :components d/update-vals check-component)))
(defmethod migrate-data "0018-sync-component-id-with-near-main"
[data _]
(let [libs (some-> (:libs data) deref)]
(letfn [(fix-shape
[data page shape]
(if (and (ctk/subcopy-head? shape)
(nil? (ctk/get-swap-slot shape)))
(let [file {:id (:id data) :data data}
ref-shape (ctf/find-ref-shape file page libs shape {:include-deleted? true :with-context? true})]
(if (and (some? ref-shape)
(or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape))))
(cond-> shape
(some? (:component-id ref-shape))
(assoc :component-id (:component-id ref-shape))
(nil? (:component-id ref-shape))
(dissoc :component-id)
(some? (:component-file ref-shape))
(assoc :component-file (:component-file ref-shape))
(nil? (:component-file ref-shape))
(dissoc :component-file))
shape))
shape))
(update-page
[data page]
(d/update-when page :objects d/update-vals (partial fix-shape data page)))
(fix-data [data]
(loop [current-data data
iteration 0]
(let [next-data (update current-data :pages-index d/update-vals (partial update-page current-data))]
(if (or (= current-data next-data)
(> iteration 20)) ;; safety bound
next-data
(recur next-data (inc iteration))))))]
(fix-data data))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@@ -1839,4 +1892,6 @@
"0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"]))
"0016-copy-fills-from-position-data-to-text-node"
"0017-remove-unneeded-objects-from-components"
"0018-sync-component-id-with-near-main"]))

View File

@@ -333,6 +333,31 @@
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :component-id-mismatch
[_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape
(fn [shape]
; Set the component-id and component-file to the ones of the near main
(log/debug :hint (str " -> set component-id to " (:component-id args)))
(log/debug :hint (str " -> set component-file to " (:component-file args)))
(cond-> shape
(some? (:component-id args))
(assoc :component-id (:component-id args))
(nil? (:component-id args))
(dissoc :component-id)
(some? (:component-file args))
(assoc :component-file (:component-file args))
(nil? (:component-file args))
(dissoc :component-file)))]
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :ref-shape-is-head
[_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape
@@ -499,7 +524,7 @@
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :component-nil-objects-not-allowed
[_ {:keys [shape] :as error} file-data _]
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
(let [repair-component
(fn [component]
; Remove the objects key, or set it to {} if the component is deleted
@@ -511,10 +536,26 @@
(log/debug :hint " -> remove :objects")
(dissoc component :objects))))]
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id shape) :name (:name shape))
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
(pcb/update-component (:id component) repair-component))))
(defmethod repair-error :non-deleted-component-cannot-have-objects
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
(let [repair-component
(fn [component]
; Remove the :objects field
(if-not (:deleted component)
(do
(log/debug :hint " -> remove :objects")
(dissoc component :objects))
component))]
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
(defmethod repair-error :invalid-text-touched
[_ {:keys [shape page-id] :as error} file-data _]

View File

@@ -51,6 +51,7 @@
:ref-shape-is-head
:ref-shape-is-not-head
:shape-ref-in-main
:component-id-mismatch
:root-main-not-allowed
:nested-main-not-allowed
:root-copy-not-allowed
@@ -59,6 +60,7 @@
:not-head-copy-not-allowed
:not-component-not-allowed
:component-nil-objects-not-allowed
:non-deleted-component-cannot-have-objects
:instance-head-not-frame
:invalid-text-touched
:misplaced-slot
@@ -326,6 +328,20 @@
:component-file (:component-file ref-shape)
:component-id (:component-id ref-shape)))))
(defn- check-ref-component-id
"Validate that if the copy has not been swwpped, the component-id and component-file are
the same as in the referenced shape in the near main."
[shape file page libraries]
(when (nil? (ctk/get-swap-slot shape))
(when-let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(when (or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape)))
(report-error :component-id-mismatch
"Nested copy component-id and component-file must be the same as the near main"
shape file page
:component-id (:component-id ref-shape)
:component-file (:component-file ref-shape))))))
(defn- check-empty-swap-slot
"Validate that this shape does not have any swap slot."
[shape file page]
@@ -418,6 +434,7 @@
(check-component-not-main-head shape file page libraries)
(check-component-not-root shape file page)
(check-valid-touched shape file page)
(check-ref-component-id shape file page libraries)
;; We can have situations where the nested copy and the ancestor copy come from different libraries and some of them have been dettached
;; so we only validate the shape-ref if the ancestor is from a valid library
(when library-exists
@@ -648,6 +665,13 @@
"Component main not allowed inside other component"
main-instance file component-page))))
(defn- check-not-objects
[component file]
(when (d/not-empty? (:objects component))
(report-error :non-deleted-component-cannot-have-objects
"A non-deleted component cannot have shapes inside"
component file nil)))
(defn- check-component
"Validate semantic coherence of a component. Report all errors found."
[component file]
@@ -656,7 +680,8 @@
"Objects list cannot be nil"
component file nil))
(when-not (:deleted component)
(check-main-inside-main component file))
(check-main-inside-main component file)
(check-not-objects component file))
(when (:deleted component)
(check-component-duplicate-swap-slot component file)
(check-ref-cycles component file))

View File

@@ -1769,6 +1769,23 @@
(pcb/update-shapes changes [(:id dest-shape)] ctk/unhead-shape {:ignore-touched true})
changes))
(defn- check-swapped-main
[changes dest-shape origin-shape]
;; Only for direct updates (from main to copy). Check if the main shape
;; has been swapped. If so, the new component-id and component-file must
;; be put into the copy.
(if (and (= (:shape-ref dest-shape) (:id origin-shape))
(ctk/instance-head? dest-shape)
(ctk/instance-head? origin-shape)
(or (not= (:component-id dest-shape) (:component-id origin-shape))
(not= (:component-file dest-shape) (:component-file origin-shape))))
(pcb/update-shapes changes [(:id dest-shape)]
#(assoc %
:component-id (:component-id origin-shape)
:component-file (:component-file origin-shape))
{:ignore-touched true})
changes))
(defn- update-attrs
"The main function that implements the attribute sync algorithm. Copy
attributes that have changed in the origin shape to the dest shape.
@@ -1811,6 +1828,8 @@
:always
(check-detached-main dest-shape origin-shape)
:always
(check-swapped-main dest-shape origin-shape)
:always
(generate-update-tokens container dest-shape origin-shape touched omit-touched? nil))
(let [attr-group (get ctk/sync-attrs attr)

View File

@@ -132,3 +132,94 @@ Some naming conventions:
(if-let [last-period (str/last-index-of s ".")]
[(subs s 0 (inc last-period)) (subs s (inc last-period))]
[s ""]))
;; Tree building functions --------------------------------------------------
"Build tree structure from flat list of paths"
"`build-tree-root` is the main function to build the tree."
"Receives a list of segments with 'name' properties representing paths,
and a separator string."
"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]"
"Transforms into a tree structure like:
[{:name 'one'
:path 'one'
:depth 0
:leaf nil
:children-fn (fn [] [{:name 'two'
:path 'one.two'
:depth 1
:leaf nil
:children-fn (fn [] [{... :name 'three'} {... :name 'four'}])}
{:name 'five'
:path 'one.five'
:depth 1
:leaf {... :name 'five'}
...}])}]"
(defn- sort-by-children
"Sorts segments so that those with children come first."
[segments separator]
(sort-by (fn [segment]
(let [path (split-path (:name segment) :separator separator)
path-length (count path)]
(if (= path-length 1)
1
0)))
segments))
(defn- group-by-first-segment
"Groups segments by their first path segment and update segment name."
[segments separator]
(reduce (fn [acc segment]
(let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator)
rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))]
(update acc first-segment (fnil conj [])
(if rest-path
(assoc segment :name rest-path)
segment))))
{}
segments))
(defn- sort-and-group-segments
"Sorts elements and groups them by their first path segment."
[segments separator]
(let [sorted (sort-by-children segments separator)
grouped (group-by-first-segment sorted separator)]
grouped))
(defn- build-tree-node
"Builds a single tree node with lazy children."
[segment-name remaining-segments separator parent-path depth]
(let [current-path (if parent-path
(str parent-path "." segment-name)
segment-name)
is-leaf? (and (seq remaining-segments)
(every? (fn [segment]
(let [remaining-segment-name (first (split-path (:name segment) :separator separator))]
(= segment-name remaining-segment-name)))
remaining-segments))
leaf-segment (when is-leaf? (first remaining-segments))
node {:name segment-name
:path current-path
:depth depth
:leaf leaf-segment
:children-fn (when-not is-leaf?
(fn []
(let [grouped-elements (sort-and-group-segments remaining-segments separator)]
(mapv (fn [[child-segment-name remaining-child-segments]]
(build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth)))
grouped-elements))))}]
node))
(defn build-tree-root
"Builds the root level of the tree."
[segments separator]
(let [grouped-elements (sort-and-group-segments segments separator)]
(mapv (fn [[segment-name remaining-segments]]
(build-tree-node segment-name remaining-segments separator nil 0))
grouped-elements)))

View File

@@ -274,7 +274,7 @@
file-id
{file-id file}
file-id))]
(thf/apply-changes file changes)))
(thf/apply-changes file changes :validate? false)))
(defn swap-component
"Swap the specified shape by the component specified by component-tag"
@@ -305,12 +305,13 @@
[changes nil])
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(when new-shape-label
(thi/rm-id! (:id new-shape))
(thi/set-id! new-shape-label (:id new-shape)))
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn swap-component-in-shape [file shape-tag component-tag & {:keys [page-label propagate-fn]}]
@@ -339,9 +340,10 @@
(assoc shape :fills (ths/sample-fills-color :fill-color color)))
(:objects page)
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn update-bottom-color
@@ -357,9 +359,10 @@
(assoc shape :fills (ths/sample-fills-color :fill-color color)))
(:objects page)
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn reset-overrides [file shape & {:keys [page-label propagate-fn]}]
@@ -374,9 +377,10 @@
{file-id file}
(ctn/make-container container :page)
(:id shape)))
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn reset-overrides-in-first-child [file shape-tag & {:keys [page-label propagate-fn]}]
@@ -398,9 +402,10 @@
#{(-> (ths/get-shape file shape-tag :page-label page-label)
:id)}
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn duplicate-shape [file shape-tag & {:keys [page-label propagate-fn]}]
@@ -419,8 +424,9 @@
(:id file)) ;; file-id
(cll/generate-duplicate-changes-update-indices (:objects page) ;; objects
#{(:id shape)}))
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))

View File

@@ -54,12 +54,14 @@
([file] (validate-file! file {}))
([file libraries]
(cfv/validate-file-schema! file)
(cfv/validate-file! file libraries)))
(cfv/validate-file! file libraries)
file))
(defn apply-changes
[file changes]
[file changes & {:keys [validate?] :or {validate? true}}]
(let [file' (ctf/update-file-data file #(cfc/process-changes % (:redo-changes changes) true))]
(validate-file! file')
(when validate?
(validate-file! file'))
file'))
(defn apply-undo-changes

View File

@@ -146,12 +146,15 @@
"Check if some attribute is one that is involved in component syncrhonization.
Note that design tokens also are involved, although they go by an alternate
route and thus they are not part of :sync-attrs.
Also when detaching a nested copy it also needs to trigger a synchronization,
even though :shape-ref is not a synced attribute per se"
Also when detaching a nested copy or it also needs to trigger a synchronization,
even though :shape-ref or :component-id are not synced attribute per se"
[attr]
(or (get sync-attrs attr)
(= :shape-ref attr)
(= :applied-tokens attr)))
(= :applied-tokens attr)
(= :component-id attr)
(= :component-file attr)
(= :component-root attr)))
(defn instance-root?
"Check if this shape is the head of a top instance."

View File

@@ -60,6 +60,9 @@
(some? objects)
(assoc :objects objects)
(nil? objects)
(dissoc :objects)
(some? modified-at)
(assoc :modified-at modified-at)

View File

@@ -465,9 +465,10 @@
page
{(:id file) file}
(thi/id :nested-h-ellipse))
file' (-> (thf/apply-changes file changes)
file' (-> (thf/apply-changes file changes :validate? false)
(tho/propagate-component-changes :c-board-with-ellipse)
(tho/propagate-component-changes :c-big-board))
(tho/propagate-component-changes :c-big-board)
(thf/validate-file!))
;; ==== Get
nested2-h-ellipse (ths/get-shape file' :nested-h-ellipse)

View File

@@ -64,9 +64,8 @@
(reset-all-overrides [file]
(-> file
(tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1)
(tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2)
(propagate-all-component-changes)))
(tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1 :propagate-fn propagate-all-component-changes)
(tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2 :propagate-fn propagate-all-component-changes)))
(fill-colors [file]
[(tho/bottom-fill-color file :frame-ellipse-1 :page-label :page-1)

View File

@@ -56,10 +56,9 @@
(reset-all-overrides [file]
(-> file
(tho/reset-overrides (ths/get-shape file :copy-simple-1))
(tho/reset-overrides (ths/get-shape file :copy-frame-composed-1))
(tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy))
(propagate-all-component-changes)))
(tho/reset-overrides (ths/get-shape file :copy-simple-1 :propagate-fn propagate-all-component-changes))
(tho/reset-overrides (ths/get-shape file :copy-frame-composed-1 :propagate-fn propagate-all-component-changes))
(tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy :propagate-fn propagate-all-component-changes))))
(fill-colors [file]
[(tho/bottom-fill-color file :frame-simple-1)

View File

@@ -6,20 +6,12 @@
(ns common-tests.logic.swap-as-override-test
(:require
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.pprint :as pp]
[app.common.data :as d]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@@ -27,23 +19,40 @@
(defn- setup []
(-> (thf/sample-file :file1)
(tho/add-simple-component :component-1 :frame-component-1 :child-component-1 :child-params {:name "child-component-1" :type :rect :fills (ths/sample-fills-color :fill-color "#111111")})
(tho/add-simple-component :component-2 :frame-component-2 :child-component-2 :child-params {:name "child-component-2" :type :rect :fills (ths/sample-fills-color :fill-color "#222222")})
(tho/add-simple-component :component-3 :frame-component-3 :child-component-3 :child-params {:name "child-component-3" :type :rect :fills (ths/sample-fills-color :fill-color "#333333")})
(tho/add-simple-component :component-1 :frame-component-1 :child-component-1
:root-params {:name "component-1"}
:child-params {:name "child-component-1"
:type :rect
:fills (ths/sample-fills-color :fill-color "#111111")})
(tho/add-simple-component :component-2 :frame-component-2 :child-component-2
:root-params {:name "component-2"}
:child-params {:name "child-component-2"
:type :rect
:fills (ths/sample-fills-color :fill-color "#222222")})
(tho/add-simple-component :component-3 :frame-component-3 :child-component-3
:root-params {:name "component-3"}
:child-params {:name "child-component-3"
:type :rect
:fills (ths/sample-fills-color :fill-color "#333333")})
(tho/add-frame :frame-icon-and-text)
(thc/instantiate-component :component-1 :copy-component-1 :parent-label :frame-icon-and-text :children-labels [:component-1-icon-and-text])
(tho/add-frame :frame-icon-and-text :name "copy-component-1")
(thc/instantiate-component :component-1 :copy-component-1
:parent-label :frame-icon-and-text
:children-labels [:component-1-icon-and-text])
(ths/add-sample-shape :text
{:type :text
:name "icon+text"
:parent-label :frame-icon-and-text})
(thc/make-component :icon-and-text :frame-icon-and-text)
(tho/add-frame :frame-panel)
(thc/instantiate-component :icon-and-text :copy-icon-and-text :parent-label :frame-panel :children-labels [:icon-and-text-panel])
(tho/add-frame :frame-panel :name "icon-and-text")
(thc/instantiate-component :icon-and-text :copy-icon-and-text
:parent-label :frame-panel
:children-labels [:icon-and-text-panel])
(thc/make-component :panel :frame-panel)
(thc/instantiate-component :panel :copy-panel :children-labels [:copy-icon-and-text-panel])))
(thc/instantiate-component :panel :copy-panel
:children-labels [:copy-icon-and-text-panel])))
(defn- propagate-all-component-changes [file]
(-> file

View File

@@ -40,6 +40,7 @@ const setupEmptyTokensFile = async (page, options = {}) => {
tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal,
tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar,
tokenSetItems: workspacePage.tokenSetItems,
tokensSidebar: workspacePage.tokensSidebar,
tokenSetGroupItems: workspacePage.tokenSetGroupItems,
tokenContextMenuForSet: workspacePage.tokenContextMenuForSet,
};
@@ -110,15 +111,12 @@ const checkInputFieldWithError = async (
).toBeVisible();
};
const checkInputFieldWithoutError = async (
tokenThemeUpdateCreateModal,
inputLocator,
) => {
const checkInputFieldWithoutError = async (inputLocator) => {
expect(await inputLocator.getAttribute("aria-invalid")).toBeNull();
expect(await inputLocator.getAttribute("aria-describedby")).toBeNull();
};
async function testTokenCreationFlow(
const testTokenCreationFlow = async (
page,
{
tokenLabel,
@@ -132,7 +130,7 @@ async function testTokenCreationFlow(
resolvedValueText,
secondResolvedValueText,
},
) {
) => {
const invalidValueError = "Invalid token value";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
@@ -242,7 +240,45 @@ async function testTokenCreationFlow(
await expect(
tokensTabPanel.getByRole("button", { name: "my-token-2" }),
).toBeEnabled();
}
};
const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => {
const tokenSegments = tokenName.split(".");
const tokenFolderTree = tokenSegments.slice(0, -1);
const tokenLeafName = tokenSegments.pop();
const typeParentWrapper = tokensTabPanel.getByTestId(`section-${type}`);
const typeSectionButton = typeParentWrapper
.getByRole("button", {
name: type,
})
.first();
const isSectionExpanded =
await typeSectionButton.getAttribute("aria-expanded");
if (isSectionExpanded === "false") {
await typeSectionButton.click();
}
for (const segment of tokenFolderTree) {
const segmentButton = typeParentWrapper
.getByRole("listitem")
.getByRole("button", { name: segment })
.first();
const isExpanded = await segmentButton.getAttribute("aria-expanded");
if (isExpanded === "false") {
await segmentButton.click();
}
}
await expect(
typeParentWrapper.getByRole("button", {
name: tokenLeafName,
}),
).toBeEnabled();
};
test.describe("Tokens: Tokens Tab", () => {
test("Clicking tokens tab button opens tokens sidebar tab", async ({
@@ -398,15 +434,12 @@ test.describe("Tokens: Tokens Tab", () => {
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const addTokenButton = tokensTabPanel.getByRole("button", {
name: `Add Token: Color`,
});
await addTokenButton.click();
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
// Placeholder checks
@@ -471,38 +504,34 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", {
name: "color.primary",
}),
).toBeEnabled();
await unfoldTokenTree(tokensSidebar, "color", "color.primary");
// Create token referencing the previous one with keyboard
await tokensTabPanel
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
await nameField.click();
await nameField.fill("color.secondary");
await nameField.fill("secondary");
await nameField.press("Tab");
await valueField.click();
await valueField.fill("{color.primary}");
await expect(submitButton).toBeEnabled();
await nameField.press("Enter");
await submitButton.press("Enter");
await expect(
tokensTabPanel.getByRole("button", {
name: "color.secondary",
tokensSidebar.getByRole("button", {
name: "secondary",
}),
).toBeEnabled();
// Tokens tab panel should have two tokens with the color red / #ff0000
await expect(
tokensTabPanel.getByRole("button", { name: "#ff0000" }),
tokensSidebar.getByRole("button", { name: "#ff0000" }),
).toHaveCount(2);
// Global set has been auto created and is active
@@ -518,7 +547,7 @@ test.describe("Tokens: Tokens Tab", () => {
).toHaveAttribute("aria-checked", "true");
// Check color picker
await tokensTabPanel
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
@@ -1079,7 +1108,7 @@ test.describe("Tokens: Tokens Tab", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, {flags: ["enable-token-shadow"]});
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1507,24 +1536,15 @@ test.describe("Tokens: Tokens Tab", () => {
test("User edits token and auto created set show up in the sidebar", async ({
page,
}) => {
const {
workspacePage,
tokensUpdateCreateModal,
tokenThemesSetsSidebar,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page);
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
const tokensColorGroup = tokensSidebar.getByRole("button", {
name: "Color 92",
});
await expect(tokensColorGroup).toBeVisible();
await tokensColorGroup.click();
await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
name: "100",
});
await expect(colorToken).toBeVisible();
await colorToken.click({ button: "right" });
@@ -1541,8 +1561,10 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(tokensUpdateCreateModal).not.toBeVisible();
await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100.changed");
const colorTokenChanged = tokensSidebar.getByRole("button", {
name: "colors.blue.100.changed",
name: "changed",
});
await expect(colorTokenChanged).toBeVisible();
});
@@ -1633,11 +1655,10 @@ test.describe("Tokens: Tokens Tab", () => {
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
@@ -1649,7 +1670,7 @@ test.describe("Tokens: Tokens Tab", () => {
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("color.dark.primary");
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
@@ -1660,7 +1681,9 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(tokensTabPanel.getByLabel("color.dark.primary")).toBeEnabled();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
@@ -1676,7 +1699,6 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
@@ -1686,7 +1708,7 @@ test.describe("Tokens: Tokens Tab", () => {
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("color.primary");
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
@@ -1704,7 +1726,6 @@ test.describe("Tokens: Tokens Tab", () => {
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await valueField.click();
@@ -1754,15 +1775,10 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(tokensSidebar).toBeVisible();
const tokensColorGroup = tokensSidebar.getByRole("button", {
name: "Color 92",
});
await expect(tokensColorGroup).toBeVisible();
await tokensColorGroup.click();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
name: "100",
});
await colorToken.click({ button: "right" });
@@ -1782,15 +1798,10 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(tokensSidebar).toBeVisible();
const tokensColorGroup = tokensSidebar.getByRole("button", {
name: "Color 92",
});
await expect(tokensColorGroup).toBeVisible();
await tokensColorGroup.click();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
name: "100",
});
await expect(colorToken).toBeVisible();
await colorToken.click({ button: "right" });
@@ -1803,8 +1814,7 @@ test.describe("Tokens: Tokens Tab", () => {
});
test("User fold/unfold color tokens", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
const { tokensSidebar } = await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1814,8 +1824,10 @@ test.describe("Tokens: Tokens Tab", () => {
await expect(tokensColorGroup).toBeVisible();
await tokensColorGroup.click();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
name: "100",
});
await expect(colorToken).toBeVisible();
await tokensColorGroup.click();
@@ -2218,13 +2230,10 @@ test.describe("Tokens: Apply token", () => {
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await tokensSidebar
.getByRole("button")
.filter({ hasText: "Color" })
.click();
unfoldTokenTree(tokensSidebar, "color", "colors.black");
await tokensSidebar
.getByRole("button", { name: "colors.black" })
.getByRole("button", { name: "black" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Fill").click();
@@ -2462,7 +2471,7 @@ test.describe("Tokens: Apply token", () => {
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("shadow.primary");
await nameField.fill("primary");
// User adds first shadow with a color from the color ramp
const firstShadowFields = tokensUpdateCreateModal.getByTestId(
@@ -2709,9 +2718,11 @@ test.describe("Tokens: Apply token", () => {
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
unfoldTokenTree(tokensSidebar, "shadow", "primary");
// Verify token appears in sidebar
const shadowToken = tokensSidebar.getByRole("button", {
name: "shadow.primary",
name: "primary",
});
await expect(shadowToken).toBeEnabled();

View File

@@ -0,0 +1,49 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.ds.layers.layer-button
(:require-macros
[app.main.style :as stl])
(:require
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[rumext.v2 :as mf]))
(def ^:private schema:layer-button
[:map
[:label :string]
[:description {:optional true} [:maybe :string]]
[:class {:optional true} :string]
[:expandable {:optional true} :boolean]
[:expanded {:optional true} :boolean]
[:icon {:optional true} :string]
[:on-toggle-expand fn?]])
(mf/defc layer-button*
{::mf/schema schema:layer-button}
[{:keys [label description class is-expandable expanded icon on-toggle-expand children] :rest props}]
(let [button-props (mf/spread-props props
{:class [class (stl/css-case :layer-button true
:layer-button--expandable is-expandable
:layer-button--expanded expanded)]
:type "button"
:on-click on-toggle-expand})]
[:div {:class (stl/css :layer-button-wrapper)}
[:> "button" button-props
[:div {:class (stl/css :layer-button-content)}
(when is-expandable
(if expanded
[:> icon* {:icon-id i/arrow-down :class (stl/css :folder-node-icon)}]
[:> icon* {:icon-id i/arrow-right :class (stl/css :folder-node-icon)}]))
(when icon
[:> icon* {:icon-id icon :class (stl/css :layer-button-icon)}])
[:span {:class (stl/css :layer-button-name)}
label]
(when description
[:span {:class (stl/css :layer-button-description)}
description])
[:span {:class (stl/css :layer-button-quantity)}]]]
[:div {:class (stl/css :layer-button-actions)}
children]]))

View File

@@ -0,0 +1,56 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/colors.scss" as *;
.layer-button-wrapper {
--layer-button-block-size: #{$sz-32};
--layer-button-background: var(--color-background-primary);
--layer-button-text: var(--color-foreground-secondary);
display: flex;
justify-content: space-between;
block-size: var(--layer-button-block-size);
background: var(--layer-button-background);
color: var(--layer-button-text);
}
.layer-button {
@include use-typography("body-small");
appearance: none;
flex: 1;
display: flex;
align-items: center;
border: none;
background: none;
color: inherit;
}
.layer-button--expanded {
& .layer-button-name {
color: var(--color-foreground-primary);
}
}
.layer-button-content {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
.layer-button-description {
padding: var(--sp-xs);
background-color: var(--color-background-tertiary);
border-radius: $br-6;
}

View File

@@ -44,6 +44,39 @@
[(seq (array/sort! empty))
(seq (array/sort! filled))]))))
(mf/defc selected-set-info*
{::mf/private true}
[{:keys [tokens-lib selected-token-set-id]}]
(let [selected-token-set
(mf/with-memo [tokens-lib]
(when selected-token-set-id
(some-> tokens-lib (ctob/get-set selected-token-set-id))))
active-token-sets-names
(mf/with-memo [tokens-lib]
(some-> tokens-lib (ctob/get-active-themes-set-names)))
token-set-active?
(mf/use-fn
(mf/deps active-token-sets-names)
(fn [name]
(contains? active-token-sets-names name)))]
[:div {:class (stl/css :sets-header-container)}
[:> text* {:as "span"
:typography "headline-small"
:class (stl/css :sets-header)}
(tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
;; NOTE: when no set in tokens-lib, the selected-token-set-id
;; will be `nil`, so for properly hide the inactive message we
;; check that at least `selected-token-set-id` has a value
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:*
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
(tr "workspace.tokens.inactive-set")]])]]))
(mf/defc tokens-section*
{::mf/private true}
[{:keys [tokens-lib active-tokens resolved-active-tokens]}]
@@ -65,9 +98,7 @@
selected-token-set-id
(mf/deref refs/selected-token-set-id)
selected-token-set
(when selected-token-set-id
(some-> tokens-lib (ctob/get-set selected-token-set-id)))
;; If we have not selected any set explicitly we just
;; select the first one from the list of sets
@@ -92,15 +123,9 @@
tokens)]
(ctob/group-by-type tokens)))
active-token-sets-names
(mf/with-memo [tokens-lib]
(some-> tokens-lib (ctob/get-active-themes-set-names)))
token-set-active?
(mf/use-fn
(mf/deps active-token-sets-names)
(fn [name]
(contains? active-token-sets-names name)))
[empty-group filled-group]
(mf/with-memo [tokens-by-type]
@@ -118,34 +143,27 @@
[:*
[:& token-context-menu]
[:div {:class (stl/css :sets-header-container)}
[:> text* {:as "span" :typography "headline-small" :class (stl/css :sets-header)} (tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
;; NOTE: when no set in tokens-lib, the selected-token-set-id
;; will be `nil`, so for properly hide the inactive message we
;; check that at least `selected-token-set-id` has a value
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:*
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
(tr "workspace.tokens.inactive-set")]])]]
[:& selected-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]
(for [type filled-group]
(let [tokens (get tokens-by-type type)]
[:> token-group* {:key (name type)
:is-open (get open-status type false)
:tokens tokens
:is-expanded (get open-status type false)
:type type
:selected-ids selected
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:tokens tokens}]))
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]))
(for [type empty-group]
[:> token-group* {:key (name type)
:tokens []
:type type
:selected-shapes selected-shapes
:is-selected-inside-layout :is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:tokens []}])]))
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens}])]))

View File

@@ -8,6 +8,9 @@
(ns app.main.ui.workspace.tokens.management.group
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.tokens-lib :as ctob]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
@@ -16,51 +19,70 @@
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-tree :refer [token-tree*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(defn token-section-icon
[type]
(case type
:border-radius "corner-radius"
:color "drop"
:boolean "boolean-difference"
:font-family "text-font-family"
:font-size "text-font-size"
:letter-spacing "text-letterspacing"
:text-case "text-mixed"
:text-decoration "text-underlined"
:font-weight "text-font-weight"
:typography "text-typography"
:opacity "percentage"
:number "number"
:rotation "rotation"
:spacing "padding-extended"
:string "text-mixed"
:stroke-width "stroke-size"
:dimensions "expand"
:sizing "expand"
:shadow "drop-shadow"
:border-radius i/corner-radius
:color i/drop
:boolean i/boolean-difference
:font-family i/text-font-family
:font-size i/text-font-size
:letter-spacing i/text-letterspacing
:text-case i/text-mixed
:text-decoration i/text-underlined
:font-weight i/text-font-weight
:typography i/text-typography
:opacity i/percentage
:number i/number
:rotation i/rotation
:spacing i/padding-extended
:string i/text-mixed
:stroke-width i/stroke-size
:dimensions i/expand
:sizing i/expand
:shadow i/drop-shadow
"add"))
(def ^:private schema:token-group
[:map
[:type :keyword]
[:tokens :any]
[:selected-shapes :any]
[:is-selected-inside-layout {:optional true} [:maybe :boolean]]
[:active-theme-tokens {:optional true} :any]
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
(mf/defc token-group*
{::mf/private true}
[{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens is-open selected-ids]}]
{::mf/schema schema:token-group}
[{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib is-expanded selected-ids]}]
(let [{:keys [modal title]}
(get dwta/token-properties type)
editing-ref (mf/deref refs/workspace-editor-state)
not-editing? (empty? editing-ref)
is-expanded (d/nilv is-expanded false)
can-edit?
(mf/use-ctx ctx/can-edit?)
is-selected-inside-layout (d/nilv is-selected-inside-layout false)
tokens
(mf/with-memo [tokens]
(vec (sort-by :name tokens)))
expandable? (d/nilv (seq tokens) false)
on-context-menu
(mf/use-fn
(fn [event token]
@@ -73,8 +95,8 @@
on-toggle-open-click
(mf/use-fn
(mf/deps is-open type)
#(st/emit! (dwtl/set-token-type-section-open type (not is-open))))
(mf/deps is-expanded type)
#(st/emit! (dwtl/set-token-type-section-open type (not is-expanded))))
on-popover-open-click
(mf/use-fn
@@ -96,33 +118,36 @@
(mf/use-fn
(mf/deps not-editing? selected-ids)
(fn [event token]
(dom/stop-propagation event)
(when (and not-editing? (seq selected-shapes) (not= (:type token) :number))
(st/emit! (dwta/toggle-token {:token token
:shape-ids selected-ids})))))]
(let [token (ctob/get-token tokens-lib selected-token-set-id (:id token))]
(dom/stop-propagation event)
(when (and not-editing? (seq selected-shapes) (not= (:type token) :number))
(st/emit! (dwta/toggle-token {:token token
:shape-ids selected-ids}))))))]
[:div {:on-click on-toggle-open-click :class (stl/css :token-section-wrapper)}
[:> cmm/asset-section* {:icon (token-section-icon type)
:title title
:section :tokens
:assets-count (count tokens)
:is-open is-open}
[:> cmm/asset-section-block* {:role :title-button}
(when can-edit?
[:> icon-button* {:on-click on-popover-open-click
:variant "ghost"
:icon i/add
:id (str "add-token-button-" title)
:aria-label (tr "workspace.tokens.add-token" title)}])]
(when is-open
[:> cmm/asset-section-block* {:role :content}
[:div {:class (stl/css :token-pills-wrapper)}
(for [token tokens]
[:> token-pill*
{:key (:name token)
:token token
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])]])]]))
[:div {:class (stl/css :token-section-wrapper)
:data-testid (dm/str "section-" (name type))}
[:> layer-button* {:label title
:expanded is-expanded
:description (when expandable? (dm/str (count tokens)))
:is-expandable expandable?
:aria-expanded is-expanded
:aria-controls (dm/str "token-tree-" (name type))
:on-toggle-expand on-toggle-open-click
:icon (token-section-icon type)}
(when can-edit?
[:> icon-button* {:id (str "add-token-button-" title)
:icon "add"
:aria-label (tr "workspace.tokens.add-token" title)
:variant "ghost"
:on-click on-popover-open-click
:class (stl/css :token-section-icon)}])]
(when is-expanded
[:> token-tree* {:tokens tokens
:id (dm/str "token-tree-" (name type))
:tokens-lib tokens-lib
:selected-shapes selected-shapes
:active-theme-tokens active-theme-tokens
:selected-token-set-id selected-token-set-id
:is-selected-inside-layout is-selected-inside-layout
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu}])]))

View File

@@ -1,11 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
.token-pills-wrapper {
display: flex;
gap: var(--sp-xs);
flex-wrap: wrap;
}

View File

@@ -307,10 +307,9 @@
:class (stl/css :token-pill-icon)}])
(if contains-path?
(let [[first-part last-part] (cpn/split-by-last-period name)]
(let [[_ last-part] (cpn/split-by-last-period name)]
[:span {:class (stl/css :divided-name-wrapper)
:aria-label name}
[:span {:class (stl/css :first-name-wrapper)} first-part]
[:span {:class (stl/css :last-name-wrapper)} last-part]])
[:span {:class (stl/css :name-wrapper)
:aria-label name}

View File

@@ -0,0 +1,110 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.token-tree
(:require-macros [app.main.style :as stl])
(:require
[app.common.path-names :as cpn]
[app.common.types.tokens-lib :as ctob]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
[rumext.v2 :as mf]))
(def ^:private schema:folder-node
[:map
[:node :any]
[:selected-shapes :any]
[:is-selected-inside-layout {:optional true} :boolean]
[:active-theme-tokens {:optional true} :any]
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
(mf/defc folder-node*
{::mf/schema schema:folder-node}
[{:keys [node selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib on-token-pill-click on-context-menu]}]
(let [expanded* (mf/use-state false)
expanded (deref expanded*)
swap-folder-expanded #(swap! expanded* not)]
[:li {:class (stl/css :folder-node)}
[:> layer-button* {:label (:name node)
:expanded expanded
:aria-expanded expanded
:aria-controls (str "folder-children-" (:path node))
:is-expandable (not (:leaf node))
:on-toggle-expand swap-folder-expanded}]
(when expanded
(let [children-fn (:children-fn node)]
[:div {:class (stl/css :folder-children-wrapper)
:id (str "folder-children-" (:path node))}
(when children-fn
(let [children (children-fn)]
(for [child children]
(if (not (:leaf child))
[:ul {:class (stl/css :node-parent)}
[:> folder-node* {:key (:path child)
:node child
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child))
token (ctob/get-token tokens-lib selected-token-set-id id)]
[:> token-pill*
{:key id
:token token
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])))))]))]))
(def ^:private schema:token-tree
[:map
[:tokens :any]
[:selected-shapes :any]
[:is-selected-inside-layout {:optional true} :boolean]
[:active-theme-tokens {:optional true} :any]
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
(mf/defc token-tree*
{::mf/schema schema:token-tree}
[{:keys [tokens selected-shapes is-selected-inside-layout active-theme-tokens tokens-lib selected-token-set-id on-token-pill-click on-context-menu]}]
(let [separator "."
tree (mf/use-memo
(mf/deps tokens)
(fn []
(cpn/build-tree-root tokens separator)))]
[:div {:class (stl/css :token-tree-wrapper)}
(for [node tree]
[:ul {:class (stl/css :node-parent)
:key (:path node)
:style {:--node-depth (inc (:depth node))}}
(if (:leaf node)
(let [token (ctob/get-token tokens-lib selected-token-set-id (get-in node [:leaf :id]))]
[:> token-pill*
{:token token
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])
;; Render segment folder
[:> folder-node* {:node node
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}])])]))

View File

@@ -0,0 +1,39 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_borders.scss" as *;
.token-tree-wrapper {
padding-block-end: var(--sp-s);
}
.node-parent {
--node-spacing: var(--sp-l);
--node-depth: 0;
margin-block-end: 0;
padding-inline-start: calc(var(--node-spacing) * var(--node-depth));
}
.folder-children-wrapper:has(> button) {
margin-inline-start: var(--sp-s);
padding-inline-start: var(--sp-s);
border-inline-start: $b-2 solid var(--color-background-quaternary);
display: flex;
flex-wrap: wrap;
column-gap: var(--sp-xs);
& .node-parent {
flex: 1 0 100%;
&:last-of-type {
margin-block-end: var(--sp-s);
}
}
& .token-pill {
flex: 0 0 auto;
}
}