♻️ Move the fn to the helpers page

This commit is contained in:
Eva Marco 2026-06-17 11:20:25 +02:00
parent c96c84af5c
commit 0fa3bbdb12
18 changed files with 139 additions and 136 deletions

View File

@ -166,18 +166,49 @@
(not (ctob/token-name-path-exists? token-name tokens-tree)))
new-tokens))))]])
(defn token-circular-reference?
"Checks if the given `tokens` map contains a circular reference reachable from
`token-name`. Uses DFS with 3-color marking (:in-progress / :done) to detect
cycles without false positives on diamond dependencies (A->B, A->C, B->C).
Returns the token name that closes the cycle, or nil."
[tokens token-name]
(let [find-refs (fn [v] (when (string? v) (cto/find-token-value-references v)))
state (atom {})]
(letfn [(visit [name]
(let [mark (get @state name)]
(if (= mark :in-progress)
name
(when-not (= mark :done)
(swap! state assoc name :in-progress)
(let [token (get tokens name)
result (when token
(let [refs (find-refs (:value token))]
(some visit refs)))]
(swap! state assoc name :done)
result)))))]
(let [token (get tokens token-name)]
(when token
(let [refs (find-refs (:value token))]
(some visit refs)))))))
(def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-schema
[tokens-tree token-type]
[tokens-tree token-type current-token-path]
[:and
(sm/merge
cto/schema:token-attrs
[:map
[:name (make-token-name-schema tokens-tree)]
[:name (make-token-name-schema (-> tokens-tree
(d/dissoc-in current-token-path)))]
[:value (make-token-value-schema token-type)]
[:description {:optional true} schema:token-description]])
[:fn {:error/field :value
:error/fn #(tr "errors.tokens.circular-reference")}
(fn [{:keys [name]}]
(when name
(not (token-circular-reference? tokens-tree name))))]
[:fn {:error/field :value
:error/fn #(tr "errors.tokens.self-reference")}
(fn [{:keys [name value]}]

View File

@ -48,32 +48,6 @@
self-reference? (get token-references token-name)]
(boolean self-reference?)))
;; TODO: Review this function when tokenscript is implemented.
(defn token-circular-reference?
"Checks if the given `tokens` map contains a circular reference reachable from
`token-name`. Uses DFS with 3-color marking (:in-progress / :done) to detect
cycles without false positives on diamond dependencies (A->B, A->C, B->C).
Returns the token name that closes the cycle, or nil."
[tokens token-name]
(let [find-refs (fn [v] (when (string? v) (find-token-value-references v)))
state (atom {})]
(letfn [(visit [name]
(let [mark (get @state name)]
(if (= mark :in-progress)
name
(when-not (= mark :done)
(swap! state assoc name :in-progress)
(let [token (get tokens name)
result (when token
(let [refs (find-refs (:value token))]
(some visit refs)))]
(swap! state assoc name :done)
result)))))]
(let [token (get tokens token-name)]
(when token
(let [refs (find-refs (:value token))]
(some visit refs)))))))
(defn references-token?
"Recursively check if a value references the token name. Handles strings, maps, and sequences."
[value token-name]

View File

@ -2351,6 +2351,8 @@ test.describe("Tokens tab - edition", () => {
// Fill in values for all fields and verify they persist when switching tabs
await fontSizeField.fill("16");
await page.waitForTimeout(500);
await expect(saveButton).toBeEnabled();
const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", {

View File

@ -44,6 +44,10 @@
{:error/code :error.token/direct-self-reference
:error/fn #(tr "errors.tokens.self-reference")}
:error.token/circular-reference
{:error/code :error.token/circular-reference
:error/fn #(tr "errors.tokens.circular-reference")}
:error.token/invalid-color
{:error/code :error.token/invalid-color
:error/fn #(str (tr "errors.tokens.invalid-color" %))}

View File

@ -13,7 +13,7 @@
[rumext.v2 :as mf]))
(mf/defc form*
[{:keys [token token-type] :as props}]
[{:keys [token token-type current-token-path] :as props}]
(let [initial
(mf/with-memo [token-type token]
{:type token-type
@ -22,7 +22,7 @@
:description (:description token "")
:color-result ""})
props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 token-type)
props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 token-type current-token-path)
(sm/dissoc-key :id)
(sm/assoc-key :color-result :string))
:initial initial

View File

@ -73,20 +73,18 @@
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(if (cto/token-circular-reference? tokens (:name token))
(rx/of {:error (wte/error-with-value :error.token/direct-self-reference nil)})
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)})))))))))
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(defn- hex->color-obj
[hex]

View File

@ -50,22 +50,20 @@
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(if (cto/token-circular-reference? tokens (:name token))
(rx/of {:error (wte/error-with-value :error.token/direct-self-reference nil)})
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors
(first errors)
(wte/error-with-value :error/unknown value))})))))))))
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors
(first errors)
(wte/error-with-value :error/unknown value))}))))))))
(mf/defc value-combobox*
[{:keys [name tokens token token-type empty-to-end ref] :rest props}]

View File

@ -68,22 +68,20 @@
tokens
(update tokens (:name token) #(ctob/make-token (merge % prev-token token)))]
(if (cto/token-circular-reference? tokens (:name token))
(rx/of {:error (wte/error-with-value :error.token/direct-self-reference nil)})
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors
(first errors)
(wte/error-with-value :error/unknown value))})))))))))
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors
(first errors)
(wte/error-with-value :error/unknown value))}))))))))
(mf/defc fonts-combobox*
[{:keys [token tokens name] :rest props}]

View File

@ -171,22 +171,19 @@
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(if (cto/token-circular-reference? tokens (:name token))
(rx/of {:error (wte/error-with-value :error.token/direct-self-reference nil)})
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors
(first errors)
(wte/error-with-value :error/unknown value))})))))))))
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (if errors
(first errors)
(wte/error-with-value :error/unknown value))}))))))))
(mf/defc input*
[{:keys [name tokens token] :rest props}]
@ -328,7 +325,7 @@
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if (or extra-error (and touched? error))
(if (or extra-error (and touched? error) (and (= :line-height input-name) error))
(mf/spread-props props {:hint-type "error"
:hint-message (:message (or error extra-error))})
props)

View File

@ -29,7 +29,7 @@
(default-validate-token)))
(mf/defc form*
[{:keys [token token-type] :rest props}]
[{:keys [token token-type current-token-path] :rest props}]
(let [token
(mf/with-memo [token]
(if token
@ -37,7 +37,7 @@
{:type token-type}))
props (mf/spread-props props {:token token
:token-type token-type
:make-schema #(-> (cfo/make-token-schema %1 token-type)
:make-schema #(-> (cfo/make-token-schema %1 token-type current-token-path)
(sm/dissoc-key :id)
;; The value as edited in the form is a simple stirng.
;; It's converted to vector in the validator.

View File

@ -6,7 +6,6 @@
(ns app.main.ui.workspace.tokens.management.forms.form-container
(:require
[app.common.data :as d]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.refs :as refs]
@ -35,9 +34,8 @@
(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)))
(mf/with-memo [tokens-in-selected-set]
(ctob/tokens-tree tokens-in-selected-set))
props
(mf/spread-props props {:token-type token-type
@ -48,7 +46,8 @@
props
(if (contains? cf/flags :token-combobox)
(mf/spread-props props {:input-component token.controls/value-combobox*})
(mf/spread-props props {:input-component token.controls/value-combobox*
:current-token-path token-path})
props)
text-case-props
@ -68,5 +67,4 @@
:text-case [:> generic/form* text-case-props]
:text-decoration [:> generic/form* text-decoration-props]
:font-weight [:> generic/form* font-weight-props]
:border-radius [:> generic/form* props]
[:> generic/form* props])))

View File

@ -77,9 +77,10 @@
initial-errors
value-type
value-subfield
input-value-placeholder] :as props}]
input-value-placeholder
current-token-path] :as props}]
(let [make-schema (or make-schema #(-> (cfo/make-token-schema % token-type)
(let [make-schema (or make-schema #(-> (cfo/make-token-schema % token-type current-token-path)
(sm/dissoc-key :id)))
input-component (or input-component token.controls/input*)
validate-token (or validator default-validate-token)

View File

@ -260,7 +260,7 @@
;; TODO: use cfo/make-schema:token-value and extend it with shadow and reference fields
(defn- make-schema
[tokens-tree active-tab]
[current-token-path tokens-tree active-tab]
(sm/schema
[:and
[:map
@ -271,7 +271,8 @@
(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))]]]
#(not (ctob/token-name-path-exists? % (-> tokens-tree
(d/dissoc-in current-token-path))))]]]
[:value
[:map
@ -349,7 +350,8 @@
(mf/defc form*
[{:keys [token
token-type] :as props}]
token-type
current-token-path] :as props}]
(let [token
(mf/with-memo [token]
(or token
@ -372,7 +374,7 @@
props (mf/spread-props props {:token token
:token-type token-type
:initial initial
:make-schema make-schema
:make-schema (partial make-schema current-token-path)
:value-type :indexed
:value-subfield :shadow
:input-component tabs-wrapper*

View File

@ -8,9 +8,9 @@
(: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.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]
@ -209,19 +209,12 @@
;; TODO: use cfo/make-schema:token-value and extend it with typography and reference fields
(defn- make-schema
[tokens-tree active-tab]
[current-token-path 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))]]]
[:name (cfo/make-token-name-schema (-> tokens-tree
(d/dissoc-in current-token-path)))]
[:value
[:map
[:font-family {:optional true} [:maybe :string]]
@ -269,7 +262,7 @@
result))]]))
(mf/defc form*
[{:keys [token] :as props}]
[{:keys [token current-token-path] :as props}]
(let [initial
(mf/with-memo [token]
(let [value (:value token)
@ -297,7 +290,7 @@
:value processed-value
:description (:description token "")}))
props (mf/spread-props props {:initial initial
:make-schema make-schema
:make-schema (partial make-schema current-token-path)
:token token
:validator validate-typography-token
:value-type :composite

View File

@ -39,22 +39,20 @@
:always
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(if (cto/token-circular-reference? tokens' (:name token))
(rx/throw {:errors [(wte/get-error-code :error.token/direct-self-reference)]})
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens'))
(sd/resolve-tokens-interactive tokens'))
(rx/mapcat
(fn [resolved-tokens]
(let [resolved-token (cond-> (get resolved-tokens (:name token))
(contains? cf/flags :tokenscript)
(update :resolved-value ts/tokenscript-symbols->penpot-unit))]
(cond
(:resolved-value resolved-token)
(rx/of resolved-token)
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens'))
(sd/resolve-tokens-interactive tokens'))
(rx/mapcat
(fn [resolved-tokens]
(let [resolved-token (cond-> (get resolved-tokens (:name token))
(contains? cf/flags :tokenscript)
(update :resolved-value ts/tokenscript-symbols->penpot-unit))]
(cond
(:resolved-value resolved-token)
(rx/of resolved-token)
:else (rx/throw {:errors (or (seq (:errors resolved-token))
[(wte/get-error-code :error/unknown-error)])})))))))))
:else (rx/throw {:errors (or (seq (:errors resolved-token))
[(wte/get-error-code :error/unknown-error)])}))))))))
(defn- validate-token-with [token validators]
(if-let [error (some (fn [validate] (validate token)) validators)]

View File

@ -317,7 +317,8 @@
(ctob/tokens-tree))]
[:tuple (-> (cfo/make-token-schema
tokens-tree
(cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
(cto/dtcg-token-type->token-type (-> args (first) (get "type")))
nil)
;; Don't allow plugins to set the id
(sm/dissoc-key :id)
;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below)

View File

@ -1896,6 +1896,10 @@ msgstr "Opacity must be between 0 and 100% or 0 and 1 (e.g. 50% or 0.5)."
msgid "errors.tokens.self-reference"
msgstr "Token has self reference"
#: src/app/common/files/tokens.cljc
msgid "errors.tokens.circular-reference"
msgstr "Token has circular reference"
#: src/app/main/data/workspace/tokens/errors.cljs:124
msgid "errors.tokens.shadow-blur-range"
msgstr "Shadow blur must be greater than or equal to 0."

View File

@ -1854,6 +1854,10 @@ msgstr "La opacidad debe estar entre 0 y 100% o 0 y 1 (p.e. 50% o 0.5)."
msgid "errors.tokens.self-reference"
msgstr "El token tiene una autoreferencia"
#: src/app/common/files/tokens.cljc
msgid "errors.tokens.circular-reference"
msgstr "El token tiene una referencia circular"
#: src/app/main/data/workspace/tokens/errors.cljs:124
msgid "errors.tokens.shadow-blur-range"
msgstr "El desenfoque (blur) de la sombra debe ser mayor o igual a 0."