♻️ Extract the token pill state and tooltip from its component (#12691)

The token pill worked out inside the component whether its token is
applied to the selection, whether it can apply to it at all and why
the token is invalid, so no test could reach that logic. Move it into
`pill-state`, and split the tooltip into `tooltip-info`, which decides
what it says, and `render-tooltip`, which renders it. Both are pure
and get unit tests for the applied, partial, disabled and error states
and each tooltip case.

The two chained `on-click` handlers become one. Nothing else changes
in behavior.

Signed-off-by: Florian Schroedl <flo.schroedl@gmail.com>
This commit is contained in:
Florian Schrödl 2026-11-04 10:56:50 +01:00 committed by GitHub
parent a089f7a5a1
commit e6153b61a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 347 additions and 133 deletions

View File

@ -109,61 +109,59 @@
(str/join ", " (map attribute-dictionary values)) ".")))
grouped-values)))
(defn- generate-tooltip
"Generates a tooltip for a given token"
[is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set error-message]
(let [{:keys [name type resolved-value value]} token
resolved-value-theme (:resolved-value theme-token)
resolved-value (or resolved-value-theme resolved-value)
{:keys [title] :as token-props} (dwta/get-token-properties theme-token)
(defn- tooltip-info
"For an invalid token the tooltip is only its error message. Otherwise
it shows the token's name and values and, for viewers, the attributes
of `shape` the token is applied to."
[token resolved-token shape state is-viewer]
(let [{:keys [name type value]} token
{:keys [is-half-applied has-missing-reference has-invalid-value
error-message]} state
resolved-value (or (:resolved-value resolved-token) (:resolved-value token))
{:keys [title] :as token-props} (dwta/get-token-properties resolved-token)
applied-tokens (:applied-tokens shape)
app-token-vals (set (vals applied-tokens))
app-token-keys (keys applied-tokens)
is-applied? (contains? app-token-vals name)
applied-to (if half-applied
(partially-applied-attr app-token-keys is-applied? token-props)
(tr "labels.all"))
grouped-values (group-by dimensions-dictionary app-token-keys)
name-line
(mf/html [:* (dm/str (tr "workspace.tokens.token-name") ": ")
[:span {:class (stl/css :token-pill-tooltip-name)} name]])
rest-of-title (dm/str (tr "workspace.tokens.original-value" (dwtf/format-token-value value)) "\n"
(tr "workspace.tokens.resolved-value" (dwtf/format-token-value resolved-value))
(when (= (:type token) :number)
(dm/str "\n" (tr "workspace.tokens.more-options"))))]
is-applied (contains? (set (vals applied-tokens)) name)]
(cond
;; If there are errors, show the appropriate message
ref-not-in-active-set
(tr "workspace.tokens.ref-not-valid" name)
has-missing-reference
{:message (tr "workspace.tokens.ref-not-valid" name)}
error-message
error-message
{:message error-message}
no-valid-value
(tr "workspace.tokens.value-not-valid")
has-invalid-value
{:message (tr "workspace.tokens.value-not-valid")}
;; If the token is applied and the user is a is-viewer, show the details
(and is-applied? is-viewer)
(mf/html
[:*
name-line
[:div rest-of-title]
[:div (tr "workspace.tokens.applied-to")]
[:div (if (= :dimensions type)
(translate-and-format grouped-values)
(str "- " title ": " applied-to))]])
;; Otherwise only show the base title
:else
{:name name
:value (dwtf/format-token-value value)
:resolved-value (dwtf/format-token-value resolved-value)
:has-more-options (= type :number)
:applied-to
(when (and is-applied is-viewer)
(if (= :dimensions type)
(translate-and-format (group-by dimensions-dictionary app-token-keys))
(str "- " title ": "
(if is-half-applied
(partially-applied-attr app-token-keys is-applied token-props)
(tr "labels.all")))))})))
(defn- render-tooltip
[{:keys [message name value resolved-value has-more-options applied-to]}]
(or message
(mf/html
[:*
name-line
[:div rest-of-title]]))))
(dm/str (tr "workspace.tokens.token-name") ": ")
[:span {:class (stl/css :token-pill-tooltip-name)} name]
[:div (dm/str (tr "workspace.tokens.original-value" value) "\n"
(tr "workspace.tokens.resolved-value" resolved-value)
(when has-more-options
(dm/str "\n" (tr "workspace.tokens.more-options"))))]
(when applied-to
[:*
[:div (tr "workspace.tokens.applied-to")]
[:div applied-to]])])))
;; FIXME: the token thould already have precalculated references, so
;; we don't need to perform this regex operation on each rerender
@ -195,12 +193,69 @@
(def token-types-with-status-icon
#{:color :border-radius :rotation :sizing :dimensions :opacity :spacing :stroke-width})
(defn- pill-state
"Works out whether the token is applied to the selection, partially
applied, disabled or invalid. Without a selection it counts as fully
applied."
[token active-theme-tokens selected-shapes is-selected-inside-layout]
(let [{:keys [name value type]} token
errors (:errors (get active-theme-tokens name))
has-selected (seq selected-shapes)
tokenscript (contains? cf/flags :tokenscript)
attributes
(as-> (get dwta/token-properties type) $
(d/nilv (:all-attributes $) (:attributes $)))
full-applied
(or (not has-selected)
(applied-all-attributes? token selected-shapes attributes))
applied
(boolean (and has-selected
(cfo/shapes-token-applied? token selected-shapes attributes)))
half-applied (and applied (not full-applied))
missing-reference
(boolean
(if tokenscript
(wte/has-error-code? :error.style-dictionary/missing-reference errors)
(and (cfo/is-reference? token)
(not (contains-reference-value? value active-theme-tokens)))))
name-collision
(= :error.token/name-collision (:error/code (first errors)))
invalid-value (boolean (seq errors))]
{:is-applied applied
:is-half-applied half-applied
:is-disabled
(boolean
(and has-selected
(not applied)
(not (attributes-match-selection?
selected-shapes attributes
{:selected-inside-layout? is-selected-inside-layout}))))
:has-missing-reference missing-reference
:has-invalid-value invalid-value
;; Tokenscript errors carry their own message, as in the edit form.
:error-message
(when (and invalid-value (or tokenscript name-collision))
(wte/resolve-error-message (first errors)))
:has-errors (or missing-reference invalid-value)
:status-icon-id
(cond
half-applied "token-status-partial"
full-applied "token-status-full"
:else "token-status-non-applied")}))
(mf/defc token-pill*
{::mf/wrap [mf/memo]}
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens can-edit]}]
(let [{:keys [name value type]} token
resolved-token (get active-theme-tokens (:name token))
errors (:errors resolved-token)
(let [{:keys [name type]} token
resolved-token (get active-theme-tokens name)
pill-ref (mf/use-ref nil)
pill-id (mf/use-id)
@ -208,59 +263,14 @@
tooltip-content* (mf/use-state "")
tooltip-content (deref tooltip-content*)
has-selected? (pos? (count selected-shapes))
is-reference? (cfo/is-reference? token)
contains-path? (str/includes? name ".")
is-viewer? (not can-edit)
attributes (as-> (get dwta/token-properties type) $
(d/nilv (:all-attributes $) (:attributes $)))
full-applied?
(if has-selected?
(applied-all-attributes? token selected-shapes attributes)
true)
applied?
(if has-selected?
(cfo/shapes-token-applied? token selected-shapes attributes)
false)
half-applied?
(and applied? (not full-applied?))
disabled? (and
has-selected?
(not applied?)
(not half-applied?)
(not (attributes-match-selection? selected-shapes attributes {:selected-inside-layout? is-selected-inside-layout})))
is-viewer? (not can-edit)
tokenscript? (contains? cf/flags :tokenscript)
ref-not-in-active-set
(if tokenscript?
(wte/has-error-code? :error.style-dictionary/missing-reference errors)
(and is-reference?
(not (contains-reference-value? value active-theme-tokens))))
name-collision (->> errors
(first)
(:error/code)
(= :error.token/name-collision))
no-valid-value (seq errors)
;; Tokenscript errors carry their own message, as in the edit form.
error-message
(when (and no-valid-value (or tokenscript? name-collision))
(wte/resolve-error-message (first errors)))
errors?
(or ref-not-in-active-set
no-valid-value
name-collision)
{:keys [is-applied is-disabled has-invalid-value error-message
has-errors status-icon-id]
:as state}
(mf/with-memo [token active-theme-tokens selected-shapes is-selected-inside-layout]
(pill-state token active-theme-tokens selected-shapes is-selected-inside-layout))
color
(when (cfo/color-token? token)
@ -269,23 +279,6 @@
status-icon? (contains? token-types-with-status-icon type)
on-click
(mf/use-fn
(mf/deps errors? on-click token)
(fn [event]
(dom/stop-propagation event)
(when (and (not (seq errors)) on-click)
(on-click event token))))
token-status-id
(cond
half-applied?
"token-status-partial"
full-applied?
"token-status-full"
:else
"token-status-non-applied")
on-context-menu
(mf/use-fn
(mf/deps can-edit on-context-menu token)
@ -296,20 +289,19 @@
on-click
(mf/use-fn
(mf/deps errors? on-click can-edit)
(mf/deps can-edit has-invalid-value on-click token)
(fn [event]
(dom/stop-propagation event)
(when (and can-edit (not (seq errors)) on-click)
(on-click event))))
(when (and can-edit (not has-invalid-value) on-click)
(on-click event token))))
on-hover
(mf/use-fn
(mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set error-message)
(mf/deps token resolved-token selected-shapes state is-viewer?)
(fn [_]
(let [theme-token (get active-theme-tokens name)
title (generate-tooltip is-viewer? (first selected-shapes) theme-token token
half-applied? no-valid-value ref-not-in-active-set error-message)]
(reset! tooltip-content* title))))
(->> (tooltip-info token resolved-token (first selected-shapes) state is-viewer?)
(render-tooltip)
(reset! tooltip-content*))))
;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped
;; in a function to satisfy the tooltip's `:content` schema; this
@ -326,20 +318,20 @@
[:button {:ref pill-ref
:class (stl/css-case
:token-pill true
:token-pill-no-icon (and (not status-icon?) (not errors?))
:token-pill-no-icon (and (not status-icon?) (not has-errors))
:token-pill-default can-edit
:token-pill-disabled disabled?
:token-pill-applied (and can-edit applied?)
:token-pill-invalid (and can-edit errors?)
:token-pill-invalid-applied (and applied? errors? can-edit)
:token-pill-disabled is-disabled
:token-pill-applied (and can-edit is-applied)
:token-pill-invalid (and can-edit has-errors)
:token-pill-invalid-applied (and is-applied has-errors can-edit)
:token-pill-viewer is-viewer?
:token-pill-applied-viewer (and is-viewer?
applied?)
is-applied)
:token-pill-invalid-viewer (and is-viewer?
errors?)
has-errors)
:token-pill-invalid-applied-viewer (and is-viewer?
applied?))
is-applied))
:id (str "token-pill-" (:id token))
:type "button"
:on-focus on-hover
@ -349,7 +341,7 @@
:on-context-menu on-context-menu}
(cond
errors?
has-errors
[:> icon*
{:icon-id i/broken-link
:class (stl/css :token-pill-icon)
@ -362,7 +354,7 @@
status-icon?
[:> token-status-icon*
{:icon-id token-status-id
{:icon-id status-icon-id
:class (stl/css :token-pill-icon)}])
(if contains-path?

View File

@ -115,6 +115,7 @@
[frontend-tests.tokens.style-dictionary-test]
[frontend-tests.tokens.token-errors-test]
[frontend-tests.tokens.token-path-test]
[frontend-tests.tokens.token-pill-test]
[frontend-tests.tokens.tokenscript-test]
[frontend-tests.tokens.workspace-tokens-remap-test]
[frontend-tests.ui.check-updates-test]
@ -287,6 +288,7 @@
'frontend-tests.tokens.style-dictionary-test
'frontend-tests.tokens.token-errors-test
'frontend-tests.tokens.token-path-test
'frontend-tests.tokens.token-pill-test
'frontend-tests.tokens.tokenscript-test
'frontend-tests.tokens.logic.tokens-source-test
'frontend-tests.tokens.logic.tokens-status-test

View File

@ -0,0 +1,220 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.tokens.token-pill-test
(:require
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.ui.workspace.tokens.management.token-pill :as wtp]
[app.util.i18n :refer [tr]]
[cljs.test :as t :include-macros true]))
(t/use-fixtures :each thi/test-fixture)
(def ^:private pill-state @#'wtp/pill-state)
(def ^:private tooltip-info @#'wtp/tooltip-info)
(def ^:private radius
(ctob/make-token :name "radius.md" :type :border-radius :value "8"))
(def ^:private spacing
(ctob/make-token :name "spacing.md" :type :spacing :value "16"))
(def ^:private width
(ctob/make-token :name "width.md" :type :dimensions :value "100"))
(def ^:private active-theme-tokens
{"radius.md" (assoc radius :resolved-value 8)
"spacing.md" (assoc spacing :resolved-value 16)
"width.md" (assoc width :resolved-value 100)})
(defn- with-token
[shape token attributes]
(cto/apply-token-to-shape {:shape shape :token token :attributes attributes}))
(defn- state
[token selected-shapes & {:keys [tokens is-selected-inside-layout]
:or {tokens active-theme-tokens}}]
(pill-state token tokens selected-shapes is-selected-inside-layout))
(defn- with-flags
[flags f]
(with-redefs [cf/flags flags]
(f)))
(def ^:private no-tokenscript-flags (disj cf/flags :tokenscript))
(def ^:private tokenscript-flags (conj cf/flags :tokenscript))
(t/deftest applied-status
(t/testing "without a selection the token counts as fully applied"
(t/is (= {:is-applied false
:is-half-applied false
:is-disabled false
:status-icon-id "token-status-full"}
(select-keys (state radius [])
[:is-applied :is-half-applied :is-disabled :status-icon-id]))))
(t/testing "applied to every attribute of every selected shape"
(let [result (state radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys)
(with-token (ths/sample-shape :rect2) radius cto/border-radius-keys)])]
(t/is (true? (:is-applied result)))
(t/is (false? (:is-half-applied result)))
(t/is (= "token-status-full" (:status-icon-id result)))))
(t/testing "applied to only some of the attributes"
(let [result (state radius [(with-token (ths/sample-shape :rect1) radius #{:r1})])]
(t/is (true? (:is-applied result)))
(t/is (true? (:is-half-applied result)))
(t/is (= "token-status-partial" (:status-icon-id result)))))
(t/testing "applied to only some of the selected shapes"
(let [result (state radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys)
(ths/sample-shape :rect2)])]
(t/is (true? (:is-half-applied result)))
(t/is (= "token-status-partial" (:status-icon-id result)))))
(t/testing "another token on the same attributes does not count"
(let [other (ctob/make-token :name "radius.lg" :type :border-radius :value "16")
result (state radius [(with-token (ths/sample-shape :rect1) other #{:r1})])]
(t/is (false? (:is-applied result)))
(t/is (= "token-status-non-applied" (:status-icon-id result)))))
(t/testing "color tokens count all their attributes, not only the fill"
(let [color (ctob/make-token :name "brand" :type :color :value "#ff0000")]
(t/is (true? (:is-half-applied
(state color [(with-token (ths/sample-shape :rect1) color #{:fill})]
:tokens {"brand" color}))))
(t/is (false? (:is-half-applied
(state color [(with-token (ths/sample-shape :rect1) color #{:fill :stroke-color})]
:tokens {"brand" color})))))))
(t/deftest disabled-status
(t/testing "a token that applies to none of the selected shapes is disabled"
(t/is (true? (:is-disabled (state radius [(ths/sample-shape :circle1 :type :circle)]))))
(t/is (true? (:is-disabled (state spacing [(ths/sample-shape :rect1)])))))
(t/testing "one shape the token applies to is enough"
(t/is (false? (:is-disabled (state radius [(ths/sample-shape :circle1 :type :circle)
(ths/sample-shape :rect1)])))))
(t/testing "spacing applies to boards with a layout"
(t/is (false? (:is-disabled (state spacing [(ths/sample-shape :board1 :type :frame :layout :flex)])))))
(t/testing "spacing applies as margin to shapes inside a layout"
(t/is (false? (:is-disabled (state spacing [(ths/sample-shape :rect1)]
:is-selected-inside-layout true)))))
(t/testing "an applied token is never disabled"
(t/is (false? (:is-disabled (state radius [(with-token (ths/sample-shape :circle1 :type :circle)
radius cto/border-radius-keys)]))))))
(t/deftest error-status
(t/testing "a valid token has no errors"
(let [result (state radius [])]
(t/is (false? (:has-errors result)))
(t/is (nil? (:error-message result)))))
(t/testing "a reference to a token outside the active sets"
(with-flags no-tokenscript-flags
#(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.missing}")
result (state token [])]
(t/is (true? (:has-missing-reference result)))
(t/is (false? (:has-invalid-value result)))
(t/is (true? (:has-errors result)))
(t/is (nil? (:error-message result)))))
(with-flags no-tokenscript-flags
#(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.md}")]
(t/is (false? (:has-errors (state token []))))))
(with-flags tokenscript-flags
#(let [error {:error/code :error.style-dictionary/missing-reference
:message "Missing reference"}
token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.missing}")
result (state token [] :tokens {"radius.ref" (assoc token :errors [error])})]
(t/is (true? (:has-missing-reference result)))
(t/is (true? (:has-invalid-value result)))
(t/is (= "Missing reference" (:error-message result))))))
(t/testing "an invalid value only shows its own message with tokenscript"
(let [error {:error/code :error.style-dictionary/invalid-token-value
:message "Invalid value"}
tokens {"radius.md" (assoc radius :errors [error])}]
(with-flags no-tokenscript-flags
#(let [result (state radius [] :tokens tokens)]
(t/is (true? (:has-invalid-value result)))
(t/is (true? (:has-errors result)))
(t/is (nil? (:error-message result)))))
(with-flags tokenscript-flags
#(t/is (= "Invalid value" (:error-message (state radius [] :tokens tokens)))))))
(t/testing "a name collision shows its message"
(with-flags no-tokenscript-flags
#(let [error {:error/code :error.token/name-collision
:error/value "radius.md"
:error/fn (fn [value] (str "Collides: " value))}
result (state radius [] :tokens {"radius.md" (assoc radius :errors [error])})]
(t/is (true? (:has-errors result)))
(t/is (= "Collides: radius.md" (:error-message result)))))))
(defn- tooltip
[token shapes & {:keys [tokens is-viewer] :or {tokens active-theme-tokens}}]
(tooltip-info token (get tokens (:name token)) (first shapes)
(pill-state token tokens shapes false)
is-viewer))
(t/deftest tooltip-messages
(t/testing "errors replace the token details"
(with-flags no-tokenscript-flags
#(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.missing}")]
(t/is (= {:message (tr "workspace.tokens.ref-not-valid" "radius.ref")}
(tooltip token [])))))
(with-flags no-tokenscript-flags
#(let [error {:error/code :error.style-dictionary/invalid-token-value}]
(t/is (= {:message (tr "workspace.tokens.value-not-valid")}
(tooltip radius [] :tokens {"radius.md" (assoc radius :errors [error])}))))))
(t/testing "a valid token shows its value and resolved value"
(t/is (= {:name "radius.md"
:value "8"
:resolved-value "8"
:has-more-options false
:applied-to nil}
(tooltip radius []))))
(t/testing "the resolved value comes from the active themes"
(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.md}")]
(t/is (= "8" (:resolved-value
(tooltip token [] :tokens (assoc active-theme-tokens
"radius.ref" (assoc token :resolved-value 8)))))))))
(t/deftest tooltip-applied-to
(t/testing "editors don't see where the token is applied"
(t/is (nil? (:applied-to (tooltip radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys)])))))
(t/testing "viewers see the attributes of the first selected shape"
(t/is (= (str "- Border Radius: " (tr "labels.all"))
(:applied-to (tooltip radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys)]
:is-viewer true))))
(t/is (= "- Border Radius: Top Left"
(:applied-to (tooltip radius [(with-token (ths/sample-shape :rect1) radius #{:r1})]
:is-viewer true)))))
(t/testing "dimension tokens list their attributes by category"
(t/is (= "- Sizing: Width."
(:applied-to (tooltip width [(with-token (ths/sample-shape :rect1) width #{:width})]
:is-viewer true)))))
(t/testing "nothing is listed when the token isn't applied to the first shape"
(t/is (nil? (:applied-to (tooltip radius [(ths/sample-shape :rect1)
(with-token (ths/sample-shape :rect2)
radius cto/border-radius-keys)]
:is-viewer true))))))