diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index d98c481028..a54a932e42 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -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? diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5b21228c1d..63fee9982b 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/frontend/test/frontend_tests/tokens/token_pill_test.cljs b/frontend/test/frontend_tests/tokens/token_pill_test.cljs new file mode 100644 index 0000000000..24476b91c0 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/token_pill_test.cljs @@ -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))))))