From e8ff89836e8c88d68a52fdf7f2c6b7aedb95b0e4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 14 Nov 2025 11:04:16 +0100 Subject: [PATCH] :wrench: Refactor token validation schemas --- common/src/app/common/files/changes.cljc | 3 +- common/src/app/common/files/tokens.cljc | 153 +- common/src/app/common/i18n.cljc | 15 + common/src/app/common/logic/shapes.cljc | 2 +- common/src/app/common/schema.cljc | 17 + common/src/app/common/types/token.cljc | 435 ++--- common/src/app/common/types/tokens_lib.cljc | 89 +- .../test/common_tests/files/tokens_test.cljc | 49 +- .../common_tests/logic/token_apply_test.cljc | 24 +- .../test/common_tests/types/token_test.cljc | 19 +- .../common_tests/types/tokens_lib_test.cljc | 22 +- .../src/app/main/data/style_dictionary.cljs | 12 +- .../data/workspace/tokens/application.cljs | 10 +- .../app/main/data/workspace/tokens/color.cljs | 4 +- .../data/workspace/tokens/library_edit.cljs | 61 +- .../tokens/management/context_menu.cljs | 6 +- .../tokens/management/create/color.cljs | 223 +++ .../tokens/management/create/dimensions.cljs | 222 +++ .../tokens/management/create/font_family.cljs | 220 +++ .../tokens/management/create/form.cljs | 1435 +++++++++++++++++ .../tokens/management/create/text_case.cljs | 223 +++ .../tokens/management/create/typography.cljs | 427 +++++ .../tokens/management/forms/color.cljs | 6 +- .../management/forms/form_container.cljs | 2 +- .../tokens/management/forms/generic_form.cljs | 11 +- .../tokens/management/forms/shadow.cljs | 8 +- .../tokens/management/forms/typography.cljs | 8 +- .../tokens/management/forms/validators.cljs | 23 +- .../tokens/management/token_pill.cljs | 12 +- .../app/main/ui/workspace/tokens/sets.cljs | 5 +- .../ui/workspace/tokens/sets/helpers.cljs | 41 +- .../main/ui/workspace/tokens/sets/lists.cljs | 7 +- .../workspace/tokens/themes/create_modal.cljs | 58 +- frontend/src/app/plugins/tokens.cljs | 173 +- frontend/src/app/plugins/utils.cljs | 28 +- frontend/src/app/util/i18n.cljs | 5 + .../frontend_tests/tokens/helpers/tokens.cljs | 4 +- .../tokens/logic/token_data_test.cljs | 3 +- 38 files changed, 3503 insertions(+), 562 deletions(-) create mode 100644 common/src/app/common/i18n.cljc create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/color.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/dimensions.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/font_family.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/text_case.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/create/typography.cljs diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 6a1f2a13dd..8673ef81e3 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -27,6 +27,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.typographies-list :as ctyl] [app.common.types.typography :as ctt] @@ -378,7 +379,7 @@ [:type [:= :set-token]] [:set-id ::sm/uuid] [:token-id ::sm/uuid] - [:attrs [:maybe ctob/schema:token-attrs]]]] + [:attrs [:maybe cto/schema:token-attrs]]]] [:set-token-set [:map {:title "SetTokenSetChange"} diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 811eb598b5..293cf3c366 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -8,9 +8,112 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.i18n :refer [tr]] + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [clojure.set :as set] [cuerdas.core :as str])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HIGH LEVEL SCHEMAS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Token + +(defn make-token-name-schema + "Dynamically generates a schema to check a token name, adding translated error messages + and two additional validations: + - Min and max length. + - Checks if other token with a path derived from the name already exists at `tokens-tree`. + e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists." + [tokens-tree] + [:and + (-> cto/schema:token-name + (sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))) + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (ctob/token-name-path-exists? % tokens-tree))]]) + +(def schema:token-description + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) + +(defn make-token-schema + [tokens-tree] + (sm/merge + cto/schema:token-attrs + [:map + [:name (make-token-name-schema tokens-tree)] + [:description {:optional true} schema:token-description]])) + +;; Token set + +(defn make-token-set-name-schema + "Generates a dynamic schema to check a token set name: + - Validate name length. + - Checks if other token set with a path derived from the name already exists in the tokens lib." + [tokens-lib set-id] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "errors.token-set-already-exists" (:value %))} + (fn [name] + (let [set (ctob/get-set-by-name tokens-lib name)] + (or (nil? set) (= (ctob/get-id set) set-id))))]]) + +(def schema:token-set-description + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) + +(defn make-token-set-schema + [tokens-lib set-id] + (sm/merge + ctob/schema:token-set-attrs + [:map + [:name [:and (make-token-set-name-schema tokens-lib set-id) + [:fn #(ctob/normalized-set-name? %)]]] + [:description {:optional true} schema:token-set-description]])) + +;; Token theme + +(defn make-token-theme-group-schema + "Generates a dynamic schema to check a token theme group: + - Validate group length. + - Checks if other token theme with the same name already exists in the new group in the tokens lib." + [tokens-lib name theme-id] + [:and + [:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "errors.token-theme-already-exists" (:value %))} + (fn [group] + (let [theme (ctob/get-theme-by-name tokens-lib group name)] + (or (nil? theme) (= (:id theme) theme-id))))]]) + +(defn make-token-theme-name-schema + "Generates a dynamic schema to check a token theme name: + - Validate name length. + - Checks if other token theme with the same name already exists in the same group in the tokens lib." + [tokens-lib group theme-id] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + [:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))} + (fn [name] + (let [theme (ctob/get-theme-by-name tokens-lib group name)] + (or (nil? theme) (= (:id theme) theme-id))))]]) + +(def schema:token-theme-description + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) + +(defn make-token-theme-schema + [tokens-lib group name theme-id] + (sm/merge + ctob/schema:token-theme-attrs + [:map + [:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here? + [:name (make-token-theme-name-schema tokens-lib group theme-id)] + [:description {:optional true} schema:token-theme-description]])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (def parseable-token-value-regexp "Regexp that can be used to parse a number value out of resolved token value. This regexp also trims whitespace around the value." @@ -80,56 +183,6 @@ (defn shapes-applied-all? [ids-by-attributes shape-ids attributes] (every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes)) -(defn token-name->path - "Splits token-name into a path vector split by `.` characters. - - Will concatenate multiple `.` characters into one." - [token-name] - (str/split token-name #"\.+")) - -(defn token-name->path-selector - "Splits token-name into map with `:path` and `:selector` using `token-name->path`. - - `:selector` is the last item of the names path - `:path` is everything leading up the the `:selector`." - [token-name] - (let [path-segments (token-name->path token-name) - last-idx (dec (count path-segments)) - [path [selector]] (split-at last-idx path-segments)] - {:path (seq path) - :selector selector})) - -(defn token-name-path-exists? - "Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists. - - It's not allowed to create a token inside a token. E.g.: - Creating a token with - - {:name \"foo.bar\"} - - in the tokens tree: - - {\"foo\" {:name \"other\"}}" - [token-name token-names-tree] - (let [{:keys [path selector]} (token-name->path-selector token-name) - path-target (reduce - (fn [acc cur] - (let [target (get acc cur)] - (cond - ;; Path segment doesn't exist yet - (nil? target) (reduced false) - ;; A token exists at this path - (:name target) (reduced true) - ;; Continue traversing the true - :else target))) - token-names-tree path)] - (cond - (boolean? path-target) path-target - (get path-target :name) true - :else (-> (get path-target selector) - (seq) - (boolean))))) - (defn color-token? [token] (= (:type token) :color)) diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc new file mode 100644 index 0000000000..bdd80b9741 --- /dev/null +++ b/common/src/app/common/i18n.cljc @@ -0,0 +1,15 @@ +;; 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.common.i18n + "Dummy i18n functions, to be used by code in common that needs translations.") + +(defn tr + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [key & _args] + key) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 6fa3030d6e..70b3f6ac79 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -58,7 +58,7 @@ (cto/shape-attr->token-attrs attr changed-sub-attr))] (if (some #(contains? tokens %) token-attrs) - (pcb/update-shapes changes [shape-id] #(cto/unapply-token-id % token-attrs)) + (pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs)) changes))) check-shape diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 6c4ecb6ef1..7a95da1b8f 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -92,6 +92,16 @@ [& items] (apply mu/merge (map schema items))) +(defn assoc-key + "Add a key & value to a schema" + [s k v] + (mu/assoc s k v)) + +(defn dissoc-key + "Remove a key from a schema" + [s k] + (mu/dissoc s k)) + (defn ref? [s] (m/-ref-schema? s)) @@ -270,6 +280,13 @@ (let [explain (fn [] (me/with-error-messages explain))] ((mdp/prettifier variant message explain default-options))))) +(defn validation-errors + "Checks a value against a schema. If valid, returns nil. If not, returns a list + of english error messages." + [value schema] + (let [explainer (explainer schema)] + (-> value explainer simplify not-empty))) + (defmacro ignoring [expr] (if (:ns &env) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 6fe15169e0..0367aa4c3c 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -9,13 +9,13 @@ [app.common.data :as d] [app.common.schema :as sm] [app.common.schema.generators :as sg] - [clojure.data :as data] + [app.common.time :as ct] [clojure.set :as set] [cuerdas.core :as str] [malli.util :as mu])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; HELPERS +;; GENERAL HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- schema-keys @@ -45,32 +45,33 @@ [token-name token-value] (let [token-references (find-token-value-references token-value) self-reference? (get token-references token-name)] - self-reference?)) + (boolean self-reference?))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SCHEMA -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn composite-token-reference? + "Predicate if a composite token is a reference value - a string pointing to another token." + [token-value] + (string? token-value)) (def token-type->dtcg-token-type {:boolean "boolean" :border-radius "borderRadius" - :shadow "shadow" :color "color" :dimensions "dimension" :font-family "fontFamilies" :font-size "fontSizes" + :font-weight "fontWeights" :letter-spacing "letterSpacing" :number "number" :opacity "opacity" :other "other" :rotation "rotation" + :shadow "shadow" :sizing "sizing" :spacing "spacing" :string "string" :stroke-width "borderWidth" :text-case "textCase" :text-decoration "textDecoration" - :font-weight "fontWeights" :typography "typography"}) (def dtcg-token-type->token-type @@ -82,14 +83,13 @@ "boxShadow" :shadow))) (def composite-token-type->dtcg-token-type - "Custom set of conversion keys for composite typography token with `:line-height` available. - (Penpot doesn't support `:line-height` token)" + "When converting the type of one element inside a composite token, an additional type + :line-height is available, that is not allowed for a standalone token." (assoc token-type->dtcg-token-type :line-height "lineHeights")) (def composite-dtcg-token-type->token-type - "Custom set of conversion keys for composite typography token with `:line-height` available. - (Penpot doesn't support `:line-height` token)" + "Same as above, in the opposite direction." (assoc dtcg-token-type->token-type "lineHeights" :line-height "lineHeight" :line-height)) @@ -97,93 +97,95 @@ (def token-types (into #{} (keys token-type->dtcg-token-type))) -(def token-name-ref - [:re {:title "TokenNameRef" :gen/gen sg/text} +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SCHEMA: Token +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def schema:token-name + "A token name can contains letters, numbers, underscores the character $ and dots, but + not start with $ or end with a dot. The $ character does not have any special meaning, + but dots separate token groups (e.g. color.primary.background)." + [:re {:title "TokenName" + :gen/gen sg/text} #"^(?!\$)([a-zA-Z0-9-$_]+\.?)*(?token-type type) + type))} -(def color-keys (schema-keys schema:color)) + token-types]) + +(def schema:token-attrs + [:map {:title "Token"} + [:id ::sm/uuid] + [:name schema:token-name] + [:type schema:token-type] + [:value ::sm/any] + [:description {:optional true} :string] + [:modified-at {:optional true} ::ct/inst]]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SCHEMA: Token application to shape +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; All the following schemas define the `:applied-tokens` attribute of a shape. +;; This attribute is a map -> . +;; Token attributes approximately match shape attributes, but not always. +;; For each schema there is a `*keys` set including all the possible token attributes +;; to which a token of the corresponding type can be applied. +;; Some token types can be applied to some attributes only if the shape has a +;; particular condition (i.e. has a layout itself or is a layout item). (def ^:private schema:border-radius [:map {:title "BorderRadiusTokenAttrs"} - [:r1 {:optional true} token-name-ref] - [:r2 {:optional true} token-name-ref] - [:r3 {:optional true} token-name-ref] - [:r4 {:optional true} token-name-ref]]) + [:r1 {:optional true} schema:token-name] + [:r2 {:optional true} schema:token-name] + [:r3 {:optional true} schema:token-name] + [:r4 {:optional true} schema:token-name]]) (def border-radius-keys (schema-keys schema:border-radius)) -(def ^:private schema:shadow - [:map {:title "ShadowTokenAttrs"} - [:shadow {:optional true} token-name-ref]]) - -(def shadow-keys (schema-keys schema:shadow)) - -(def ^:private schema:stroke-width +(def ^:private schema:color [:map - [:stroke-width {:optional true} token-name-ref]]) + [:fill {:optional true} schema:token-name] + [:stroke-color {:optional true} schema:token-name]]) -(def stroke-width-keys (schema-keys schema:stroke-width)) +(def color-keys (schema-keys schema:color)) (def ^:private schema:sizing-base [:map {:title "SizingBaseTokenAttrs"} - [:width {:optional true} token-name-ref] - [:height {:optional true} token-name-ref]]) + [:width {:optional true} schema:token-name] + [:height {:optional true} schema:token-name]]) (def ^:private schema:sizing-layout-item [:map {:title "SizingLayoutItemTokenAttrs"} - [:layout-item-min-w {:optional true} token-name-ref] - [:layout-item-max-w {:optional true} token-name-ref] - [:layout-item-min-h {:optional true} token-name-ref] - [:layout-item-max-h {:optional true} token-name-ref]]) + [:layout-item-min-w {:optional true} schema:token-name] + [:layout-item-max-w {:optional true} schema:token-name] + [:layout-item-min-h {:optional true} schema:token-name] + [:layout-item-max-h {:optional true} schema:token-name]]) + +(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item)) (def ^:private schema:sizing (-> (reduce mu/union [schema:sizing-base schema:sizing-layout-item]) (mu/update-properties assoc :title "SizingTokenAttrs"))) -(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item)) - (def sizing-keys (schema-keys schema:sizing)) -(def ^:private schema:opacity - [:map {:title "OpacityTokenAttrs"} - [:opacity {:optional true} token-name-ref]]) - -(def opacity-keys (schema-keys schema:opacity)) - (def ^:private schema:spacing-gap [:map {:title "SpacingGapTokenAttrs"} - [:row-gap {:optional true} token-name-ref] - [:column-gap {:optional true} token-name-ref]]) + [:row-gap {:optional true} schema:token-name] + [:column-gap {:optional true} schema:token-name]]) (def ^:private schema:spacing-padding [:map {:title "SpacingPaddingTokenAttrs"} - [:p1 {:optional true} token-name-ref] - [:p2 {:optional true} token-name-ref] - [:p3 {:optional true} token-name-ref] - [:p4 {:optional true} token-name-ref]]) - -(def ^:private schema:spacing-margin - [:map {:title "SpacingMarginTokenAttrs"} - [:m1 {:optional true} token-name-ref] - [:m2 {:optional true} token-name-ref] - [:m3 {:optional true} token-name-ref] - [:m4 {:optional true} token-name-ref]]) - -(def ^:private schema:spacing - (-> (reduce mu/union [schema:spacing-gap - schema:spacing-padding - schema:spacing-margin]) - (mu/update-properties assoc :title "SpacingTokenAttrs"))) - -(def spacing-margin-keys (schema-keys schema:spacing-margin)) - -(def spacing-keys (schema-keys schema:spacing)) + [:p1 {:optional true} schema:token-name] + [:p2 {:optional true} schema:token-name] + [:p3 {:optional true} schema:token-name] + [:p4 {:optional true} schema:token-name]]) (def ^:private schema:spacing-gap-padding (-> (reduce mu/union [schema:spacing-gap @@ -192,6 +194,29 @@ (def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding)) +(def ^:private schema:spacing-margin + [:map {:title "SpacingMarginTokenAttrs"} + [:m1 {:optional true} schema:token-name] + [:m2 {:optional true} schema:token-name] + [:m3 {:optional true} schema:token-name] + [:m4 {:optional true} schema:token-name]]) + +(def spacing-margin-keys (schema-keys schema:spacing-margin)) + +(def ^:private schema:spacing + (-> (reduce mu/union [schema:spacing-gap + schema:spacing-padding + schema:spacing-margin]) + (mu/update-properties assoc :title "SpacingTokenAttrs"))) + +(def spacing-keys (schema-keys schema:spacing)) + +(def ^:private schema:stroke-width + [:map + [:stroke-width {:optional true} schema:token-name]]) + +(def stroke-width-keys (schema-keys schema:stroke-width)) + (def ^:private schema:dimensions (-> (reduce mu/union [schema:sizing schema:spacing @@ -201,91 +226,109 @@ (def dimensions-keys (schema-keys schema:dimensions)) -(def ^:private schema:axis - [:map - [:x {:optional true} token-name-ref] - [:y {:optional true} token-name-ref]]) - -(def axis-keys (schema-keys schema:axis)) - -(def ^:private schema:rotation - [:map {:title "RotationTokenAttrs"} - [:rotation {:optional true} token-name-ref]]) - -(def rotation-keys (schema-keys schema:rotation)) - -(def ^:private schema:font-size - [:map {:title "FontSizeTokenAttrs"} - [:font-size {:optional true} token-name-ref]]) - -(def font-size-keys (schema-keys schema:font-size)) - -(def ^:private schema:letter-spacing - [:map {:title "LetterSpacingTokenAttrs"} - [:letter-spacing {:optional true} token-name-ref]]) - -(def letter-spacing-keys (schema-keys schema:letter-spacing)) - (def ^:private schema:font-family [:map - [:font-family {:optional true} token-name-ref]]) + [:font-family {:optional true} schema:token-name]]) (def font-family-keys (schema-keys schema:font-family)) -(def ^:private schema:text-case - [:map - [:text-case {:optional true} token-name-ref]]) +(def ^:private schema:font-size + [:map {:title "FontSizeTokenAttrs"} + [:font-size {:optional true} schema:token-name]]) -(def text-case-keys (schema-keys schema:text-case)) +(def font-size-keys (schema-keys schema:font-size)) (def ^:private schema:font-weight [:map - [:font-weight {:optional true} token-name-ref]]) + [:font-weight {:optional true} schema:token-name]]) (def font-weight-keys (schema-keys schema:font-weight)) -(def ^:private schema:typography - [:map - [:typography {:optional true} token-name-ref]]) +(def ^:private schema:letter-spacing + [:map {:title "LetterSpacingTokenAttrs"} + [:letter-spacing {:optional true} schema:token-name]]) -(def typography-token-keys (schema-keys schema:typography)) +(def letter-spacing-keys (schema-keys schema:letter-spacing)) -(def ^:private schema:text-decoration - [:map - [:text-decoration {:optional true} token-name-ref]]) +(def ^:private schema:line-height ;; This is not available for standalone tokens, only typography + [:map {:title "LineHeightTokenAttrs"} + [:line-height {:optional true} schema:token-name]]) -(def text-decoration-keys (schema-keys schema:text-decoration)) +(def line-height-keys (schema-keys schema:line-height)) -(def typography-keys (set/union font-size-keys - letter-spacing-keys - font-family-keys - font-weight-keys - text-case-keys - text-decoration-keys - font-weight-keys - typography-token-keys - #{:line-height})) +(def ^:private schema:rotation + [:map {:title "RotationTokenAttrs"} + [:rotation {:optional true} schema:token-name]]) + +(def rotation-keys (schema-keys schema:rotation)) (def ^:private schema:number - (-> (reduce mu/union [[:map [:line-height {:optional true} token-name-ref]] + (-> (reduce mu/union [schema:line-height schema:rotation]) (mu/update-properties assoc :title "NumberTokenAttrs"))) (def number-keys (schema-keys schema:number)) -(def all-keys (set/union color-keys +(def ^:private schema:opacity + [:map {:title "OpacityTokenAttrs"} + [:opacity {:optional true} schema:token-name]]) + +(def opacity-keys (schema-keys schema:opacity)) + +(def ^:private schema:shadow + [:map {:title "ShadowTokenAttrs"} + [:shadow {:optional true} schema:token-name]]) + +(def shadow-keys (schema-keys schema:shadow)) + +(def ^:private schema:text-case + [:map + [:text-case {:optional true} schema:token-name]]) + +(def text-case-keys (schema-keys schema:text-case)) + +(def ^:private schema:text-decoration + [:map + [:text-decoration {:optional true} schema:token-name]]) + +(def text-decoration-keys (schema-keys schema:text-decoration)) + +(def ^:private schema:typography + [:map + [:typography {:optional true} schema:token-name]]) + +(def typography-token-keys (schema-keys schema:typography)) + +(def typography-keys (set/union font-family-keys + font-size-keys + font-weight-keys + font-weight-keys + letter-spacing-keys + line-height-keys + text-case-keys + text-decoration-keys + typography-token-keys)) + +(def ^:private schema:axis + [:map + [:x {:optional true} schema:token-name] + [:y {:optional true} schema:token-name]]) + +(def axis-keys (schema-keys schema:axis)) + +(def all-keys (set/union axis-keys border-radius-keys - shadow-keys - stroke-width-keys - sizing-keys - opacity-keys - spacing-keys + color-keys dimensions-keys - axis-keys + number-keys + opacity-keys rotation-keys + shadow-keys + sizing-keys + spacing-keys + stroke-width-keys typography-keys - typography-token-keys - number-keys)) + typography-token-keys)) (def ^:private schema:tokens [:map {:title "GenericTokenAttrs"}]) @@ -306,11 +349,28 @@ schema:text-decoration schema:dimensions]) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS for token attributes by token type +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn token-attr? [attr] (contains? all-keys attr)) +(defn token-attr->shape-attr + "Returns the actual shape attribute affected when a token have been applied + to a given `token-attr`." + [token-attr] + (case token-attr + :fill :fills + :stroke-color :strokes + :stroke-width :strokes + token-attr)) + (defn shape-attr->token-attrs + "Returns the token-attr affected when a given attribute in a shape is changed. + The sub-attr is for attributes that may have multiple values, like strokes + (may be width or color) and layout padding & margin (may have 4 edges)." ([shape-attr] (shape-attr->token-attrs shape-attr nil)) ([shape-attr changed-sub-attr] (cond @@ -352,21 +412,13 @@ (number-keys shape-attr) #{shape-attr} (axis-keys shape-attr) #{shape-attr}))) -(defn token-attr->shape-attr - [token-attr] - (case token-attr - :fill :fills - :stroke-color :strokes - :stroke-width :strokes - token-attr)) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TOKEN SHAPE ATTRIBUTES +;; HELPERS for token attributes by shape type ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def position-attributes #{:x :y}) +(def ^:private position-attributes #{:x :y}) -(def generic-attributes +(def ^:private generic-attributes (set/union color-keys stroke-width-keys rotation-keys @@ -375,20 +427,22 @@ shadow-keys position-attributes)) -(def rect-attributes +(def ^:private rect-attributes (set/union generic-attributes border-radius-keys)) -(def frame-with-layout-attributes +(def ^:private frame-with-layout-attributes (set/union rect-attributes spacing-gap-padding-keys)) -(def text-attributes +(def ^:private text-attributes (set/union generic-attributes typography-keys number-keys)) (defn shape-type->attributes + "Returns what token attributes may be applied to a shape depending on its type + and if it is a frame with a layout." [type is-layout] (case type :bool generic-attributes @@ -404,12 +458,14 @@ nil)) (defn appliable-attrs-for-shape - "Returns intersection of shape `attributes` for `shape-type`." + "Returns which ones of the given `attributes` can be applied to a shape + of type `shape-type` and `is-layout`." [attributes shape-type is-layout] (set/intersection attributes (shape-type->attributes shape-type is-layout))) (defn any-appliable-attr-for-shape? - "Checks if `token-type` supports given shape `attributes`." + "Returns if any of the given `attributes` can be applied to a shape + of type `shape-type` and `is-layout`." [attributes token-type is-layout] (d/not-empty? (appliable-attrs-for-shape attributes token-type is-layout))) @@ -420,42 +476,6 @@ typography-keys #{:fill})) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TOKENS IN SHAPES -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- toggle-or-apply-token - "Remove any shape attributes from token if they exists. - Othewise apply token attributes." - [shape token] - (let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)] - (merge {} shape-leftover token-leftover))) - -(defn- token-from-attributes [token attributes] - (->> (map (fn [attr] [attr (:name token)]) attributes) - (into {}))) - -(defn- apply-token-to-attributes [{:keys [shape token attributes]}] - (let [token (token-from-attributes token attributes)] - (toggle-or-apply-token shape token))) - -(defn apply-token-to-shape - [{:keys [shape token attributes] :as _props}] - (let [applied-tokens (apply-token-to-attributes {:shape shape - :token token - :attributes attributes})] - (update shape :applied-tokens #(merge % applied-tokens)))) - -(defn unapply-token-id [shape attributes] - (update shape :applied-tokens d/without-keys attributes)) - -(defn unapply-layout-item-tokens - "Unapplies all layout item related tokens from shape." - [shape] - (let [layout-item-attrs (set/union sizing-layout-item-keys - spacing-margin-keys)] - (unapply-token-id shape layout-item-attrs))) - (def tokens-by-input "A map from input name to applicable token for that input." {:width #{:sizing :dimensions} @@ -481,7 +501,48 @@ :stroke-color #{:color}}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TYPOGRAPHY +;; HELPERS for tokens application +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; TODO it seems that this function is redundant, maybe? +;; (defn- toggle-or-apply-token +;; "Remove any shape attributes from token if they exists. +;; Othewise apply token attributes." +;; [shape token] +;; (let [[only-in-shape only-in-token _matching] (data/diff (:applied-tokens shape) token)] +;; (merge {} only-in-shape only-in-token))) + +(defn- generate-attr-map [token attributes] + (->> (map (fn [attr] [attr (:name token)]) attributes) + (into {}))) + +(defn apply-token-to-shape + "Applies the token to the given attributes in the shape." + [{:keys [shape token attributes] :as _props}] + (let [map-to-apply (generate-attr-map token attributes)] + (update shape :applied-tokens #(merge % map-to-apply)))) + +;; (defn apply-token-to-shape +;; [{:keys [shape token attributes] :as _props}] +;; (let [map-to-apply (generate-attr-map token attributes) +;; applied-tokens (toggle-or-apply-token shape map-to-apply)] +;; (update shape :applied-tokens #(merge % applied-tokens)))) + +(defn unapply-tokens-from-shape + "Removes any token applied to the given attributes in the shape." + [shape attributes] + (update shape :applied-tokens d/without-keys attributes)) + +(defn unapply-layout-item-tokens + "Unapplies all layout item related tokens from shape." + [shape] + (let [layout-item-attrs (set/union sizing-layout-item-keys + spacing-margin-keys)] + (unapply-tokens-from-shape shape layout-item-attrs))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS for typography tokens ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn split-font-family @@ -544,17 +605,3 @@ (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)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SHADOW -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn shadow-composite-token-reference? - "Predicate if a shadow composite token is a reference value - a string pointing to another reference token." - [token-value] - (string? token-value)) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 930b1f8e05..2e1a692ee9 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -114,25 +114,19 @@ [o] (instance? Token o)) -(def schema:token-attrs - [:map {:title "Token"} - [:id ::sm/uuid] - [:name cto/token-name-ref] - [:type [::sm/one-of cto/token-types]] - [:value ::sm/any] - [:description {:optional true} :string] - [:modified-at {:optional true} ::ct/inst]]) - (declare make-token) (def schema:token - [:and {:gen/gen (->> (sg/generator schema:token-attrs) + [:and {:gen/gen (->> (sg/generator cto/schema:token-attrs) (sg/fmap #(make-token %)))} - (sm/required-keys schema:token-attrs) + (sm/required-keys cto/schema:token-attrs) [:fn token?]]) (def ^:private check-token-attrs - (sm/check-fn schema:token-attrs :hint "expected valid params for token")) + (sm/check-fn cto/schema:token-attrs :hint "expected valid params for token")) + +(def decode-token-attrs + (sm/lazy-decoder cto/schema:token-attrs sm/json-transformer)) (def check-token (sm/check-fn schema:token :hint "expected valid token")) @@ -317,10 +311,13 @@ [o] (instance? TokenSetLegacy o)) +(declare make-token-set) +(declare normalized-set-name?) + (def schema:token-set-attrs [:map {:title "TokenSet"} [:id ::sm/uuid] - [:name :string] + [:name [:and :string [:fn #(normalized-set-name? %)]]] [:description {:optional true} :string] [:modified-at {:optional true} ::ct/inst] [:tokens {:optional true @@ -342,8 +339,6 @@ :string schema:token] [:fn d/ordered-map?]]]]) -(declare make-token-set) - (def schema:token-set [:schema {:gen/gen (->> (sg/generator schema:token-set-attrs) (sg/fmap #(make-token-set %)))} @@ -404,12 +399,25 @@ (split-set-name name)) (cpn/join-path :separator set-separator :with-spaces? false)))) +(defn normalized-set-name? + "Check if a set name is normalized (no extra spaces)." + [name] + (= name (normalize-set-name name))) + (defn replace-last-path-name "Replaces the last element in a `path` vector with `name`." [path name] (-> (into [] (drop-last path)) (conj name))) +(defn make-child-name + "Generate the name of a set child of `parent-set` adding the name `name`." + [parent-set name] + (if-let [parent-path (get-set-path parent-set)] + (->> (concat parent-path (split-set-name name)) + (join-set-path)) + (normalize-set-name name))) + ;; The following functions will be removed after refactoring the internal structure of TokensLib, ;; since we'll no longer need group prefixes to differentiate between sets and set-groups. @@ -1365,10 +1373,13 @@ Will return a value that matches this schema: (def ^:private check-tokens-lib-map (sm/check-fn schema:tokens-lib-map :hint "invalid tokens-lib internal data structure")) +(defn tokens-lib? + [o] + (instance? TokensLib o)) + (defn valid-tokens-lib? [o] - (and (instance? TokensLib o) - (valid? o))) + (and (tokens-lib? o) (valid? o))) (defn- ensure-hidden-theme "A helper that is responsible to ensure that the hidden theme always @@ -1430,6 +1441,50 @@ Will return a value that matches this schema: (rename copy-name) (reid (uuid/next)))))) +(defn- token-name->path-selector + "Splits token-name into map with `:path` and `:selector` using `token-name->path`. + + `:selector` is the last item of the names path + `:path` is everything leading up the the `:selector`." + [token-name] + (let [path-segments (get-token-path {:name token-name}) + last-idx (dec (count path-segments)) + [path [selector]] (split-at last-idx path-segments)] + {:path (seq path) + :selector selector})) + +(defn token-name-path-exists? + "Traverses the path from `token-name` down a `tokens-tree` and checks if a token at that path exists. + + It's not allowed to create a token inside a token. E.g.: + Creating a token with + + {:name \"foo.bar\"} + + in the tokens tree: + + {\"foo\" {:name \"other\"}}" + [token-name tokens-tree] + (let [{:keys [path selector]} (token-name->path-selector token-name) + path-target (reduce + (fn [acc cur] + (let [target (get acc cur)] + (cond + ;; Path segment doesn't exist yet + (nil? target) (reduced false) + ;; A token exists at this path + (:name target) (reduced true) + ;; Continue traversing the true + :else target))) + tokens-tree + path)] + (cond + (boolean? path-target) path-target + (get path-target :name) true + :else (-> (get path-target selector) + (seq) + (boolean))))) + ;; === Import / Export from JSON format ;; Supported formats: diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index a16b625c88..cf8b04f6c4 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -6,34 +6,34 @@ (ns common-tests.files.tokens-test (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [clojure.test :as t])) (t/deftest test-parse-token-value (t/testing "parses double from a token value" - (t/is (= {:value 100.1 :unit nil} (cft/parse-token-value "100.1"))) - (t/is (= {:value -9.0 :unit nil} (cft/parse-token-value "-9")))) + (t/is (= {:value 100.1 :unit nil} (cfo/parse-token-value "100.1"))) + (t/is (= {:value -9.0 :unit nil} (cfo/parse-token-value "-9")))) (t/testing "trims white-space" - (t/is (= {:value -1.3 :unit nil} (cft/parse-token-value " -1.3 ")))) + (t/is (= {:value -1.3 :unit nil} (cfo/parse-token-value " -1.3 ")))) (t/testing "parses unit: px" - (t/is (= {:value 70.3 :unit "px"} (cft/parse-token-value " 70.3px ")))) + (t/is (= {:value 70.3 :unit "px"} (cfo/parse-token-value " 70.3px ")))) (t/testing "parses unit: %" - (t/is (= {:value -10.0 :unit "%"} (cft/parse-token-value "-10%")))) + (t/is (= {:value -10.0 :unit "%"} (cfo/parse-token-value "-10%")))) (t/testing "parses unit: px") (t/testing "returns nil for any invalid characters" - (t/is (nil? (cft/parse-token-value " -1.3a ")))) + (t/is (nil? (cfo/parse-token-value " -1.3a ")))) (t/testing "doesnt accept invalid double" - (t/is (nil? (cft/parse-token-value ".3"))))) + (t/is (nil? (cfo/parse-token-value ".3"))))) (t/deftest token-applied-test (t/testing "matches passed token with `:token-attributes`" - (t/is (true? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x})))) + (t/is (true? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x})))) (t/testing "doesn't match empty token" - (t/is (nil? (cft/token-applied? {} {:applied-tokens {:x "a"}} #{:x})))) + (t/is (nil? (cfo/token-applied? {} {:applied-tokens {:x "a"}} #{:x})))) (t/testing "does't match passed token `:id`" - (t/is (nil? (cft/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x})))) + (t/is (nil? (cfo/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x})))) (t/testing "doesn't match passed `:token-attributes`" - (t/is (nil? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y}))))) + (t/is (nil? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y}))))) (t/deftest shapes-ids-by-applied-attributes (t/testing "Returns set of matched attributes that fit the applied token" @@ -54,7 +54,7 @@ shape-applied-x-y shape-applied-all shape-applied-none] - expected (cft/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)] + expected (cfo/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)] (t/is (= (:x expected) (shape-ids shape-applied-x shape-applied-x-y shape-applied-all))) @@ -62,34 +62,21 @@ shape-applied-x-y shape-applied-all))) (t/is (= (:z expected) (shape-ids shape-applied-all))) - (t/is (true? (cft/shapes-applied-all? expected (shape-ids shape-applied-all) attributes))) - (t/is (false? (cft/shapes-applied-all? expected (apply shape-ids shapes) attributes))) + (t/is (true? (cfo/shapes-applied-all? expected (shape-ids shape-applied-all) attributes))) + (t/is (false? (cfo/shapes-applied-all? expected (apply shape-ids shapes) attributes))) (shape-ids shape-applied-x shape-applied-x-y shape-applied-all)))) (t/deftest tokens-applied-test (t/testing "is true when single shape matches the token and attributes" - (t/is (true? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} + (t/is (true? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} {:applied-tokens {:x "b"}}] #{:x})))) (t/testing "is false when no shape matches the token or attributes" - (t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}} + (t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}} {:applied-tokens {:x "b"}}] #{:x}))) - (t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} + (t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}} {:applied-tokens {:x "a"}}] #{:y}))))) - -(t/deftest name->path-test - (t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo.bar.baz"))) - (t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz"))) - (t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz....")))) - -(t/deftest token-name-path-exists?-test - (t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}}))) - (t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}}))) - (t/is (true? (cft/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}}))) - (t/is (true? (cft/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}}))) - (t/is (false? (cft/token-name-path-exists? "other" {"border-radius" {:name "sm"}}))) - (t/is (false? (cft/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}})))) diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index ff1f54d3d7..eb031e4868 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -255,28 +255,28 @@ (cls/generate-update-shapes [(:id frame1)] (fn [shape] (-> shape - (cto/unapply-token-id [:r1 :r2 :r3 :r4]) - (cto/unapply-token-id [:rotation]) - (cto/unapply-token-id [:opacity]) - (cto/unapply-token-id [:stroke-width]) - (cto/unapply-token-id [:stroke-color]) - (cto/unapply-token-id [:fill]) - (cto/unapply-token-id [:width :height]))) + (cto/unapply-tokens-from-shape [:r1 :r2 :r3 :r4]) + (cto/unapply-tokens-from-shape [:rotation]) + (cto/unapply-tokens-from-shape [:opacity]) + (cto/unapply-tokens-from-shape [:stroke-width]) + (cto/unapply-tokens-from-shape [:stroke-color]) + (cto/unapply-tokens-from-shape [:fill]) + (cto/unapply-tokens-from-shape [:width :height]))) (:objects page) {}) (cls/generate-update-shapes [(:id text1)] (fn [shape] (-> shape - (cto/unapply-token-id [:font-size]) - (cto/unapply-token-id [:letter-spacing]) - (cto/unapply-token-id [:font-family]))) + (cto/unapply-tokens-from-shape [:font-size]) + (cto/unapply-tokens-from-shape [:letter-spacing]) + (cto/unapply-tokens-from-shape [:font-family]))) (:objects page) {}) (cls/generate-update-shapes [(:id circle1)] (fn [shape] (-> shape - (cto/unapply-token-id [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w]) - (cto/unapply-token-id [:m1 :m2 :m3 :m4]))) + (cto/unapply-tokens-from-shape [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w]) + (cto/unapply-tokens-from-shape [:m1 :m2 :m3 :m4]))) (:objects page) {})) diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index 694f4f51fb..fb93bd2541 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -8,20 +8,19 @@ (:require [app.common.schema :as sm] [app.common.types.token :as cto] - [app.common.uuid :as uuid] [clojure.test :as t])) (t/deftest test-valid-token-name-schema ;; Allow regular namespace token names - (t/is (true? (sm/validate cto/token-name-ref "Foo"))) - (t/is (true? (sm/validate cto/token-name-ref "foo"))) - (t/is (true? (sm/validate cto/token-name-ref "FOO"))) - (t/is (true? (sm/validate cto/token-name-ref "Foo.Bar.Baz"))) + (t/is (true? (sm/validate cto/schema:token-name "Foo"))) + (t/is (true? (sm/validate cto/schema:token-name "foo"))) + (t/is (true? (sm/validate cto/schema:token-name "FOO"))) + (t/is (true? (sm/validate cto/schema:token-name "Foo.Bar.Baz"))) ;; Disallow trailing tokens - (t/is (false? (sm/validate cto/token-name-ref "Foo.Bar.Baz...."))) + (t/is (false? (sm/validate cto/schema:token-name "Foo.Bar.Baz...."))) ;; Disallow multiple separator dots - (t/is (false? (sm/validate cto/token-name-ref "Foo..Bar.Baz"))) + (t/is (false? (sm/validate cto/schema:token-name "Foo..Bar.Baz"))) ;; Disallow any special characters - (t/is (false? (sm/validate cto/token-name-ref "Hey Foo.Bar"))) - (t/is (false? (sm/validate cto/token-name-ref "Hey😈Foo.Bar"))) - (t/is (false? (sm/validate cto/token-name-ref "Hey%Foo.Bar")))) + (t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar"))) + (t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar"))) + (t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar")))) diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 30bd570860..150ffcfb08 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -678,35 +678,35 @@ (t/deftest list-active-themes-tokens-bug-taiga-10617 (let [tokens-lib (-> (ctob/make-tokens-lib) - (ctob/add-set (ctob/make-token-set :name "Mode / Dark" + (ctob/add-set (ctob/make-token-set :name "Mode/Dark" :tokens {"red" (ctob/make-token :name "red" :type :color :value "#700000")})) - (ctob/add-set (ctob/make-token-set :name "Mode / Light" + (ctob/add-set (ctob/make-token-set :name "Mode/Light" :tokens {"red" (ctob/make-token :name "red" :type :color :value "#ff0000")})) - (ctob/add-set (ctob/make-token-set :name "Device / Desktop" + (ctob/add-set (ctob/make-token-set :name "Device/Desktop" :tokens {"border1" (ctob/make-token :name "border1" :type :border-radius :value 30)})) - (ctob/add-set (ctob/make-token-set :name "Device / Mobile" + (ctob/add-set (ctob/make-token-set :name "Device/Mobile" :tokens {"border1" (ctob/make-token :name "border1" :type :border-radius :value 50)})) (ctob/add-theme (ctob/make-token-theme :group "App" :name "Mobile" - :sets #{"Mode / Dark" "Device / Mobile"})) + :sets #{"Mode/Dark" "Device/Mobile"})) (ctob/add-theme (ctob/make-token-theme :group "App" :name "Web" - :sets #{"Mode / Dark" "Mode / Light" "Device / Desktop"})) + :sets #{"Mode/Dark" "Mode/Light" "Device/Desktop"})) (ctob/add-theme (ctob/make-token-theme :group "Brand" :name "Brand A" - :sets #{"Mode / Dark" "Mode / Light" "Device / Desktop" "Device / Mobile"})) + :sets #{"Mode/Dark" "Mode/Light" "Device/Desktop" "Device/Mobile"})) (ctob/add-theme (ctob/make-token-theme :group "Brand" :name "Brand B" :sets #{})) @@ -2013,3 +2013,11 @@ (t/is (some? imported-ref)) (t/is (= (:type original-ref) (:type imported-ref))) (t/is (= (:value imported-ref) (:value original-ref)))))))) + +(t/deftest token-name-path-exists?-test + (t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}}))) + (t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}}))) + (t/is (true? (ctob/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}}))) + (t/is (true? (ctob/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}}))) + (t/is (false? (ctob/token-name-path-exists? "other" {"border-radius" {:name "sm"}}))) + (t/is (false? (ctob/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}})))) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 3c869e17c2..0a92f67e3c 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -8,7 +8,7 @@ (:require ["@tokens-studio/sd-transforms" :as sd-transforms] ["style-dictionary$default" :as sd] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] @@ -83,7 +83,7 @@ [value] (let [number? (or (number? value) (numeric-string? value)) - parsed-value (cft/parse-token-value value) + parsed-value (cfo/parse-token-value value) out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) (<= (:value parsed-value) sm/min-safe-int))] @@ -109,7 +109,7 @@ "Parses `value` of a number `sd-token` into a map like `{:value 1 :unit \"px\"}`. If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] - (let [parsed-value (cft/parse-token-value value) + (let [parsed-value (cfo/parse-token-value value) out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) (<= (:value parsed-value) sm/min-safe-int))] (if (and parsed-value (not out-of-bounds)) @@ -127,7 +127,7 @@ If the `value` is parseable but is out of range returns a map with `warnings`." [value] (let [missing-references? (seq (seq (cto/find-token-value-references value))) - parsed-value (cft/parse-token-value value) + parsed-value (cfo/parse-token-value value) out-of-scope (not (<= 0 (:value parsed-value) 1)) references (seq (cto/find-token-value-references value))] (cond (and parsed-value (not out-of-scope)) @@ -151,7 +151,7 @@ If the `value` is parseable but is out of range returns a map with `warnings`." [value] (let [missing-references? (seq (cto/find-token-value-references value)) - parsed-value (cft/parse-token-value value) + parsed-value (cfo/parse-token-value value) out-of-scope (< (:value parsed-value) 0) references (seq (cto/find-token-value-references value))] (cond @@ -250,7 +250,7 @@ :font-size-value font-size-value})] (or error (try - (when-let [{:keys [unit value]} (cft/parse-token-value line-height-value)] + (when-let [{:keys [unit value]} (cfo/parse-token-value line-height-value)] (case unit "%" (/ value 100) "px" (/ value font-size-value) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index c58b8c8135..b6ca9bfff3 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -7,7 +7,7 @@ (ns app.main.data.workspace.tokens.application (:require [app.common.data :as d] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.types.component :as ctk] [app.common.types.shape.layout :as ctsl] [app.common.types.shape.radius :as ctsr] @@ -525,8 +525,8 @@ shape-ids (d/nilv (keys shapes) []) any-variant? (->> shapes vals (some ctk/is-variant?) boolean) - resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value]) - tokenized-attributes (cft/attributes-map attributes token) + resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) + tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] (rx/concat (rx/of @@ -585,7 +585,7 @@ ptk/WatchEvent (watch [_ _ _] (rx/of - (let [remove-token #(when % (cft/remove-attributes-for-token attributes token %))] + (let [remove-token #(when % (cfo/remove-attributes-for-token attributes token %))] (dwsh/update-shapes shape-ids (fn [shape] @@ -613,7 +613,7 @@ (get token-properties (:type token)) unapply-tokens? - (cft/shapes-token-applied? token shapes (or attrs all-attributes attributes)) + (cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes)) shape-ids (map :id shapes)] diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index 828a1ca4cd..d0c0b1dbd6 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -6,7 +6,7 @@ (ns app.main.data.workspace.tokens.color (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.main.data.tinycolor :as tinycolor])) (defn color-bullet-color [token-color-value] @@ -17,5 +17,5 @@ (tinycolor/->hex-string tc)))) (defn resolved-token-bullet-color [{:keys [resolved-value] :as token}] - (when (and resolved-value (cft/color-token? token)) + (when (and resolved-value (cfo/color-token? token)) (color-bullet-color resolved-value))) \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 63b883a48f..468defce48 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -147,27 +147,30 @@ (defn create-token-set [token-set] + (assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema? (ptk/reify ::create-token-set - ptk/UpdateEvent - (update [_ state] - ;; Clear possible local state - (update state :workspace-tokens dissoc :token-set-new-path)) - ptk/WatchEvent (watch [it state _] - (let [data (dsh/lookup-file-data state) - tokens-lib (get data :tokens-lib) - token-set (ctob/rename token-set (ctob/normalize-set-name (ctob/get-name token-set)))] - (if (and tokens-lib (ctob/get-set-by-name tokens-lib (ctob/get-name token-set))) - (rx/of (ntf/show {:content (tr "errors.token-set-already-exists") - :type :toast - :level :error - :timeout 9000})) - (let [changes (-> (pcb/empty-changes it) - (pcb/with-library-data data) - (pcb/set-token-set (ctob/get-id token-set) token-set))] - (rx/of (set-selected-token-set-id (ctob/get-id token-set)) - (dch/commit-changes changes)))))))) + (let [data (dsh/lookup-file-data state) + changes (-> (pcb/empty-changes it) + (pcb/with-library-data data) + (pcb/set-token-set (ctob/get-id token-set) token-set))] + (rx/of (set-selected-token-set-id (ctob/get-id token-set)) + (dch/commit-changes changes)))))) + +(defn rename-token-set + [token-set new-name] + (assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema after renaming? + (assert (string? new-name) "a new name is required") ;; TODO should assert normalized-set-name? + (ptk/reify ::update-token-set + ptk/WatchEvent + (watch [it state _] + (let [data (dsh/lookup-file-data state) + changes (-> (pcb/empty-changes it) + (pcb/with-library-data data) + (pcb/rename-token-set (ctob/get-id token-set) new-name))] + (rx/of (set-selected-token-set-id (ctob/get-id token-set)) + (dch/commit-changes changes)))))) (defn rename-token-set-group [set-group-path set-group-fname] @@ -179,26 +182,6 @@ (rx/of (dch/commit-changes changes)))))) -(defn update-token-set - [token-set name] - (ptk/reify ::update-token-set - ptk/WatchEvent - (watch [it state _] - (let [data (dsh/lookup-file-data state) - name (ctob/normalize-set-name name (ctob/get-name token-set)) - tokens-lib (get data :tokens-lib)] - - (if (ctob/get-set-by-name tokens-lib name) - (rx/of (ntf/show {:content (tr "errors.token-set-already-exists") - :type :toast - :level :error - :timeout 9000})) - (let [changes (-> (pcb/empty-changes it) - (pcb/with-library-data data) - (pcb/rename-token-set (ctob/get-id token-set) name))] - (rx/of (set-selected-token-set-id (ctob/get-id token-set)) - (dch/commit-changes changes)))))))) - (defn duplicate-token-set [id] (ptk/reify ::duplicate-token-set @@ -448,7 +431,7 @@ (ctob/get-id token-set) token-id)] (let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set))) - unames (map :name tokens) + unames (map :name tokens) ;; TODO: add function duplicate-token in tokens-lib suffix (tr "workspace.tokens.duplicate-suffix") copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix) new-token (-> token diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index 1b26079247..ec6c74c69d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -9,7 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.types.shape.layout :as ctsl] [app.common.types.token :as ctt] [app.main.data.modal :as modal] @@ -47,9 +47,9 @@ ;; Actions --------------------------------------------------------------------- (defn attribute-actions [token selected-shapes attributes] - (let [ids-by-attributes (cft/shapes-ids-by-applied-attributes token selected-shapes attributes) + (let [ids-by-attributes (cfo/shapes-ids-by-applied-attributes token selected-shapes attributes) shape-ids (into #{} (map :id selected-shapes))] - {:all-selected? (cft/shapes-applied-all? ids-by-attributes shape-ids attributes) + {:all-selected? (cfo/shapes-applied-all? ids-by-attributes shape-ids attributes) :shape-ids shape-ids :selected-pred #(seq (% ids-by-attributes))})) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/color.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/color.cljs new file mode 100644 index 0000000000..66da0400ce --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/color.cljs @@ -0,0 +1,223 @@ +;; 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.create.color + (:require-macros [app.main.style :as stl]) + (:require + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.forms :as fc] + [app.main.ui.workspace.tokens.management.create.form-color-input-token :refer [form-color-input-token*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- token-value-error-fn + [{:keys [value]}] + (when (or (str/empty? value) + (str/blank? value)) + (tr "workspace.tokens.empty-input"))) + +(defn- make-schema + [tokens-tree] + (sm/schema + [:and + [:map + [:name + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (ctob/token-name-path-exists? % tokens-tree))]]] + + [:value [::sm/text {:error/fn token-value-error-fn}]] + + [:description {:optional true} + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] + + [:fn {:error/field :value + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (when (and name value) + (not (cto/token-value-self-reference? name value))))]])) + +(mf/defc form* + [{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}] + + (let [token + (mf/with-memo [token] + (or token {:type :color})) + + token-type + (get token :type) + + token-properties + (dwta/get-token-properties token) + + token-title (str/lower (:title token-properties)) + + tokens + (mf/deref refs/workspace-active-theme-sets-tokens) + + tokens + (mf/with-memo [tokens token] + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (cond-> tokens + (and (:name token) (:value token)) + (assoc (:name token) token))) + + schema + (mf/with-memo [tokens-tree-in-selected-set] + (make-schema tokens-tree-in-selected-set)) + + initial + (mf/with-memo [token] + {:name (:name token "") + :value (:value token "") + :description (:description token "")}) + + form + (fm/use-form :schema schema + :initial initial) + + warning-name-change? + (not= (get-in @form [:data :name]) + (:name initial)) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id token) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-cancel e)))) + + on-submit + (mf/use-fn + (mf/deps validate-token token tokens token-type) + (fn [form _event] + (let [name (get-in @form [:clean-data :name]) + description (get-in @form [:clean-data :description]) + value (get-in @form [:clean-data :value])] + (->> (validate-token {:token-value value + :token-name name + :token-description description + :prev-token token + :tokens tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))] + + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (tr "workspace.tokens.create-token" token-type)] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :input-row)} + [:> form-color-input-token* + {:placeholder (tr "workspace.tokens.token-value-enter") + :label (tr "workspace.tokens.token-value") + :name :value + :token token + :tokens tokens}]] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + + [:> fc/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/dimensions.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/dimensions.cljs new file mode 100644 index 0000000000..0ff8e4f7f6 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/dimensions.cljs @@ -0,0 +1,222 @@ +;; 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.create.dimensions + (:require-macros [app.main.style :as stl]) + (:require + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.forms :as fc] + [app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- token-value-error-fn + [{:keys [value]}] + (when (or (str/empty? value) + (str/blank? value)) + (tr "workspace.tokens.empty-input"))) + +(defn- make-schema + [tokens-tree] + (sm/schema + [:and + [:map + [:name + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (ctob/token-name-path-exists? % tokens-tree))]]] + + [:value [::sm/text {:error/fn token-value-error-fn}]] + + [:description {:optional true} + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] + + [:fn {:error/field :value + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (when (and name value) + (not (cto/token-value-self-reference? name value))))]])) + +(mf/defc form* + [{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}] + + (let [token + (mf/with-memo [token] + (or token {:type :dimensions})) + + token-type + (get token :type) + + token-properties + (dwta/get-token-properties token) + + token-title (str/lower (:title token-properties)) + + tokens + (mf/deref refs/workspace-active-theme-sets-tokens) + + tokens + (mf/with-memo [tokens token] + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (cond-> tokens + (and (:name token) (:value token)) + (assoc (:name token) token))) + + schema + (mf/with-memo [tokens-tree-in-selected-set] + (make-schema tokens-tree-in-selected-set)) + + initial + (mf/with-memo [token] + {:name (:name token "") + :value (:value token "") + :description (:description token "")}) + + form + (fm/use-form :schema schema + :initial initial) + + warning-name-change? + (not= (get-in @form [:data :name]) + (:name initial)) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id token) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-cancel e)))) + + on-submit + (mf/use-fn + (mf/deps validate-token token tokens token-type) + (fn [form _event] + (let [name (get-in @form [:clean-data :name]) + description (get-in @form [:clean-data :description]) + value (get-in @form [:clean-data :value])] + (->> (validate-token {:token-value value + :token-name name + :token-description description + :prev-token token + :tokens tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))] + + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (tr "workspace.tokens.create-token" token-type)] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :input-row)} + [:> form-input-token* + {:placeholder (tr "workspace.tokens.token-value-enter") + :label (tr "workspace.tokens.token-value") + :name :value + :token token + :tokens tokens}]] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + + [:> fc/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/font_family.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/font_family.cljs new file mode 100644 index 0000000000..9aef90f3b7 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/font_family.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 INC + +(ns app.main.ui.workspace.tokens.management.create.font-family + (:require-macros [app.main.style :as stl]) + (:require + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.forms :as fc] + [app.main.ui.workspace.tokens.management.create.combobox-token-fonts :refer [font-picker-combobox*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- make-schema + [tokens-tree] + (sm/schema + [:and + [:map + [:name + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (ctob/token-name-path-exists? % tokens-tree))]]] + + [:value ::sm/text] + + [:description {:optional true} + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] + + [:fn {:error/field :value + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (when (and name value) + (not (cto/token-value-self-reference? name value))))]])) + + +(mf/defc form* + [{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}] + + (let [token + (mf/with-memo [token] + (if token + (update token :value cto/join-font-family) + {:type :font-family})) + + token-type + (get token :type) + + token-properties + (dwta/get-token-properties token) + + token-title (str/lower (:title token-properties)) + + tokens + (mf/deref refs/workspace-active-theme-sets-tokens) + + tokens + (mf/with-memo [tokens token] + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (cond-> tokens + (and (:name token) (:value token)) + (assoc (:name token) token))) + + schema + (mf/with-memo [tokens-tree-in-selected-set] + (make-schema tokens-tree-in-selected-set)) + + initial + (mf/with-memo [token] + {:name (:name token "") + :value (:value token "") + :description (:description token "")}) + + form + (fm/use-form :schema schema + :initial initial) + + warning-name-change? + (not= (get-in @form [:data :name]) + (:name initial)) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id token) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-cancel e)))) + + on-submit + (mf/use-fn + (mf/deps validate-token token tokens token-type) + (fn [form _event] + (let [name (get-in @form [:clean-data :name]) + description (get-in @form [:clean-data :description]) + value (get-in @form [:clean-data :value])] + (->> (validate-token {:token-value value + :token-name name + :token-description description + :prev-token token + :tokens tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))] + + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (tr "workspace.tokens.create-token" token-type)] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :input-row)} + [:> font-picker-combobox* + {:placeholder (tr "workspace.tokens.token-value-enter") + :label (tr "workspace.tokens.token-value") + :name :value + :token token + :tokens tokens}]] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + + [:> fc/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]])) 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 new file mode 100644 index 0000000000..23afc25b0a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs @@ -0,0 +1,1435 @@ +;; 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.create.form + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] + [app.common.types.color :as c] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.style-dictionary :as sd] + [app.main.data.tinycolor :as tinycolor] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.errors :as wte] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.fonts :as fonts] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.workspace.colorpicker :as colorpicker] + [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]] + [app.main.ui.workspace.sidebar.options.menus.typography :refer [font-selector*]] + [app.main.ui.workspace.tokens.management.create.border-radius :as border-radius] + [app.main.ui.workspace.tokens.management.create.color :as color] + [app.main.ui.workspace.tokens.management.create.dimensions :as dimensions] + [app.main.ui.workspace.tokens.management.create.font-family :as font-family] + [app.main.ui.workspace.tokens.management.create.input-token-color-bullet :refer [input-token-color-bullet*]] + [app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-token* token-value-hint*]] + [app.main.ui.workspace.tokens.management.create.text-case :as text-case] + [app.main.ui.workspace.tokens.management.create.typography :as typography] + [app.util.dom :as dom] + [app.util.functions :as uf] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [app.util.object :as obj] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +;; Helpers --------------------------------------------------------------------- + +(defn- clean-name [name] + (-> (str/trim name) + ;; Remove trailing dots + (str/replace #"\.+$" ""))) + +(defn- valid-name? [name] + (seq (clean-name (str name)))) + +;; Value Validation ------------------------------------------------------------- + +(defn check-empty-value [token-value] + (when (empty? (str/trim token-value)) + (wte/get-error-code :error.token/empty-input))) + +(defn check-token-empty-value [token] + (check-empty-value (:value token))) + +(defn check-self-reference [token-name token-value] + (when (cto/token-value-self-reference? token-name token-value) + (wte/get-error-code :error.token/direct-self-reference))) + +(defn validate-resolve-token + [token prev-token tokens] + (let [token (cond-> token + ;; When creating a new token we dont have a name yet or invalid name, + ;; but we still want to resolve the value to show in the form. + ;; So we use a temporary token name that hopefully doesn't clash with any of the users token names + (not (sm/valid? cto/schema:token-name (:name token))) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__")) + tokens' (cond-> tokens + ;; Remove previous token when renaming a token + (not= (:name token) (:name prev-token)) + (dissoc (:name prev-token)) + + :always + (update (:name token) #(ctob/make-token (merge % prev-token token))))] + (->> tokens' + (sd/resolve-tokens-interactive) + (rx/mapcat + (fn [resolved-tokens] + (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))] + (cond + resolved-value (rx/of resolved-token) + :else (rx/throw {:errors (or (seq errors) + [(wte/get-error-code :error/unknown-error)])})))))))) + +(defn- validate-token-with [token validators] + (if-let [error (some (fn [validate] (validate token)) validators)] + (rx/throw {:errors [error]}) + (rx/of token))) + +(def ^:private default-validators + [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. + Returns rx stream of either a valid resolved token or an errors map. + + Props: + token-name, token-value, token-description: Values from the form inputs + prev-token: The existing token currently being edited + tokens: tokens map keyed by token-name + Used to look up the editing token & resolving step. + + validators: A list of predicates that will be used to do simple validation on the unresolved token map. + The validators get the token map as input and should either return: + - An errors map .e.g: {:errors []} + - nil (valid token predicate) + Mostly used to do simple checks like invalidating empy token `:name`. + Will default to `default-validators`." + [{:keys [token-name token-value token-description prev-token tokens validators] + :or {validators default-validators}}] + (let [token (-> {:name token-name + :value token-value + :description token-description} + (d/without-nils))] + (->> (rx/of token) + ;; Simple validation of the editing token + (rx/mapcat #(validate-token-with % validators)) + ;; Resolving token via StyleDictionary + (rx/mapcat #(validate-resolve-token % prev-token tokens))))) + +(defn- check-coll-self-reference + "Invalidate a collection of `token-vals` for a self-refernce against `token-name`.," + [token-name token-vals] + (when (some #(cto/token-value-self-reference? token-name %) token-vals) + (wte/get-error-code :error.token/direct-self-reference))) + +(defn- check-font-family-token-self-reference [token] + (check-coll-self-reference (:name token) (:value token))) + +(defn- validate-font-family-token + [props] + (-> props + (update :token-value cto/split-font-family) + (assoc :validators [(fn [token] + (when (empty? (:value token)) + (wte/get-error-code :error.token/empty-input))) + check-font-family-token-self-reference]) + (default-validate-token))) + +(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 (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- check-shadow-token-self-reference + "Check token when any of the attributes in a shadow's value have a self-reference." + [token] + (let [token-name (:name token) + shadow-values (:value token)] + (some (fn [[shadow-idx shadow-map]] + (some (fn [[k v]] + (when-let [err (check-self-reference token-name v)] + (assoc err :shadow-key k :shadow-index shadow-idx))) + shadow-map)) + (d/enumerate shadow-values)))) + +(defn- check-empty-shadow-token [token] + (when (or (empty? (:value token)) + (some (fn [shadow] (not-every? #(contains? shadow %) [:offsetX :offsetY :blur :spread :color])) + (:value token))) + (wte/get-error-code :error.token/empty-input))) + +(defn- validate-typography-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 + (cto/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? %) (cto/split-font-family %) %))))) + (assoc :validators [check-empty-typography-token + check-typography-token-self-reference]) + (default-validate-token)))) + +(defn- validate-shadow-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 + (cto/composite-token-reference? token-value) (default-validate-token props) + ;; Validate composite token + :else + (-> props + (update :token-value (fn [value] + (->> (or value []) + (mapv (fn [shadow] + (d/update-when shadow :inset #(cond + (boolean? %) % + (= "true" %) true + :else false))))))) + (assoc :validators [check-empty-shadow-token + check-shadow-token-self-reference]) + (default-validate-token)))) + +(defn- use-debonced-resolve-callback + "Resolves a token values using `StyleDictionary`. + This function is debounced as the resolving might be an expensive calculation. + Uses a custom debouncing logic, as the resolve function is async." + [{:keys [timeout name-ref token tokens callback validate-token] + :or {timeout 160}}] + (let [timeout-id-ref (mf/use-ref nil)] + (mf/use-fn + (mf/deps token callback tokens) + (fn [value] + (let [timeout-id (js/Symbol) + ;; Dont execute callback when the timout-id-ref is outdated because this function got called again + timeout-outdated-cb? #(not= (mf/ref-val timeout-id-ref) timeout-id)] + (mf/set-ref-val! timeout-id-ref timeout-id) + (js/setTimeout + (fn [] + (when (not (timeout-outdated-cb?)) + (->> (validate-token {:token-value value + :token-name (mf/ref-val name-ref) + :prev-token token + :tokens tokens}) + (rx/filter #(not (timeout-outdated-cb?))) + (rx/subs! callback callback)))) + timeout)))))) + +(defonce form-token-cache-atom (atom nil)) + +;; Form Component -------------------------------------------------------------- + +(mf/defc form* + "Form component to edit or create a token of any token type. + + Callback props: + validate-token: Function to validate and resolve an editing token, see `default-validate-token`. + on-value-resolve: Will be called when a token value is resolved + Used to sync external state (like color picker) + on-get-token-value: Custom function to get the input value from the dom + (As there might be multiple inputs passed for `custom-input-token-value`) + Can also be used to manipulate the value (E.g.: Auto-prepending # for hex colors) + + Custom component props: + custom-input-token-value: Custom component for editing/displaying the token value + custom-input-token-value-props: Custom props passed to the custom-input-token-value merged with the default props" + [{:keys [is-create + token + token-type + selected-token-set-id + action + input-value-placeholder + + ;; Callbacks + validate-token + on-value-resolve + on-get-token-value + + ;; Custom component props + custom-input-token-value + custom-input-token-value-props] + :or {validate-token default-validate-token}}] + + (let [token + (mf/with-memo [token] + (or token {:type token-type})) + + token-name (get token :name) + token-description (get token :description) + token-name-ref (mf/use-ref token-name) + + name-ref (mf/use-ref nil) + + name-errors* (mf/use-state nil) + name-errors (deref name-errors*) + + touched-name* (mf/use-state false) + touched-name? (deref touched-name*) + + warning-name-change* + (mf/use-state false) + + warning-name-change? + (deref warning-name-change*) + + token-properties + (dwta/get-token-properties token) + + tokens-in-selected-set + (mf/deref refs/workspace-all-tokens-in-selected-set) + + active-theme-tokens + (cond-> (mf/deref refs/workspace-active-theme-sets-tokens) + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (and (:name token) (:value token)) + (assoc (:name token) token) + + ;; Style dictionary resolver needs font families to be an array of strings + (= :font-family (or (:type token) token-type)) + (update-in [(:name token) :value] cto/split-font-family) + + (= :typography (or (:type token) token-type)) + (d/update-in-when [(:name token) :font-family :value] cto/split-font-family)) + + resolved-tokens + (sd/use-resolved-tokens active-theme-tokens + {:cache-atom form-token-cache-atom + :interactive? true}) + + token-path + (mf/with-memo [token-name] + (ctob/get-token-path {:name token-name})) + + tokens-tree-in-selected-set + (mf/with-memo [token-path tokens-in-selected-set] + (-> (ctob/tokens-tree tokens-in-selected-set) + ;; Allow setting editing token to it's own path + (d/dissoc-in token-path))) + + on-blur-name + (mf/use-fn + (mf/deps touched-name?) + (fn [e] + (let [value (dom/get-target-val e) + errors (sm/validation-errors value (cfo/make-token-name-schema tokens-tree-in-selected-set))] + (when touched-name? (reset! warning-name-change* true)) + (reset! name-errors* errors)))) + + on-update-name-debounced + (mf/with-memo [touched-name?] + (uf/debounce (fn [token-name] + (when touched-name? + (let [errors + (sm/validation-errors token-name + (cfo/make-token-name-schema tokens-tree-in-selected-set))] + (reset! name-errors* errors)))) + 300)) + + on-update-name + (mf/use-fn + (mf/deps on-update-name-debounced name-ref) + (fn [] + (let [ref (mf/ref-val name-ref) + token-name (dom/get-value ref)] + (reset! touched-name* true) + + (mf/set-ref-val! token-name-ref token-name) + (on-update-name-debounced token-name)))) + + valid-name-field? + (and + (not name-errors) + (valid-name? (mf/ref-val token-name-ref))) + + ;; Value + value-input-ref (mf/use-ref nil) + value-ref (mf/use-ref (:value token)) + + token-resolve-result* + (mf/use-state #(get resolved-tokens (cfo/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) + (fn [token-or-err] + (when on-value-resolve + (cond + (:errors token-or-err) (on-value-resolve nil) + :else (on-value-resolve (:resolved-value token-or-err)))) + (reset! token-resolve-result* token-or-err))) + + on-update-value-debounced + (use-debonced-resolve-callback + {:name-ref token-name-ref + :token token + :tokens active-theme-tokens + :callback set-resolve-value + :validate-token validate-token}) + + ;; Callback to update the value state via on of the inputs :on-change + on-update-value + (mf/use-fn + (mf/deps on-update-value-debounced on-get-token-value) + (fn [e] + (let [value (if (fn? on-get-token-value) + (on-get-token-value e (mf/ref-val value-ref)) + (dom/get-target-val e))] + (mf/set-ref-val! value-ref value) + (on-update-value-debounced value)))) + + ;; Calback to update the value state from the outside (e.g.: color picker) + on-external-update-value + (mf/use-fn + (mf/deps on-update-value-debounced) + (fn [next-value] + (mf/set-ref-val! value-ref next-value) + (on-update-value-debounced next-value))) + + value-error? (seq (:errors token-resolve-result)) + valid-value-field? (and token-resolve-result (not value-error?)) + + ;; Description + description-ref (mf/use-ref token-description) + description-errors* (mf/use-state nil) + description-errors (deref description-errors*) + + on-update-description-debounced + (mf/with-memo [] + (uf/debounce (fn [e] + (let [value (dom/get-target-val e) + errors (sm/validation-errors value cfo/schema:token-description)] + (reset! description-errors* errors))))) + + on-update-description + (mf/use-fn + (mf/deps on-update-description-debounced) + (fn [e] + (mf/set-ref-val! description-ref (dom/get-target-val e)) + (on-update-description-debounced e))) + + valid-description-field? + (empty? description-errors) + + ;; Form + disabled? + (or (not valid-name-field?) + (not valid-value-field?) + (not valid-description-field?)) + + on-submit + (mf/use-fn + (mf/deps is-create token active-theme-tokens validate-token) + (fn [e] + (dom/prevent-default e) + ;; We have to re-validate the current form values before submitting + ;; because the validation is asynchronous/debounced + ;; and the user might have edited a valid form to make it invalid, + ;; and press enter before the next validations could return. + + (let [clean-name (clean-name (mf/ref-val token-name-ref)) + valid-name? (empty? (sm/validation-errors clean-name (cfo/make-token-name-schema tokens-tree-in-selected-set))) + + value (mf/ref-val value-ref) + clean-description (mf/ref-val description-ref) + valid-description? (or (some-> clean-description + (sm/validation-errors cfo/schema:token-description) + empty?) + true)] + + (when (and valid-name? valid-description?) + (->> (validate-token {:token-value value + :token-name clean-name + :token-description clean-description + :prev-token token + :tokens active-theme-tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name clean-name + :type token-type + :value (:value valid-token) + :description clean-description})) + + (dwtl/update-token (:id token) + {:name clean-name + :value (:value valid-token) + :description clean-description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (k/enter? e) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (k/enter? e) + (on-cancel e)))) + + handle-key-down-save + (mf/use-fn + (fn [e] + (mf/deps on-submit) + (when (k/enter? e) + (on-submit e))))] + + ;; Clear form token cache on unmount + (mf/with-effect [] + #(reset! form-token-cache-atom nil)) + + ;; Update the value when editing an existing token + ;; so the user doesn't have to interact with the form to validate the token + (mf/with-effect [is-create token resolved-tokens token-resolve-result set-resolve-value] + (when (and (not is-create) + (:value token) ;; Don't retrigger this effect when switching tabs on composite tokens + (not token-resolve-result) + resolved-tokens) + (-> (get resolved-tokens (mf/ref-val token-name-ref)) + (set-resolve-value)))) + + [:form {:class (stl/css :form-wrapper) + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (if (= action "edit") + (tr "workspace.tokens.edit-token" token-type) + (tr "workspace.tokens.create-token" token-type))] + + [:div {:class (stl/css :input-row)} + (let [token-title (str/lower (:title token-properties))] + [:> input* {:id "token-name" + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name", token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true + :default-value (mf/ref-val token-name-ref) + :hint-type (when-not (empty? name-errors) "error") + :hint-message (first name-errors) + :ref name-ref + :on-blur on-blur-name + :on-change on-update-name}]) + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :input-row)} + (let [placeholder (or input-value-placeholder (tr "workspace.tokens.token-value-enter")) + label (tr "workspace.tokens.token-value") + default-value (mf/ref-val value-ref) + ref value-input-ref] + (if (fn? custom-input-token-value) + [:> custom-input-token-value + {:placeholder placeholder + :label label + :default-value default-value + :input-ref ref + :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 + :clear-resolve-value clear-resolve-value}] + [:> input-token* + {:placeholder placeholder + :label label + :default-value default-value + :ref ref + :on-blur on-update-value + :on-change on-update-value + :token-resolve-result token-resolve-result}]))] + [:div {:class (stl/css :input-row)} + [:> input* {:label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :is-optional true + :max-length max-input-length + :variant "comfortable" + :default-value (mf/ref-val description-ref) + :hint-type (when-not (empty? description-errors) "error") + :hint-message (first description-errors) + :on-blur on-update-description + :on-change on-update-description}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + [:> button* {:type "submit" + :on-key-down handle-key-down-save + :variant "primary" + :disabled disabled?} + (tr "labels.save")]]]])) + +;; Tabs Component -------------------------------------------------------------- + +(mf/defc composite-reference-input* + [{:keys [default-value on-blur on-update-value token-resolve-result reference-label reference-icon is-reference-fn]}] + [:> input-token* + {:aria-label (tr "labels.reference") + :placeholder reference-label + :icon reference-icon + :default-value (when (is-reference-fn 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 composite-tabs* + [{:keys [default-value + on-update-value + on-external-update-value + on-value-resolve + clear-resolve-value + custom-input-token-value-props] + :rest props}] + (let [;; Active Tab State + {:keys [active-tab + composite-tab + is-reference-fn + reference-icon + reference-label + set-active-tab + title + update-composite-backup-value]} custom-input-token-value-props + 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)) + (set-active-tab next-tab)))) + + update-composite-value + (mf/use-fn + (fn [f] + (clear-resolve-value) + (swap! backup-state-ref f) + (on-external-update-value (get @backup-state-ref :composite)))) + + ;; Store updated value in backup-state-ref + on-update-value' + (mf/use-fn + (mf/deps on-update-value reference-tab-active? update-composite-backup-value) + (fn [e] + (if reference-tab-active? + (swap! backup-state-ref assoc :reference (dom/get-target-val e)) + (swap! backup-state-ref update :composite #(update-composite-backup-value % e))) + (on-update-value e)))] + [:div {:class (stl/css :typography-inputs-row)} + [:div {:class (stl/css :title-bar)} + [:div {:class (stl/css :title)} title] + [:& radio-buttons {:class (stl/css :listing-options) + :selected (if reference-tab-active? "reference" "composite") + :on-change on-toggle-tab + :name "reference-composite-tab"} + [:& radio-button {:icon i/layers + :value "composite" + :title (tr "workspace.tokens.individual-tokens") + :id "composite-opt"}] + [:& radio-button {:icon i/tokens + :value "reference" + :title (tr "workspace.tokens.use-reference") + :id "reference-opt"}]]] + [:div {:class (stl/css :typography-inputs)} + (if reference-tab-active? + [:> composite-reference-input* + (mf/spread-props props {:default-value default-value + :on-update-value on-update-value' + :reference-icon reference-icon + :reference-label reference-label + :is-reference-fn is-reference-fn})] + [:> composite-tab + (mf/spread-props props {:default-value default-value + :on-update-value on-update-value' + :update-composite-value update-composite-value})])]])) + +(mf/defc composite-form* + "Wrapper around form* that manages composite/reference tab state. + Takes the same props as form* plus a function to determine if a token value is a reference." + [{:keys [token is-reference-fn composite-tab reference-icon title update-composite-backup-value] :rest props}] + (let [active-tab* (mf/use-state (if (is-reference-fn (:value token)) :reference :composite)) + active-tab (deref active-tab*) + + custom-input-token-value-props + (mf/use-memo + (mf/deps active-tab composite-tab reference-icon title update-composite-backup-value is-reference-fn) + (fn [] + {:active-tab active-tab + :set-active-tab #(reset! active-tab* %) + :composite-tab composite-tab + :reference-icon reference-icon + :reference-label (tr "workspace.tokens.reference-composite") + :title title + :update-composite-backup-value update-composite-backup-value + :is-reference-fn is-reference-fn})) + + ;; Remove the value from a stored token when it doesn't match the tab type + ;; We need this to keep the form disabled when there's an existing value that doesn't match the tab type + token + (mf/use-memo + (mf/deps token active-tab is-reference-fn) + (fn [] + (let [token-tab-type (if (is-reference-fn (:value token)) :reference :composite)] + (cond-> token + (not= token-tab-type active-tab) (dissoc :value token)))))] + [:> form* + (mf/spread-props props {:token token + :custom-input-token-value composite-tabs* + :custom-input-token-value-props custom-input-token-value-props})])) + +;; Token Type Forms ------------------------------------------------------------ + +;; FIXME: this function has confusing name +(defn- hex->value + [hex] + (when-let [tc (tinycolor/valid-color hex)] + (let [hex (tinycolor/->hex-string tc) + alpha (tinycolor/alpha tc) + [r g b] (c/hex->rgb hex) + [h s v] (c/hex->hsv hex)] + {:hex hex + :r r :g g :b b + :h h :s s :v v + :alpha alpha}))) + +(mf/defc ramp* + [{:keys [color on-change]}] + (let [wrapper-node-ref (mf/use-ref nil) + dragging-ref (mf/use-ref false) + + on-start-drag + (mf/use-fn #(mf/set-ref-val! dragging-ref true)) + + on-finish-drag + (mf/use-fn #(mf/set-ref-val! dragging-ref false)) + + internal-color* + (mf/use-state #(hex->value color)) + + internal-color + (deref internal-color*) + + on-change' + (mf/use-fn + (mf/deps on-change) + (fn [{:keys [hex alpha] :as selector-color}] + (let [dragging? (mf/ref-val dragging-ref)] + (when-not (and dragging? hex) + (reset! internal-color* selector-color) + (on-change hex alpha)))))] + (mf/use-effect + (mf/deps color) + (fn [] + ;; Update internal color when user changes input value + (when-let [color (tinycolor/valid-color color)] + (when-not (= (tinycolor/->hex-string color) (:hex internal-color)) + (reset! internal-color* (hex->value color)))))) + + (colorpicker/use-color-picker-css-variables! wrapper-node-ref internal-color) + [:div {:ref wrapper-node-ref} + [:> ramp-selector* + {:color internal-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag + :on-change on-change'}]])) + +(mf/defc color-picker* + [{:keys [placeholder label default-value input-ref on-blur on-update-value on-external-update-value custom-input-token-value-props token-resolve-result]}] + (let [{:keys [color on-display-colorpicker]} custom-input-token-value-props + color-ramp-open* (mf/use-state false) + color-ramp-open? (deref color-ramp-open*) + + on-click-swatch + (mf/use-fn + (mf/deps color-ramp-open? on-display-colorpicker) + (fn [] + (let [open? (not color-ramp-open?)] + (reset! color-ramp-open* open?) + (when on-display-colorpicker + (on-display-colorpicker open?))))) + + swatch + (mf/html + [:> input-token-color-bullet* + {:color color + :class (stl/css :slot-start) + :on-click on-click-swatch}]) + + on-change' + (mf/use-fn + (mf/deps color on-external-update-value) + (fn [hex-value alpha] + (let [;; StyleDictionary will always convert to hex/rgba, so we take the format from the value input field + prev-input-color (some-> (dom/get-value (mf/ref-val input-ref)) + (tinycolor/valid-color)) + ;; If the input is a reference we will take the format from the computed value + prev-computed-color (when-not prev-input-color + (some-> color (tinycolor/valid-color))) + prev-format (some-> (or prev-input-color prev-computed-color) + (tinycolor/color-format)) + to-rgba? (and + (< alpha 1) + (or (= prev-format "hex") (not prev-format))) + to-hex? (and (not prev-format) (= alpha 1)) + format (cond + to-rgba? "rgba" + to-hex? "hex" + prev-format prev-format + :else "hex") + 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))))] + + [:* + [:> input-token* + {:placeholder placeholder + :label label + :default-value default-value + :ref input-ref + :on-blur on-blur + :on-change on-update-value + :slot-start swatch}] + (when color-ramp-open? + [:> ramp* + {:color (some-> color (tinycolor/valid-color)) + :on-change on-change'}]) + [:> token-value-hint* {:result token-resolve-result}]])) + +(mf/defc shadow-color-picker-wrapper* + "Wrapper for color-picker* that passes shadow color state from parent. + Similar to color-form* but receives color state from shadow-value-inputs*." + [{:keys [placeholder label default-value input-ref on-update-value on-external-update-value token-resolve-result shadow-color]}] + (let [;; Use the color state passed from parent (shadow-value-inputs*) + resolved-color (get token-resolve-result :resolved-value) + color (or shadow-color resolved-color default-value "") + + custom-input-token-value-props + (mf/use-memo + (mf/deps color) + (fn [] + {:color color}))] + + [:> color-picker* + {:placeholder placeholder + :label label + :default-value default-value + :input-ref input-ref + :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}])) + +(def ^:private shadow-inputs + #(d/ordered-map + :offsetX + {:label (tr "workspace.tokens.shadow-x") + :placeholder (tr "workspace.tokens.shadow-x")} + :offsetY + {:label (tr "workspace.tokens.shadow-y") + :placeholder (tr "workspace.tokens.shadow-y")} + :blur + {:label (tr "workspace.tokens.shadow-blur") + :placeholder (tr "workspace.tokens.shadow-blur")} + :spread + {:label (tr "workspace.tokens.shadow-spread") + :placeholder (tr "workspace.tokens.shadow-spread")} + :color + {:label (tr "workspace.tokens.shadow-color") + :placeholder (tr "workspace.tokens.shadow-color")} + :inset + {:label (tr "workspace.tokens.shadow-inset") + :placeholder (tr "workspace.tokens.shadow-inset")})) + +(mf/defc inset-type-select* + [{:keys [default-value shadow-idx label on-change]}] + (let [selected* (mf/use-state (or (str default-value) "false")) + selected (deref selected*) + + on-change + (mf/use-fn + (mf/deps on-change selected shadow-idx) + (fn [value e] + (obj/set! e "tokenValue" (if (= "true" value) true false)) + (on-change e) + (reset! selected* (str value))))] + [:div {:class (stl/css :input-row)} + [:div {:class (stl/css :inset-label)} label] + [:& radio-buttons {:selected selected + :on-change on-change + :name (str "inset-select-" shadow-idx)} + [:& radio-button {:value "false" + :title "false" + :icon i/close + :id (str "inset-default-" shadow-idx)}] + [:& radio-button {:value "true" + :title "true" + :icon i/tick + :id (str "inset-false-" shadow-idx)}]]])) + +(mf/defc shadow-input* + [{:keys [default-value label placeholder shadow-idx input-type on-update-value on-external-update-value token-resolve-result errors-by-key shadow-color]}] + (let [color-input-ref (mf/use-ref) + + on-change + (mf/use-fn + (mf/deps shadow-idx input-type on-update-value) + (fn [e] + (-> (obj/set! e "tokenTypeAtIndex" [shadow-idx input-type]) + (on-update-value)))) + + on-external-update-value' + (mf/use-fn + (mf/deps shadow-idx input-type on-external-update-value) + (fn [v] + (on-external-update-value [shadow-idx input-type] v))) + + resolved (get-in token-resolve-result [:resolved-value shadow-idx input-type]) + + errors (get errors-by-key input-type) + + should-show? (or (some? resolved) (seq errors)) + + token-prop (when should-show? + (d/without-nils + {:resolved-value resolved + :errors errors}))] + (case input-type + :inset + [:> inset-type-select* + {:default-value default-value + :shadow-idx shadow-idx + :label label + :on-change on-change}] + :color + [:> shadow-color-picker-wrapper* + {:placeholder placeholder + :label label + :default-value default-value + :input-ref color-input-ref + :on-update-value on-change + :on-external-update-value on-external-update-value' + :token-resolve-result token-prop + :shadow-color shadow-color + :data-testid (str "shadow-color-input-" shadow-idx)}] + [:div {:class (stl/css :input-row) + :data-testid (str "shadow-" (name input-type) "-input-" shadow-idx)} + [:> input-token* + {:label label + :placeholder placeholder + :default-value default-value + :on-change on-change + :token-resolve-result token-prop}]]))) + +(mf/defc shadow-input-fields* + [{:keys [shadow shadow-idx on-remove-shadow on-add-shadow is-remove-disabled on-update-value token-resolve-result errors-by-key on-external-update-value shadow-color] :as props}] + (let [on-remove-shadow + (mf/use-fn + (mf/deps shadow-idx on-remove-shadow) + #(on-remove-shadow shadow-idx))] + [:div {:data-testid (str "shadow-input-fields-" shadow-idx)} + [:> icon-button* {:icon i/add + :type "button" + :on-click on-add-shadow + :data-testid (str "shadow-add-button-" shadow-idx) + :aria-label (tr "workspace.tokens.shadow-add-shadow")}] + [:> icon-button* {:variant "ghost" + :type "button" + :icon i/remove + :on-click on-remove-shadow + :disabled is-remove-disabled + :data-testid (str "shadow-remove-button-" shadow-idx) + :aria-label (tr "workspace.tokens.shadow-remove-shadow")}] + (for [[input-type {:keys [label placeholder]}] (shadow-inputs)] + [:> shadow-input* + {:key (str input-type shadow-idx) + :input-type input-type + :label label + :placeholder placeholder + :shadow-idx shadow-idx + :default-value (get shadow input-type) + :on-update-value on-update-value + :token-resolve-result token-resolve-result + :errors-by-key errors-by-key + :on-external-update-value on-external-update-value + :shadow-color shadow-color}])])) + +(mf/defc shadow-value-inputs* + [{:keys [default-value on-update-value token-resolve-result update-composite-value] :as props}] + (let [shadows* (mf/use-state (or default-value [{}])) + shadows (deref shadows*) + shadows-count (count shadows) + composite-token? (not (cto/composite-token-reference? (:value token-resolve-result))) + + ;; Maintain a map of color states for each shadow to prevent reset on add/remove + shadow-colors* (mf/use-state {}) + shadow-colors (deref shadow-colors*) + + ;; Initialize color states for each shadow index + _ (mf/use-effect + (mf/deps shadows) + (fn [] + (doseq [[idx shadow] (d/enumerate shadows)] + (when-not (contains? shadow-colors idx) + (let [resolved-color (get-in token-resolve-result [:resolved-value idx :color]) + initial-color (or resolved-color (get shadow :color) "")] + (swap! shadow-colors* assoc idx initial-color)))))) + + ;; Define on-external-update-value here where we have access to on-update-value + on-external-update-value + (mf/use-callback + (mf/deps on-update-value shadow-colors*) + (fn [token-type-at-index value] + (let [[idx token-type] token-type-at-index + e (js-obj)] + ;; Update shadow color state if this is a color update + (when (= token-type :color) + (swap! shadow-colors* assoc idx value)) + (obj/set! e "tokenTypeAtIndex" token-type-at-index) + (obj/set! e "target" #js {:value value}) + (on-update-value e)))) + + on-add-shadow + (mf/use-fn + (mf/deps shadows update-composite-value) + (fn [] + (update-composite-value + (fn [state] + (let [new-state (update state :composite (fnil conj []) {})] + (reset! shadows* (:composite new-state)) + new-state))))) + + on-remove-shadow + (mf/use-fn + (mf/deps shadows update-composite-value) + (fn [idx] + (update-composite-value + (fn [state] + (let [new-state (update state :composite d/remove-at-index idx)] + (reset! shadows* (:composite new-state)) + new-state)))))] + [:div {:class (stl/css :nested-input-row)} + (for [[shadow-idx shadow] (d/enumerate shadows) + :let [is-remove-disabled (= shadows-count 1) + key (str shadows-count shadow-idx) + errors-by-key (when composite-token? + (sd/collect-shadow-errors token-resolve-result shadow-idx))]] + [:div {:key key + :class (stl/css :nested-input-row)} + [:> shadow-input-fields* + {:is-remove-disabled is-remove-disabled + :shadow-idx shadow-idx + :on-add-shadow on-add-shadow + :on-remove-shadow on-remove-shadow + :shadow shadow + :on-update-value on-update-value + :token-resolve-result token-resolve-result + :errors-by-key errors-by-key + :on-external-update-value on-external-update-value + :shadow-color (get shadow-colors shadow-idx "")}]])])) + +(mf/defc shadow-form* + [{:keys [token] :rest props}] + (let [on-get-token-value + (mf/use-callback + (fn [e prev-composite-value] + (let [prev-composite-value (or prev-composite-value []) + [idx token-type :as token-type-at-index] (obj/get e "tokenTypeAtIndex") + input-value (case token-type + :inset (obj/get e "tokenValue") + (dom/get-target-val e)) + reference-value-input? (not token-type-at-index)] + (cond + reference-value-input? input-value + (and (string? input-value) (empty? input-value)) (update prev-composite-value idx dissoc token-type) + :else (assoc-in prev-composite-value token-type-at-index input-value))))) + + update-composite-backup-value + (mf/use-callback + (fn [prev-composite-value e] + (let [[idx token-type :as token-type-at-index] (obj/get e "tokenTypeAtIndex") + token-value (case token-type + :inset (obj/get e "tokenValue") + (dom/get-target-val e)) + valid? (case token-type + :inset (boolean? token-value) + (seq token-value))] + (if valid? + (assoc-in (or prev-composite-value []) token-type-at-index token-value) + ;; Remove empty values so they don't retrigger validation when switching tabs + (update prev-composite-value idx dissoc token-type)))))] + [:> composite-form* + (mf/spread-props props {:token token + :composite-tab shadow-value-inputs* + :reference-icon i/text-typography + :is-reference-fn cto/composite-token-reference? + :title (tr "workspace.tokens.shadow-title") + :validate-token validate-shadow-token + :on-get-token-value on-get-token-value + :update-composite-backup-value update-composite-backup-value})])) + +(mf/defc font-selector-wrapper* + [{:keys [font input-ref on-select-font on-close-font-selector]}] + (let [current-font* (mf/use-state (or font + (some-> (mf/ref-val input-ref) + (dom/get-value) + (cto/split-font-family) + (first) + (fonts/find-font-family)))) + current-font (deref current-font*)] + [:div {:class (stl/css :font-select-wrapper)} + [:> font-selector* {:current-font current-font + :on-select on-select-font + :on-close on-close-font-selector + :full-size true}]])) + +(mf/defc font-picker-combobox* + [{:keys [default-value label aria-label input-ref on-blur on-update-value on-external-update-value token-resolve-result placeholder]}] + (let [font* (mf/use-state (fonts/find-font-family default-value)) + font (deref font*) + set-font (mf/use-fn + (mf/deps font) + #(reset! font* %)) + + font-selector-open* (mf/use-state false) + font-selector-open? (deref font-selector-open*) + + on-close-font-selector + (mf/use-fn + (fn [] + (reset! font-selector-open* false))) + + on-click-dropdown-button + (mf/use-fn + (mf/deps font-selector-open?) + (fn [e] + (dom/prevent-default e) + (reset! font-selector-open* (not font-selector-open?)))) + + on-select-font + (mf/use-fn + (mf/deps on-external-update-value set-font font) + (fn [{:keys [family] :as font}] + (when font + (set-font font) + (on-external-update-value family)))) + + on-update-value' + (mf/use-fn + (mf/deps on-update-value set-font) + (fn [value] + (set-font nil) + (on-update-value value))) + + font-selector-button + (mf/html + [:> icon-button* + {:on-click on-click-dropdown-button + :aria-label (tr "workspace.tokens.token-font-family-select") + :icon i/arrow-down + :variant "action" + :type "button"}])] + [:* + [:> input-token* + {:placeholder (or placeholder (tr "workspace.tokens.token-font-family-value-enter")) + :label label + :aria-label aria-label + :default-value (or (:name font) default-value) + :ref input-ref + :on-blur on-blur + :on-change on-update-value' + :icon i/text-font-family + :slot-end font-selector-button + :token-resolve-result token-resolve-result}] + (when font-selector-open? + [:> font-selector-wrapper* {:font font + :input-ref input-ref + :on-select-font on-select-font + :on-close-font-selector on-close-font-selector}])])) + +(mf/defc font-family-form* + [{:keys [token] :rest props}] + (let [on-value-resolve + (mf/use-fn + (fn [value] + (when value + (cto/join-font-family value))))] + [:> form* + (mf/spread-props props {:token (when token (update token :value cto/join-font-family)) + :custom-input-token-value font-picker-combobox* + :on-value-resolve on-value-resolve + :validate-token validate-font-family-token})])) + +(mf/defc text-decoration-form* + [{:keys [token] :rest props}] + [:> form* + (mf/spread-props props {:token token + :input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")})]) + +(mf/defc font-weight-form* + [{:keys [token] :rest props}] + [:> form* + (mf/spread-props props {:token token + :input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})]) + +(def ^:private typography-inputs + #(d/ordered-map + :font-family + {:label (tr "workspace.tokens.token-font-family-value") + :icon i/text-font-family + :placeholder (tr "workspace.tokens.token-font-family-value-enter")} + :font-size + {:label "Font Size" + :icon i/text-font-size + :placeholder (tr "workspace.tokens.font-size-value-enter")} + :font-weight + {:label "Font Weight" + :icon i/text-font-weight + :placeholder (tr "workspace.tokens.font-weight-value-enter")} + :line-height + {:label "Line Height" + :icon i/text-lineheight + :placeholder (tr "workspace.tokens.line-height-value-enter")} + :letter-spacing + {:label "Letter Spacing" + :icon i/text-letterspacing + :placeholder (tr "workspace.tokens.letter-spacing-value-enter-composite")} + :text-case + {:label "Text Case" + :icon i/text-mixed + :placeholder (tr "workspace.tokens.text-case-value-enter")} + :text-decoration + {:label "Text Decoration" + :icon i/text-underlined + :placeholder (tr "workspace.tokens.text-decoration-value-enter")})) + +(mf/defc typography-value-inputs* + [{:keys [default-value on-blur on-update-value token-resolve-result]}] + (let [composite-token? (not (cto/composite-token-reference? (:value token-resolve-result))) + typography-inputs (mf/use-memo typography-inputs) + errors-by-key (sd/collect-typography-errors token-resolve-result)] + [:div {:class (stl/css :nested-input-row)} + (for [[token-type {:keys [label placeholder icon]}] typography-inputs] + (let [value (get default-value token-type) + resolved (get-in token-resolve-result [:resolved-value token-type]) + errors (get errors-by-key token-type) + + should-show? (or (and (some? resolved) + (not= value (str resolved))) + (seq errors)) + + token-prop (when (and composite-token? should-show?) + (d/without-nils + {:resolved-value (when-not (str/empty? resolved) resolved) + :errors errors})) + + input-ref (mf/use-ref) + + on-external-update-value + (mf/use-fn + (mf/deps on-update-value) + (fn [next-value] + (let [element (mf/ref-val input-ref)] + (dom/set-value! element next-value) + (on-update-value #js {:target element + :tokenType :font-family})))) + + on-change + (mf/use-fn + (mf/deps token-type) + ;; Passing token-type via event to prevent deep function adapting & passing of type + (fn [event] + (-> (obj/set! event "tokenType" token-type) + (on-update-value))))] + + [:div {:key (str token-type) + :class (stl/css :input-row)} + (case token-type + :font-family + [:> font-picker-combobox* + {:aria-label label + :placeholder placeholder + :input-ref input-ref + :default-value (when value (cto/join-font-family value)) + :on-blur on-blur + :on-update-value on-change + :on-external-update-value on-external-update-value + :token-resolve-result token-prop}] + [:> input-token* + {:aria-label label + :placeholder placeholder + :default-value value + :on-blur on-blur + :icon icon + :on-change on-change + :token-resolve-result token-prop}])]))])) + +(mf/defc typography-form* + [{:keys [token] :rest props}] + (let [on-get-token-value + (mf/use-fn + (fn [e prev-composite-value] + (let [token-type (obj/get e "tokenType") + input-value (dom/get-target-val e) + reference-value-input? (not token-type)] + (cond + reference-value-input? input-value + + (empty? input-value) (dissoc prev-composite-value token-type) + :else (assoc prev-composite-value token-type input-value))))) + + update-composite-backup-value + (mf/use-fn + (fn [prev-composite-value e] + (let [token-type (obj/get e "tokenType") + token-value (dom/get-target-val e) + token-value (cond-> token-value + (= :font-family token-type) (cto/split-font-family))] + (if (seq token-value) + (assoc prev-composite-value token-type token-value) + ;; Remove empty values so they don't retrigger validation when switching tabs + (dissoc prev-composite-value token-type)))))] + [:> composite-form* + (mf/spread-props props {:token token + :composite-tab typography-value-inputs* + :reference-icon i/text-typography + :is-reference-fn cto/composite-token-reference? + :title (tr "labels.typography") + :validate-token validate-typography-token + :on-get-token-value on-get-token-value + :update-composite-backup-value update-composite-backup-value})])) + +(mf/defc form-wrapper* + [{:keys [token token-type] :rest props}] + (let [token-type + (or (:type token) token-type) + ;; NOTE: All this references to tokens can be + ;; provided via context for + ;; avoid duplicate code among each form, this is because it is + ;; a common code and is probably will be needed on all forms + + tokens-in-selected-set + (mf/deref refs/workspace-all-tokens-in-selected-set) + + token-path + (mf/with-memo [token] + (ctob/get-token-path token)) + + tokens-tree-in-selected-set + (mf/with-memo [token-path tokens-in-selected-set] + (-> (ctob/tokens-tree tokens-in-selected-set) + (d/dissoc-in token-path))) + props + (mf/spread-props props {:token-type token-type + :validate-token default-validate-token + :tokens-tree-in-selected-set tokens-tree-in-selected-set + :token token}) + font-family-props (mf/spread-props props {:validate-token validate-font-family-token}) + typography-props (mf/spread-props props {:validate-token validate-typography-token})] + + (case token-type + :color [:> color/form* props] + :typography [:> typography/form* typography-props] + :shadow [:> shadow-form* props] + :font-family [:> font-family/form* font-family-props] + :text-case [:> text-case/form* props] + :text-decoration [:> text-decoration-form* props] + :font-weight [:> font-weight-form* props] + :border-radius [:> border-radius/form* props] + :dimensions [:> dimensions/form* props] + [:> form* props]))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/text_case.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/text_case.cljs new file mode 100644 index 0000000000..da1cba8e06 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/text_case.cljs @@ -0,0 +1,223 @@ +;; 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.create.text-case + (:require-macros [app.main.style :as stl]) + (:require + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.forms :as fc] + [app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- token-value-error-fn + [{:keys [value]}] + (when (or (str/empty? value) + (str/blank? value)) + (tr "workspace.tokens.empty-input"))) + +(defn- make-schema + [tokens-tree] + (sm/schema + [:and + [:map + [:name + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (ctob/token-name-path-exists? % tokens-tree))]]] + + [:value [::sm/text {:error/fn token-value-error-fn}]] + + [:description {:optional true} + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] + + [:fn {:error/field :value + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (when (and name value) + (not (cto/token-value-self-reference? name value))))]])) + +(mf/defc form* + [{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}] + + (let [token + (mf/with-memo [token] + (or token {:type :text-case})) + + token-type + (get token :type) + + token-properties + (dwta/get-token-properties token) + + token-title (str/lower (:title token-properties)) + + tokens + (mf/deref refs/workspace-active-theme-sets-tokens) + + tokens + (mf/with-memo [tokens token] + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (cond-> tokens + (and (:name token) (:value token)) + (assoc (:name token) token))) + + schema + (mf/with-memo [tokens-tree-in-selected-set] + (make-schema tokens-tree-in-selected-set)) + + initial + (mf/with-memo [token] + {:name (:name token "") + :value (:value token "") + :description (:description token "")}) + + form + (fm/use-form :schema schema + :initial initial) + + warning-name-change? + (not= (get-in @form [:data :name]) + (:name initial)) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id token) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-cancel e)))) + + on-submit + (mf/use-fn + (mf/deps validate-token token tokens token-type) + (fn [form _event] + (let [name (get-in @form [:clean-data :name]) + description (get-in @form [:clean-data :description]) + value (get-in @form [:clean-data :value])] + (->> (validate-token {:token-value value + :token-name name + :token-description description + :prev-token token + :tokens tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))] + + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (tr "workspace.tokens.create-token" token-type)] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :input-row)} + [:> form-input-token* + {:placeholder (tr "workspace.tokens.text-case-value-enter") + :label (tr "workspace.tokens.token-value") + :name :value + :token token + :tokens tokens}]] + + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + + [:> fc/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/typography.cljs new file mode 100644 index 0000000000..ca46b6c2b2 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/typography.cljs @@ -0,0 +1,427 @@ +;; 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.create.typography + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.main.constants :refer [max-input-length]] + [app.main.data.modal :as modal] + [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.forms :as forms] + [app.main.ui.workspace.tokens.management.create.combobox-token-fonts :refer [font-picker-composite-combobox*]] + [app.main.ui.workspace.tokens.management.create.form-input-token :refer [token-composite-value-input*]] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(mf/defc composite-form* + [{:keys [token tokens] :as props}] + (let [letter-spacing-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :letter-spacing + :value (cto/join-font-family (get value :letter-spacing))} + {:type :letter-spacing})) + + font-family-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :font-family + :value (get value :font-family)} + {:type :font-family})) + + font-size-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :font-size + :value (get value :font-size)} + {:type :font-size})) + + font-weight-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :font-weight + :value (get value :font-weight)} + {:type :font-weight})) + + ;; TODO: Review this type + line-height-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :number + :value (get value :line-height)} + {:type :number})) + + text-case-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :text-case + :value (get value :text-case)} + {:type :text-case})) + + text-decoration-sub-token + (mf/with-memo [token] + (if-let [value (get token :value)] + {:type :text-decoration + :value (get value :text-decoration)} + {:type :text-decoration}))] + + [:* + [:div {:class (stl/css :input-row)} + [:> font-picker-composite-combobox* + {:icon i/text-font-family + :placeholder (tr "workspace.tokens.token-font-family-value-enter") + :aria-label (tr "workspace.tokens.token-font-family-value") + :name :font-family + :token font-family-sub-token + :tokens tokens}]] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:aria-label "Font Size" + :icon i/text-font-size + :placeholder (tr "workspace.tokens.font-size-value-enter") + :name :font-size + :token font-size-sub-token + :tokens tokens}]] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:aria-label "Font Weight" + :icon i/text-font-weight + :placeholder (tr "workspace.tokens.font-weight-value-enter") + :name :font-weight + :token font-weight-sub-token + :tokens tokens}]] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:aria-label "Line Height" + :icon i/text-lineheight + :placeholder (tr "workspace.tokens.line-height-value-enter") + :name :line-height + :token line-height-sub-token + :tokens tokens}]] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:aria-label "Letter Spacing" + :icon i/text-letterspacing + :placeholder (tr "workspace.tokens.letter-spacing-value-enter-composite") + :name :letter-spacing + :token letter-spacing-sub-token + :tokens tokens}]] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:aria-label "Text Case" + :icon i/text-mixed + :placeholder (tr "workspace.tokens.text-case-value-enter") + :name :text-case + :token text-case-sub-token + :tokens tokens}]] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:aria-label "Text Decoration" + :icon i/text-underlined + :placeholder (tr "workspace.tokens.text-decoration-value-enter") + :name :text-decoration + :token text-decoration-sub-token + :tokens tokens}]]])) + +(mf/defc reference-form* + [{:keys [token tokens] :as props}] + [:div {:class (stl/css :input-row)} + [:> token-composite-value-input* + {:placeholder (tr "workspace.tokens.reference-composite") + :aria-label (tr "labels.reference") + :icon i/text-typography + :name :reference + :token token + :tokens tokens}]]) + +(defn- make-schema + [tokens-tree active-tab] + (sm/schema + [:and + [:map + [:name + [:and + [:string {:min 1 :max 255 + :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (sm/update-properties cto/schema:token-name assoc + :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + #(not (ctob/token-name-path-exists? % tokens-tree))]]] + + [:value + [:map + [:font-family {:optional true} [:maybe :string]] + [:font-size {:optional true} [:maybe :string]] + [:font-weight {:optional true} [:maybe :string]] + [:line-height {:optional true} [:maybe :string]] + [:letter-spacing {:optional true} [:maybe :string]] + [:text-case {:optional true} [:maybe :string]] + [:text-decoration {:optional true} [:maybe :string]] + (if (= active-tab :reference) + [:reference {:optional false} ::sm/text] + [:reference {:optional true} [:maybe :string]])]] + + [:description {:optional true} + [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] + + [:fn {:error/field [:value :reference] + :error/fn #(tr "workspace.tokens.self-reference")} + (fn [{:keys [name value]}] + (let [reference (get value :reference)] + (if (and reference name) + (not (cto/token-value-self-reference? name reference)) + true)))] + + [:fn {:error/field [:value :line-height] + :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size")} + (fn [{:keys [value]}] + (let [line-heigh (get value :line-height) + font-size (get value :font-size)] + (if (and line-heigh (not font-size)) + false + true)))] + + ;; This error does not shown on interface, it's just to avoid saving empty composite tokens + ;; We don't need to translate it. + [:fn {:error/fn (fn [_] "At least one composite field must be set") + :error/field :value} + (fn [attrs] + (let [result (reduce-kv (fn [_ _ v] + (if (str/empty? v) + false + (reduced true))) + false + (get attrs :value))] + result))]])) + +(mf/defc form* + [{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}] + + (let [token + (mf/with-memo [token] + (or token {:type :typography})) + + active-tab* (mf/use-state #(if (cfo/is-reference? token) :reference :composite)) + active-tab (deref active-tab*) + + token-type + (get token :type) + + token-properties + (dwta/get-token-properties token) + + token-title (str/lower (:title token-properties)) + + tokens + (mf/deref refs/workspace-active-theme-sets-tokens) + + tokens + (mf/with-memo [tokens token] + ;; Ensure that the resolved value uses the currently editing token + ;; even if the name has been overriden by a token with the same name + ;; in another set below. + (cond-> tokens + (and (:name token) (:value token)) + (assoc (:name token) token))) + + schema + (mf/with-memo [tokens-tree-in-selected-set active-tab] + (make-schema tokens-tree-in-selected-set active-tab)) + + initial + (mf/with-memo [token] + (let [value (:value token) + processed-value + (cond + (string? value) + {:reference value} + + (map? value) + (let [value (cond-> value + (:font-family value) + (update :font-family cto/join-font-family))] + (select-keys value + [:font-family + :font-size + :font-weight + :line-height + :letter-spacing + :text-case + :text-decoration])) + :else + {})] + + {:name (:name token "") + :value processed-value + :description (:description token "")})) + + form + (fm/use-form :schema schema + :initial initial) + + warning-name-change? + (not= (get-in @form [:data :name]) + (:name initial)) + + on-toggle-tab + (mf/use-fn + (mf/deps) + (fn [new-tab] + (let [new-tab (keyword new-tab)] + (reset! active-tab* new-tab)))) + + on-cancel + (mf/use-fn + (fn [e] + (dom/prevent-default e) + (modal/hide!))) + + on-delete-token + (mf/use-fn + (mf/deps selected-token-set-id token) + (fn [e] + (dom/prevent-default e) + (modal/hide!) + (st/emit! (dwtl/delete-token selected-token-set-id (:id token))))) + + handle-key-down-delete + (mf/use-fn + (mf/deps on-delete-token) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-delete-token e)))) + + handle-key-down-cancel + (mf/use-fn + (mf/deps on-cancel) + (fn [e] + (when (or (k/enter? e) (k/space? e)) + (on-cancel e)))) + + on-submit + (mf/use-fn + (mf/deps validate-token token tokens token-type) + (fn [form _event] + (let [name (get-in @form [:clean-data :name]) + description (get-in @form [:clean-data :description]) + value (get-in @form [:clean-data :value])] + + (->> (validate-token {:token-value (if (contains? value :reference) + (get value :reference) + value) + :token-name name + :token-description description + :prev-token token + :tokens tokens}) + (rx/subs! + (fn [valid-token] + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtp/propagate-workspace-tokens) + (modal/hide))))))))] + + [:> forms/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} + + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (tr "workspace.tokens.create-token" token-type)] + + [:div {:class (stl/css :input-row)} + [:> forms/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] + + [:div {:class (stl/css :title-bar)} + [:div {:class (stl/css :title)} (tr "labels.typography")] + [:& radio-buttons {:class (stl/css :listing-options) + :selected (d/name active-tab) + :on-change on-toggle-tab + :name "reference-composite-tab"} + [:& radio-button {:icon i/layers + :value "composite" + :title (tr "workspace.tokens.individual-tokens") + :id "composite-opt"}] + [:& radio-button {:icon i/tokens + :value "reference" + :title (tr "workspace.tokens.use-reference") + :id "reference-opt"}]]] + [:div {:class (stl/css :inputs-wrapper)} + (if (= active-tab :composite) + [:> composite-form* {:token token + :tokens tokens}] + + [:> reference-form* {:token token + :tokens tokens}])] + + [:div {:class (stl/css :input-row)} + [:> forms/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] + + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) + + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] + + [:> forms/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs index ee1de768ec..e86ea8c8d8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs @@ -6,9 +6,9 @@ (ns app.main.ui.workspace.tokens.management.forms.color (:require - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.util.i18n :refer [tr]] @@ -29,9 +29,9 @@ [:name [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-tree))]]] [:value [::sm/text {:error/fn token-value-error-fn}]] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs index 70797979c6..17a441fbcb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs @@ -28,7 +28,7 @@ token-path (mf/with-memo [token] - (cft/token-name->path (:name token))) + (ctob/get-token-path token)) tokens-tree-in-selected-set (mf/with-memo [token-path tokens-in-selected-set] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index 098f738010..ceba08c626 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -7,7 +7,7 @@ (ns app.main.ui.workspace.tokens.management.forms.generic-form (:require-macros [app.main.style :as stl]) (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.schema :as sm] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] @@ -39,7 +39,6 @@ (str/blank? value)) (tr "workspace.tokens.empty-input"))) - (defn get-value-for-validator [active-tab value value-subfield form-type] @@ -64,9 +63,9 @@ [:name [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-tree))]]] [:value [::sm/text {:error/fn token-value-error-fn}]] @@ -77,7 +76,7 @@ :error/fn #(tr "workspace.tokens.self-reference")} (fn [{:keys [name value]}] (when (and name value) - (nil? (cto/token-value-self-reference? name value))))]])) + (not (cto/token-value-self-reference? name value))))]])) (mf/defc form* [{:keys [token @@ -98,7 +97,7 @@ input-component (or input-component token.controls/input*) validate-token (or validator default-validate-token) - active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite)) + active-tab* (mf/use-state #(if (cfo/is-reference? token) :reference :composite)) active-tab (deref active-tab*) on-toggle-tab diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 880d957167..33a562fef6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -8,9 +8,9 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] @@ -50,7 +50,7 @@ ;; Entering form without a value - show no error just resolve nil (nil? token-value) (rx/of nil) ;; Validate refrence string - (cto/shadow-composite-token-reference? token-value) (default-validate-token params) + (cto/composite-token-reference? token-value) (default-validate-token params) ;; Validate composite token :else (let [params (-> params @@ -261,10 +261,10 @@ [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-tree))]]] [:value [:map diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 1db82e390d..c75fd1402d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -8,9 +8,9 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.ds.foundations.assets.icon :as i] @@ -46,7 +46,7 @@ ;; Entering form without a value - show no error just resolve nil (nil? token-value) (rx/of nil) ;; Validate refrence string - (cto/typography-composite-token-reference? token-value) (default-validate-token props) + (cto/composite-token-reference? token-value) (default-validate-token props) ;; Validate composite token :else (-> props @@ -216,10 +216,10 @@ [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc + (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-tree))]]] [:value [:map diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index f17156dd45..036887f307 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -1,7 +1,6 @@ (ns app.main.ui.workspace.tokens.management.forms.validators (:require [app.common.data :as d] - [app.common.files.tokens :as cft] [app.common.schema :as sm] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] @@ -29,7 +28,7 @@ ;; When creating a new token we dont have a name yet or invalid name, ;; but we still want to resolve the value to show in the form. ;; So we use a temporary token name that hopefully doesn't clash with any of the users token names - (not (sm/valid? cto/token-name-ref (:name token))) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__")) + (not (sm/valid? cto/schema:token-name (:name token))) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__")) tokens' (cond-> tokens ;; Remove previous token when renaming a token (not= (:name token) (:name prev-token)) @@ -89,23 +88,3 @@ [token-name token-vals] (when (some #(cto/token-value-self-reference? token-name %) token-vals) (wte/get-error-code :error.token/direct-self-reference))) - - - -;; This is used in plugins - -(defn- make-token-name-schema - "Generate a dynamic schema validation to check if a token path derived - from the name already exists at `tokens-tree`." - [tokens-tree] - [:and - [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] - (sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) - [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (cft/token-name-path-exists? % tokens-tree))]]) - -(defn validate-token-name - [tokens-tree name] - (let [schema (make-token-name-schema tokens-tree) - explainer (sm/explainer schema)] - (-> name explainer sm/simplify not-empty))) \ No newline at end of file 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 dd001e187c..6dc9f0d61a 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 @@ -10,7 +10,7 @@ [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.path-names :as cpn] [app.common.types.token :as ctt] [app.main.data.workspace.tokens.application :as dwta] @@ -156,9 +156,9 @@ (defn- applied-all-attributes? [token selected-shapes attributes] - (let [ids-by-attributes (cft/shapes-ids-by-applied-attributes token selected-shapes attributes) + (let [ids-by-attributes (cfo/shapes-ids-by-applied-attributes token selected-shapes attributes) shape-ids (into #{} xf:map-id selected-shapes)] - (cft/shapes-applied-all? ids-by-attributes shape-ids attributes))) + (cfo/shapes-applied-all? ids-by-attributes shape-ids attributes))) (defn attributes-match-selection? [selected-shapes attrs & {:keys [selected-inside-layout?]}] @@ -178,7 +178,7 @@ (let [{:keys [name value errors type]} token has-selected? (pos? (count selected-shapes)) - is-reference? (cft/is-reference? token) + is-reference? (cfo/is-reference? token) contains-path? (str/includes? name ".") attributes (as-> (get dwta/token-properties type) $ @@ -191,7 +191,7 @@ applied? (if has-selected? - (cft/shapes-token-applied? token selected-shapes attributes) + (cfo/shapes-token-applied? token selected-shapes attributes) false) half-applied? @@ -219,7 +219,7 @@ no-valid-value) color - (when (cft/color-token? token) + (when (cfo/color-token? token) (let [theme-token (get active-theme-tokens name)] (or (dwtc/resolved-token-bullet-color theme-token) (dwtc/resolved-token-bullet-color token)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.cljs b/frontend/src/app/main/ui/workspace/tokens/sets.cljs index d530394b99..aa96f1b28b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets.cljs @@ -62,7 +62,8 @@ (st/emit! (dwtl/start-token-set-edition id)))))] [:> controlled-sets-list* - {:token-sets token-sets + {:tokens-lib tokens-lib + :token-sets token-sets :is-token-set-active token-set-active? :is-token-set-group-active token-set-group-active? @@ -79,6 +80,6 @@ :on-toggle-token-set on-toggle-token-set-click :on-toggle-token-set-group on-toggle-token-set-group-click - :on-update-token-set sets-helpers/on-update-token-set + :on-update-token-set (partial sets-helpers/on-update-token-set tokens-lib) :on-update-token-set-group sets-helpers/on-update-token-set-group :on-create-token-set sets-helpers/on-create-token-set}])) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs index ab6331bd47..79660e7e1f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs @@ -1,9 +1,13 @@ (ns app.main.ui.workspace.tokens.sets.helpers (:require + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] [app.common.types.tokens-lib :as ctob] [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] + [app.util.i18n :refer [tr]] [potok.v2.core :as ptk])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -11,9 +15,18 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn on-update-token-set - [token-set name] - (st/emit! (dwtl/clear-token-set-edition) - (dwtl/update-token-set token-set name))) + [tokens-lib token-set name] + (let [name (ctob/normalize-set-name name) + errors (sm/validation-errors name (cfo/make-token-set-name-schema + tokens-lib + (ctob/get-id token-set)))] + (st/emit! (dwtl/clear-token-set-edition)) + (if (empty? errors) + (st/emit! (dwtl/rename-token-set token-set name)) + (st/emit! (ntf/show {:content (tr "errors.token-set-already-exists") + :type :toast + :level :error + :timeout 9000}))))) (defn on-update-token-set-group [path name] @@ -21,15 +34,15 @@ (dwtl/rename-token-set-group path name))) (defn on-create-token-set - [parent-set name] - (let [;; FIXME: this code should be reusable under helper under - ;; common types namespace - name - (if-let [parent-path (ctob/get-set-path parent-set)] - (->> (concat parent-path (ctob/split-set-name name)) - (ctob/join-set-path)) - (ctob/normalize-set-name name)) - token-set (ctob/make-token-set :name name)] - + [tokens-lib parent-set name] + (let [name (ctob/make-child-name parent-set name) + errors (sm/validation-errors name (cfo/make-token-set-name-schema tokens-lib nil))] (st/emit! (ptk/data-event ::ev/event {::ev/name "create-token-set" :name name}) - (dwtl/create-token-set token-set)))) + (dwtl/clear-token-set-creation)) + (if (empty? errors) + (let [token-set (ctob/make-token-set :name name)] + (st/emit! (dwtl/create-token-set token-set))) + (st/emit! (ntf/show {:content (tr "errors.token-set-already-exists") + :type :toast + :level :error + :timeout 9000}))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs index 89d5fa4895..ad510bc7d6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs @@ -321,6 +321,7 @@ on-select on-toggle-set on-toggle-set-group + tokens-lib token-sets new-path edition-id]}] @@ -408,7 +409,7 @@ :on-drop on-drop :on-reset-edition on-reset-edition - :on-edit-submit sets-helpers/on-create-token-set}] + :on-edit-submit (partial sets-helpers/on-create-token-set tokens-lib)}] :else [:> sets-tree-set* @@ -434,7 +435,8 @@ :on-edit-submit on-edit-submit-set}]))))) (mf/defc controlled-sets-list* - [{:keys [token-sets + [{:keys [tokens-lib + token-sets selected on-update-token-set on-update-token-set-group @@ -486,6 +488,7 @@ {:is-draggable draggable? :new-path new-path :edition-id edition-id + :tokens-lib tokens-lib :token-sets token-sets :selected selected :on-select on-select diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs index 7df19b02c8..35b666d1e4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs @@ -7,8 +7,11 @@ (ns app.main.ui.workspace.tokens.themes.create-modal (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] [app.common.logic.tokens :as clt] + [app.common.schema :as sm] [app.common.types.tokens-lib :as ctob] [app.main.constants :refer [max-input-length]] [app.main.data.event :as ev] @@ -30,32 +33,9 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as k] [cuerdas.core :as str] - [malli.core :as m] - [malli.error :as me] [potok.v2.core :as ptk] [rumext.v2 :as mf])) -;; Schemas --------------------------------------------------------------------- - -(defn- theme-name-schema - "Generate a dynamic schema validation to check if a theme path derived from the name already exists at `tokens-tree`." - [{:keys [group theme-id tokens-lib]}] - (m/-simple-schema - {:type :token/name-exists - :pred (fn [name] - (if tokens-lib - (let [theme (ctob/get-theme-by-name tokens-lib group name)] - (or (nil? theme) - (= (ctob/get-id theme) theme-id))) - true)) ;; if still no library exists, cannot be duplicate - :type-properties {:error/fn #(tr "workspace.tokens.theme-name-already-exists")}})) - -(defn validate-theme-name - [tokens-lib group theme-id name] - (let [schema (theme-name-schema {:tokens-lib tokens-lib :theme-id theme-id :group group}) - validation (m/explain schema (str/trim name))] - (me/humanize validation))) - ;; Form Component -------------------------------------------------------------- (mf/defc empty-themes @@ -199,26 +179,43 @@ theme-groups) current-group* (mf/use-state (:group theme)) current-group (deref current-group*) + current-name* (mf/use-state (:name theme)) + current-name (deref current-name*) + group-errors* (mf/use-state nil) + group-errors (deref group-errors*) name-errors* (mf/use-state nil) name-errors (deref name-errors*) on-update-group (mf/use-fn - (mf/deps on-change-field) + (mf/deps on-change-field tokens-lib current-name) (fn [value] - (reset! current-group* value) - (on-change-field :group value))) + (let [errors (sm/validation-errors value (cfo/make-token-theme-group-schema + tokens-lib + current-name + (ctob/get-id theme)))] + (reset! group-errors* errors) + (if (empty? errors) + (do + (reset! current-group* value) + (on-change-field :group value)) + (on-change-field :group ""))))) on-update-name (mf/use-fn (mf/deps on-change-field tokens-lib current-group) (fn [event] (let [value (-> event dom/get-target dom/get-value) - errors (validate-theme-name tokens-lib current-group (ctob/get-id theme) value)] + errors (sm/validation-errors value (cfo/make-token-theme-name-schema + tokens-lib + current-group + (ctob/get-id theme)))] (reset! name-errors* errors) (mf/set-ref-val! theme-name-ref value) (if (empty? errors) - (on-change-field :name value) + (do + (reset! current-name* value) + (on-change-field :name value)) (on-change-field :name "")))))] [:div {:class (stl/css :edit-theme-inputs-wrapper)} @@ -228,6 +225,7 @@ :placeholder (tr "workspace.tokens.label.group-placeholder") :default-selected (:group theme) :options (clj->js options) + :has-error (d/not-empty? group-errors) :on-change on-update-group}]] [:div {:class (stl/css :group-input-wrapper)} @@ -280,6 +278,7 @@ (mf/defc edit-create-theme* [{:keys [change-view theme on-save is-editing has-prev-view]}] (let [ordered-token-sets (mf/deref refs/workspace-ordered-token-sets) + tokens-lib (mf/deref refs/tokens-lib) token-sets (mf/deref refs/workspace-token-sets-tree) current-theme* (mf/use-state theme) @@ -381,7 +380,8 @@ [:div {:class (stl/css :sets-list-wrapper)} [:> wts/controlled-sets-list* - {:token-sets token-sets + {:tokens-lib tokens-lib + :token-sets token-sets :is-token-set-active token-set-active? :is-token-set-group-active token-set-group-active? :on-select on-click-token-set diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index a3737d706d..67e7d7d8dc 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -7,18 +7,20 @@ (ns app.plugins.tokens (:require [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] + [app.common.schema :as sm] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] - [app.main.ui.workspace.tokens.management.forms.validators :as form-validator] - [app.main.ui.workspace.tokens.themes.create-modal :as theme-form] [app.plugins.utils :as u] [app.util.object :as obj] [clojure.datafy :refer [datafy]])) +;; === Token + (defn- apply-token-to-shapes [file-id set-id id shape-ids attrs] (let [token (u/locate-token file-id set-id id) @@ -50,15 +52,13 @@ (ctob/get-name token))) :set (fn [_ value] - (let [tokens-lib (u/locate-tokens-lib file-id) - errors (form-validator/validate-token-name - (ctob/get-tokens tokens-lib set-id) - value)] - (cond - (some? errors) - (u/display-not-valid :name (first errors)) - - :else + (let [name (u/coerce-1 value + (cfo/make-token-name-schema + (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens set-id))) + :name + "Invalid token name")] + (when name (st/emit! (dwtl/update-token set-id id {:name value})))))} :type @@ -84,6 +84,11 @@ :duplicate (fn [] + ;; TODO: + ;; - add function duplicate-token in tokens-lib, that allows to specify the new id + ;; - use this function in dwtl/duplicate-token + ;; - return the new token proxy using the locally forced id + ;; - do the same with sets and themes (let [token (u/locate-token file-id set-id id) token' (ctob/make-token (-> (datafy token) (dissoc :id @@ -104,6 +109,9 @@ (let [selected (get-in @st/state [:workspace-local :selected])] (apply-token-to-shapes file-id set-id id selected attrs))))) + +;; === Token Set + (defn token-set-proxy [plugin-id file-id id] (obj/reify {:name "TokenSetProxy"} @@ -122,13 +130,15 @@ (ctob/get-name set))) :set (fn [_ value] - (let [set (u/locate-token-set file-id id)] - (cond - (not (string? value)) - (u/display-not-valid :name value) - - :else - (st/emit! (dwtl/update-token-set set value)))))} + (let [set (u/locate-token-set file-id id) + name (u/coerce-1 value + (cfo/make-token-set-name-schema + (u/locate-tokens-lib file-id) + id) + :setTokenSet + "Invalid token set name")] + (when name + (st/emit! (dwtl/rename-token-set set name)))))} :active {:this true @@ -140,8 +150,13 @@ (ctob/token-set-active? tokens-lib (ctob/get-name set)))) :set (fn [_ value] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))} + (let [value (u/coerce-1 value + (sm/schema [:boolean]) + :setActiveSet + value)] + (when (some? value) + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))))} :toggleActive (fn [_] @@ -153,8 +168,7 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib)] + (let [tokens-lib (u/locate-tokens-lib file-id)] (->> (ctob/get-tokens tokens-lib id) (vals) (map #(token-proxy plugin-id file-id id (:id %))) @@ -165,8 +179,7 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib) + (let [tokens-lib (u/locate-tokens-lib file-id) tokens (ctob/get-tokens tokens-lib id)] (->> tokens (vals) @@ -193,30 +206,20 @@ (token-proxy plugin-id file-id id token-id))))) :addToken - (fn [type-str name value] - (let [type (cto/dtcg-token-type->token-type type-str)] - (cond - (nil? type) - (u/display-not-valid :addTokenType type-str) - - (not (string? name)) - (u/display-not-valid :addTokenName name) - - :else - (let [token (ctob/make-token {:type type - :name name - :value value})] + (fn [attrs] + (let [schema (-> (sm/schema (cfo/make-token-schema + (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id)))) + (sm/dissoc-key :id)) ;; We don't allow plugins to set the id + attrs (u/coerce attrs schema :addToken "invalid token attrs")] + (when attrs + (let [token (ctob/make-token attrs)] (st/emit! (dwtl/create-token id token)) (token-proxy plugin-id file-id (:id set) (:id token)))))) :duplicate (fn [] - (let [set (u/locate-token-set file-id id) - set' (ctob/make-token-set (-> (datafy set) - (dissoc :id - :modified-at)))] - (st/emit! (dwtl/create-token-set set')) - (token-set-proxy plugin-id file-id (:id set')))) + (st/emit! (dwtl/duplicate-token-set id))) :remove (fn [] @@ -247,12 +250,15 @@ (:group theme))) :set (fn [_ value] - (let [theme (u/locate-token-theme file-id id)] - (cond - (not (string? value)) - (u/display-not-valid :group value) - - :else + (let [theme (u/locate-token-theme file-id id) + group (u/coerce-1 value + (cfo/make-token-theme-group-schema + (u/locate-tokens-lib file-id) + (:name theme) + (:id theme)) + :group + "Invalid token theme group")] + (when group (st/emit! (dwtl/update-token-theme id (assoc theme :group value))))))} :name @@ -264,16 +270,14 @@ :set (fn [_ value] (let [theme (u/locate-token-theme file-id id) - errors (theme-form/validate-theme-name - (u/locate-tokens-lib file-id) - (:group theme) - id - value)] - (cond - (some? errors) - (u/display-not-valid :name (first errors)) - - :else + name (u/coerce-1 value + (cfo/make-token-theme-name-schema + (u/locate-tokens-lib file-id) + (:id theme) + (:group theme)) + :name + "Invalid token theme name")] + (when name (st/emit! (dwtl/update-token-theme id (assoc theme :name value))))))} :active @@ -328,8 +332,7 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib) + (let [tokens-lib (u/locate-tokens-lib file-id) themes (->> (ctob/get-themes tokens-lib) (remove #(= (:id %) uuid/zero)))] (apply array (map #(token-theme-proxy plugin-id file-id (ctob/get-id %)) themes))))} @@ -339,36 +342,36 @@ :enumerable false :get (fn [_] - (let [file (u/locate-file file-id) - tokens-lib (->> file :data :tokens-lib) + (let [tokens-lib (u/locate-tokens-lib file-id) sets (ctob/get-sets tokens-lib)] (apply array (map #(token-set-proxy plugin-id file-id (ctob/get-id %)) sets))))} :addTheme - (fn [group name] - (cond - (not (string? group)) - (u/display-not-valid :addThemeGroup group) - - (not (string? name)) - (u/display-not-valid :addThemeName name) - - :else - (let [theme (ctob/make-token-theme {:group group - :name name})] - (st/emit! (dwtl/create-token-theme theme)) - (token-theme-proxy plugin-id file-id (:id theme))))) + (fn [attrs] + (let [schema (-> (sm/schema (cfo/make-token-theme-schema + (u/locate-tokens-lib file-id) + (or (obj/get attrs "group") "") + (or (obj/get attrs "name") "") + nil)) + (sm/dissoc-key :id)) ;; We don't allow plugins to set the id + attrs (u/coerce attrs schema :addTheme "invalid theme attrs")] + (when attrs + (let [theme (ctob/make-token-theme attrs)] + (st/emit! (dwtl/create-token-theme theme)) + (token-theme-proxy plugin-id file-id (:id theme)))))) :addSet - (fn [name] - (cond - (not (string? name)) - (u/display-not-valid :addSetName name) - - :else - (let [set (ctob/make-token-set {:name name})] - (st/emit! (dwtl/create-token-set set)) - (token-set-proxy plugin-id file-id (:id set))))) + (fn [attrs] + (obj/update! attrs "name" ctob/normalize-set-name) ;; TODO: seems a quite weird way of doing this + (let [schema (-> (sm/schema (cfo/make-token-set-schema + (u/locate-tokens-lib file-id) + nil)) + (sm/dissoc-key :id)) ;; We don't allow plugins to set the id + attrs (u/coerce attrs schema :addSet "invalid set attrs")] + (when attrs + (let [set (ctob/make-token-set attrs)] + (st/emit! (dwtl/create-token-set set)) + (token-set-proxy plugin-id file-id (ctob/get-id set)))))) :getThemeById (fn [theme-id] diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 907a8dd352..bec1a5e710 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,12 +9,15 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.json :as json] + [app.common.schema :as sm] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn locate-file [id] @@ -218,7 +221,8 @@ (defn display-not-valid [code value] - (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) + (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)) + nil) (defn reject-not-valid [reject code value] @@ -226,6 +230,26 @@ (.error js/console msg) (reject msg))) +(defn coerce + "Decodes a javascript object into clj and check against schema. If schema validation fails, + displays a not-valid message with the code and hint provided and returns nil." + [attrs schema code hint] + (let [decoder (sm/decoder schema sm/json-transformer) + explainer (sm/explainer schema) + attrs (-> attrs json/->clj decoder)] + (if-let [explain (explainer attrs)] + (display-not-valid code (str hint " " (sm/humanize-explain explain))) + attrs))) + +(defn coerce-1 + "Checks a single javascript value against schema. If schema validation fails, + displays a not-valid message with the code and hint provided and returns nil." + [value schema code hint] + (let [errors (sm/validation-errors value schema)] + (if (d/not-empty? errors) + (display-not-valid code (str hint " " (str/join ", " errors))) + value))) + (defn mixed-value [values] (let [s (set values)] diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 3b43ca3b29..d0154c1735 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -8,6 +8,7 @@ "A i18n foundation." (:require [app.common.data :as d] + [app.common.i18n] [app.common.logging :as log] [app.common.time :as ct] [app.config :as cf] @@ -212,3 +213,7 @@ cv (get cv :locale)] (when (not= pv cv) (ct/set-default-locale! cv))))) + +;; We set the real translation function in the common i18n namespace, +;; so that when common code calls (tr ...) it uses this function. +(set! app.common.i18n/tr tr) diff --git a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs index 952e5a8288..63ffa83957 100644 --- a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs @@ -6,7 +6,7 @@ (ns frontend-tests.tokens.helpers.tokens (:require - [app.common.files.tokens :as cft] + [app.common.files.tokens :as cfo] [app.common.test-helpers.ids-map :as thi] [app.common.types.tokens-lib :as ctob])) @@ -20,7 +20,7 @@ (let [first-page-id (get-in file [:data :pages 0]) shape-id (thi/id shape-label) token (get-token file token-label) - applied-attributes (cft/attributes-map attributes token)] + applied-attributes (cfo/attributes-map attributes token)] (update-in file [:data :pages-index first-page-id :objects shape-id diff --git a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs index c7f6a2e59c..94e61f382b 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -57,8 +57,7 @@ store (ths/setup-store file) tokens-lib (toht/get-tokens-lib file) set-a (ctob/get-set-by-name tokens-lib "Set A") - events [(dwtl/update-token-set (ctob/rename set-a "Set A updated") - "Set A updated")]] + events [(dwtl/rename-token-set set-a "Set A updated")]] (tohs/run-store-async store done events