diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 841d0bc29d..320fdf8167 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -442,3 +442,8 @@ (when (font-weight-values weight) (cond-> {:weight weight} italic? (assoc :style "italic"))))) + +(defn typography-composite-token-reference? + "Predicate if a typography composite token is a reference value - a string pointing to another reference token." + [token-value] + (string? token-value)) diff --git a/frontend/playwright/ui/specs/tokens.spec.js b/frontend/playwright/ui/specs/tokens.spec.js index e1d707268f..08f3b3f5a9 100644 --- a/frontend/playwright/ui/specs/tokens.spec.js +++ b/frontend/playwright/ui/specs/tokens.spec.js @@ -972,10 +972,141 @@ test.describe("Tokens: Themes modal", () => { await fontSizeField.fill(""); await expect(saveButton).toBeEnabled(); + // Fill in values for all fields and verify they persist when switching tabs + await fontSizeField.fill("16"); + + const fontFamilyField = tokensUpdateCreateModal + .getByLabel(/Font Family/i) + .first(); + const fontWeightField = + tokensUpdateCreateModal.getByLabel(/Font Weight/i); + const letterSpacingField = + tokensUpdateCreateModal.getByLabel(/Letter Spacing/i); + const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i); + const textDecorationField = + tokensUpdateCreateModal.getByLabel(/Text Decoration/i); + + // Capture all values before switching tabs + const originalValues = { + fontSize: await fontSizeField.inputValue(), + fontFamily: await fontFamilyField.inputValue(), + fontWeight: await fontWeightField.inputValue(), + letterSpacing: await letterSpacingField.inputValue(), + textCase: await textCaseField.inputValue(), + textDecoration: await textDecorationField.inputValue(), + }; + + // Switch to reference tab and back to composite tab + const referenceTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Reference", + }); + await referenceTabButton.click(); + const compositeTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Composite", + }); + await compositeTabButton.click(); + + // Verify all values are preserved after switching tabs + await expect(fontSizeField).toHaveValue(originalValues.fontSize); + await expect(fontFamilyField).toHaveValue(originalValues.fontFamily); + await expect(fontWeightField).toHaveValue(originalValues.fontWeight); + await expect(letterSpacingField).toHaveValue( + originalValues.letterSpacing, + ); + await expect(textCaseField).toHaveValue(originalValues.textCase); + await expect(textDecorationField).toHaveValue( + originalValues.textDecoration, + ); + await saveButton.click(); // Modal should close, token should be visible (with new name) in sidebar await expect(tokensUpdateCreateModal).not.toBeVisible(); }); + + test("User cant submit empty typography token or reference", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } = + await setupTypographyTokensFile(page); + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: "Add Token: Typography" }) + .click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("typography.empty"); + + const valueField = tokensUpdateCreateModal.getByLabel("Font Size"); + + // Insert a value and then delete it + await valueField.fill("1"); + await valueField.fill(""); + + // Submit button should be disabled when field is empty + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await expect(submitButton).toBeDisabled(); + + // Switch to reference tab, should not be submittable either + const referenceTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Reference", + }); + await referenceTabButton.click(); + await expect(submitButton).toBeDisabled(); + }); + + test("User adds typography token with reference", async ({ page }) => { + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } = + await setupTypographyTokensFile(page); + + const newTokenTitle = "NewReference"; + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: "Add Token: Typography" }) + .click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill(newTokenTitle); + + const referenceTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Reference", + }); + referenceTabButton.click(); + + const referenceField = tokensUpdateCreateModal.getByLabel("Reference"); + await referenceField.fill("{Full}"); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + + const resolvedValue = + await tokensUpdateCreateModal.getByText("Resolved value:"); + await expect(resolvedValue).toBeVisible(); + await expect(resolvedValue).toContainText("Font Family: 42dot Sans"); + await expect(resolvedValue).toContainText("Font Size: 100"); + await expect(resolvedValue).toContainText("Font Weight: 300"); + await expect(resolvedValue).toContainText("Letter Spacing: 2"); + await expect(resolvedValue).toContainText("Text Case: uppercase"); + await expect(resolvedValue).toContainText("Text Decoration: underline"); + + await expect(submitButton).toBeEnabled(); + await submitButton.click(); + + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + const newToken = tokensSidebar.getByRole("button", { + name: newTokenTitle, + }); + await expect(newToken).toBeVisible(); + }); }); }); diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index 75e1649911..4173481916 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -88,6 +88,10 @@ {:error/code :error.style-dictionary/invalid-token-value-font-weight :error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)} + :error.style-dictionary/invalid-token-value-typography + {:error/code :error.style-dictionary/invalid-token-value-typography + :error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)} + :error/unknown {:error/code :error/unknown :error/fn #(tr "labels.unknown-error")}}) diff --git a/frontend/src/app/main/data/workspace/tokens/format.cljs b/frontend/src/app/main/data/workspace/tokens/format.cljs new file mode 100644 index 0000000000..43a26c9c20 --- /dev/null +++ b/frontend/src/app/main/data/workspace/tokens/format.cljs @@ -0,0 +1,32 @@ +(ns app.main.data.workspace.tokens.format + (:require + [cuerdas.core :as str])) + +(def category-dictionary + {:stroke-width "Stroke Width" + :spacing "Spacing" + :sizing "Sizing" + :border-radius "Border Radius" + :x "X" + :y "Y" + :font-size "Font Size" + :font-family "Font Family" + :font-weight "Font Weight" + :letter-spacing "Letter Spacing" + :text-case "Text Case" + :text-decoration "Text Decoration"}) + +(defn format-token-value + "Converts token value of any shape to a string." + [token-value] + (cond + (map? token-value) + (->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value) + (str/join "\n") + (str "\n")) + + (sequential? token-value) + (str/join ", " token-value) + + :else + (str token-value))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs index c31b2bde82..ba445a9b17 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs @@ -102,19 +102,19 @@ ;; Validation ------------------------------------------------------------------ -(defn invalidate-empty-value [token-value] +(defn check-empty-value [token-value] (when (empty? (str/trim token-value)) (wte/get-error-code :error.token/empty-input))) -(defn invalidate-token-empty-value [token] - (invalidate-empty-value (:value token))) +(defn check-token-empty-value [token] + (check-empty-value (:value token))) -(defn invalidate-self-reference [token-name token-value] +(defn check-self-reference [token-name token-value] (when (ctob/token-value-self-reference? token-name token-value) (wte/get-error-code :error.token/direct-self-reference))) -(defn invalidate-token-self-reference [token] - (invalidate-self-reference (:name token) (:value token))) +(defn check-token-self-reference [token] + (check-self-reference (:name token) (:value token))) (defn validate-resolve-token [token prev-token tokens] @@ -146,7 +146,7 @@ (rx/of token))) (def default-validators - [invalidate-token-empty-value invalidate-token-self-reference]) + [check-token-empty-value check-self-reference]) (defn default-validate-token "Validates a token by confirming a list of `validator` predicates and resolving the token using `tokens` with StyleDictionary. @@ -176,14 +176,14 @@ ;; Resolving token via StyleDictionary (rx/mapcat #(validate-resolve-token % prev-token tokens))))) -(defn invalidate-coll-self-reference +(defn check-coll-self-reference "Invalidate a collection of `token-vals` for a self-refernce against `token-name`.," [token-name token-vals] (when (some #(ctob/token-value-self-reference? token-name %) token-vals) (wte/get-error-code :error.token/direct-self-reference))) -(defn invalidate-font-family-token-self-reference [token] - (invalidate-coll-self-reference (:name token) (:value token))) +(defn check-font-family-token-self-reference [token] + (check-coll-self-reference (:name token) (:value token))) (defn validate-font-family-token [props] @@ -192,30 +192,42 @@ (assoc :validators [(fn [token] (when (empty? (:value token)) (wte/get-error-code :error.token/empty-input))) - invalidate-font-family-token-self-reference]) + check-font-family-token-self-reference]) (default-validate-token))) -(defn invalidate-typography-token-self-reference - "Invalidate token when any of the attributes in token value have a self refernce." +(defn check-typography-token-self-reference + "Check token when any of the attributes in token value have a self-reference." [token] (let [token-name (:name token) token-values (:value token)] (some (fn [[k v]] (when-let [err (case k - :font-family (invalidate-coll-self-reference token-name v) - (invalidate-self-reference token-name v))] + :font-family (check-coll-self-reference token-name v) + (check-self-reference token-name v))] (assoc err :typography-key k))) token-values))) +(defn check-empty-typography-token [token] + (when (empty? (:value token)) + (wte/get-error-code :error.token/empty-input))) + (defn validate-typography-token - [props] - (-> props - (update :token-value - (fn [v] - (-> (or v {}) - (d/update-when :font-family #(if (string? %) (ctt/split-font-family %) %))))) - (assoc :validators [invalidate-typography-token-self-reference]) - (default-validate-token))) + [{:keys [token-value] :as props}] + (cond + ;; Entering form without a value - show no error just resolve nil + (nil? token-value) (rx/of nil) + ;; Validate refrence string + (ctt/typography-composite-token-reference? token-value) (default-validate-token props) + ;; Validate composite token + :else + (-> props + (update :token-value + (fn [v] + (-> (or v {}) + (d/update-when :font-family #(if (string? %) (ctt/split-font-family %) %))))) + (assoc :validators [check-empty-typography-token + check-typography-token-self-reference]) + (default-validate-token)))) (defn use-debonced-resolve-callback "Resolves a token values using `StyleDictionary`. @@ -365,6 +377,11 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va token-resolve-result* (mf/use-state (get resolved-tokens (cft/token-identifier token))) token-resolve-result (deref token-resolve-result*) + clear-resolve-value + (mf/use-fn + (fn [] + (reset! token-resolve-result* nil))) + set-resolve-value (mf/use-fn (mf/deps on-value-resolve) @@ -399,7 +416,6 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va (mf/use-fn (mf/deps on-update-value-debounced) (fn [next-value] - (dom/set-value! (mf/ref-val value-input-ref) next-value) (mf/set-ref-val! value-ref next-value) (on-update-value-debounced next-value))) @@ -576,7 +592,8 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va :on-update-value on-update-value :on-external-update-value on-external-update-value :custom-input-token-value-props custom-input-token-value-props - :token-resolve-result token-resolve-result}] + :token-resolve-result token-resolve-result + :clear-resolve-value clear-resolve-value}] [:> input-tokens-value* {:placeholder placeholder :label label @@ -716,6 +733,7 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va color-value (-> (tinycolor/valid-color hex-value) (tinycolor/set-alpha (or alpha 1)) (tinycolor/->string format))] + (dom/set-value! (mf/ref-val input-ref) color-value) (on-external-update-value color-value))))] [:* @@ -897,7 +915,7 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va {:label "Text Decoration" :placeholder (tr "workspace.tokens.text-decoration-value-enter")})) -(mf/defc typography-inputs* +(mf/defc typography-value-inputs* [{:keys [default-value on-blur on-update-value token-resolve-result]}] (let [typography-inputs (mf/use-memo typography-inputs) errors-by-key (sd/collect-typography-errors token-resolve-result)] @@ -928,12 +946,12 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va (-> (obj/set! e "tokenType" k) (on-update-value))))] - [:div {:class (stl/css :input-row)} + [:div {:key (str k) + :class (stl/css :input-row)} (case k :font-family [:> font-picker* - {:key (str k) - :label label + {:label label :placeholder placeholder :input-ref input-ref :default-value (when value (ctt/join-font-family value)) @@ -942,24 +960,84 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va :on-external-update-value on-external-update-value :token-resolve-result (when (seq token-resolve-result) token-resolve-result)}] [:> input-tokens-value* - {:key (str k) - :label label + {:label label :placeholder placeholder :default-value value :on-blur on-blur :on-change on-change :token-resolve-result (when (seq token-resolve-result) token-resolve-result)}])]))])) +(mf/defc typography-reference-input* + [{:keys [default-value on-blur on-update-value token-resolve-result]}] + [:> input-tokens-value* + {:label "Reference" + :placeholder "Reference" + :default-value (when (ctt/typography-composite-token-reference? default-value) default-value) + :on-blur on-blur + :on-change on-update-value + :token-resolve-result (when (or + (:errors token-resolve-result) + (string? (:value token-resolve-result))) + token-resolve-result)}]) + +(mf/defc typography-inputs* + [{:keys [default-value on-update-value on-external-update-value on-value-resolve clear-resolve-value] :rest props}] + (let [;; Active Tab State + active-tab* (mf/use-state (if (ctt/typography-composite-token-reference? default-value) :reference :composite)) + active-tab (deref active-tab*) + reference-tab-active? (= :reference active-tab) + ;; Backup value ref + ;; Used to restore the previously entered value when switching tabs + ;; Uses ref to not trigger state updates during update + backup-state-ref (mf/use-var + (if reference-tab-active? + {:reference default-value} + {:composite default-value})) + default-value (get @backup-state-ref active-tab) + + on-toggle-tab + (mf/use-fn + (mf/deps active-tab on-external-update-value on-value-resolve clear-resolve-value) + (fn [] + (let [next-tab (if (= active-tab :composite) :reference :composite)] + ;; Clear the resolved value so it wont show up before the next-tab value has resolved + (clear-resolve-value) + ;; Restore the internal value from backup + (on-external-update-value (get @backup-state-ref next-tab)) + (reset! active-tab* next-tab)))) + + ;; Store token value in the backup-state-ref + on-update-reference-value + (mf/use-fn + (mf/deps on-update-value active-tab) + (fn [e] + (if reference-tab-active? + (swap! backup-state-ref assoc :reference (dom/get-target-val e)) + (swap! backup-state-ref assoc-in [:composite (obj/get e "tokenType")] (dom/get-target-val e))) + (on-update-value e))) + + input-props (mf/spread-props props {:default-value default-value + :on-update-value on-update-reference-value})] + [:div {:class (stl/css :nested-input-row)} + [:button {:on-click on-toggle-tab :type "button"} + (if reference-tab-active? "Composite" "Reference")] + (if reference-tab-active? + [:> typography-reference-input* input-props] + [:> typography-value-inputs* input-props])])) + (mf/defc typography-form* [{:keys [token] :rest props}] (let [on-get-token-value (mf/use-callback (fn [e prev-value] (let [token-type (obj/get e "tokenType") - input-value (dom/get-target-val e)] - (if (empty? input-value) - (dissoc prev-value token-type) - (assoc prev-value token-type input-value)))))] + input-value (dom/get-target-val e) + reference-value-input? (not token-type)] + (cond + reference-value-input? input-value + + (empty? input-value) (dissoc prev-value token-type) + :else (assoc prev-value token-type input-value)))))] [:> form* (mf/spread-props props {:token token :custom-input-token-value typography-inputs* diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs index def747e6f8..c0de51d709 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs @@ -9,6 +9,7 @@ (:require [app.common.data.macros :as dm] [app.main.data.workspace.tokens.errors :as wte] + [app.main.data.workspace.tokens.format :as dwtf] [app.main.data.workspace.tokens.warnings :as wtw] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] @@ -41,7 +42,7 @@ (str/join "\n")) errors (->> (wte/humanize-errors errors) (str/join "\n")) - :else (tr "workspace.tokens.resolved-value" (or resolved-value result))) + :else (tr "workspace.tokens.resolved-value" (dwtf/format-token-value (or resolved-value result)))) type (cond empty-message? "hint" errors "error" diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.scss b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.scss index 3a097cddc9..581c14b807 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.scss @@ -31,3 +31,7 @@ @include textEllipsis; color: var(--label-color); } + +.resolved-value { + white-space: pre; +} 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 372d19d548..afc2e85b82 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 @@ -15,6 +15,7 @@ [app.common.types.token :as ctt] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.color :as dwtc] + [app.main.data.workspace.tokens.format :as dwtf] [app.main.refs :as refs] [app.main.ui.components.color-bullet :refer [color-bullet]] [app.main.ui.ds.foundations.assets.icon :refer [icon*]] @@ -26,6 +27,7 @@ [rumext.v2 :as mf])) ;; Translation dictionaries + (def ^:private attribute-dictionary {:rotation "Rotation" :opacity "Opacity" @@ -74,20 +76,6 @@ :x :x :y :y}) -(def ^:private category-dictionary - {:stroke-width "Stroke Width" - :spacing "Spacing" - :sizing "Sizing" - :border-radius "Border Radius" - :x "X" - :y "Y" - :font-size "Font Size" - :font-family "Font Family" - :font-weight "Font Weight" - :letter-spacing "Letter Spacing" - :text-case "Text Case" - :text-decoration "Text Decoration"}) - ;; Helper functions (defn partially-applied-attr @@ -105,24 +93,11 @@ (str/join "\n" (map (fn [[category values]] (if (#{:x :y} category) - (dm/str "- " (category-dictionary category)) - (dm/str "- " (category-dictionary category) ": " + (dm/str "- " (dwtf/category-dictionary category)) + (dm/str "- " (dwtf/category-dictionary category) ": " (str/join ", " (map attribute-dictionary values)) "."))) grouped-values))) -(defn format-token-value [token-value] - (cond - (map? token-value) - (->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value) - (str/join "\n") - (str "\n")) - - (sequential? token-value) - (str/join "," token-value) - - :else - (str token-value))) - (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] @@ -142,8 +117,8 @@ grouped-values (group-by dimensions-dictionary app-token-keys) base-title (dm/str "Token: " name "\n" - (tr "workspace.tokens.original-value" (format-token-value value)) "\n" - (tr "workspace.tokens.resolved-value" (format-token-value resolved-value)) + (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"))))] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f189cb51f1..fdc42bb441 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7531,6 +7531,10 @@ msgstr "" "Invalid font weight value: use numeric values (100-950) or standard names " "(thin, light, regular, bold, etc.) optionally followed by 'Italic'" +#: src/app/main/data/workspace/tokens/errors.cljs:91 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Invalid value: must reference a composite typography token." + #: src/app/main/data/workspace/tokens/errors.cljs:23 msgid "workspace.tokens.invalid-json" msgstr "Import Error: Invalid token data in JSON." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4ce7513cd0..989c31c8e7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7483,6 +7483,10 @@ msgstr "" "estándar (thin, light, regular, bold, etc.) opcionalmente seguidos de " "'Italic'" +#: src/app/main/data/workspace/tokens/errors.cljs:91 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Valor no válido: debe hacer referencia a un token tipográfico compuesto." + #: src/app/main/data/workspace/tokens/errors.cljs:23 msgid "workspace.tokens.invalid-json" msgstr "Error al importar: Datos de token no válidos en JSON."