From c96c84af5c1544e904f98c24e66a89ebd9faba32 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 15 Jun 2026 13:53:09 +0200 Subject: [PATCH] :bug: Fix circular reference error on token edition --- common/src/app/common/types/token.cljc | 26 ++ .../playwright/ui/specs/tokens/crud.spec.js | 264 +++++++++++++++--- .../playwright/ui/specs/tokens/helpers.js | 1 + .../forms/controls/color_input.cljs | 26 +- .../management/forms/controls/combobox.cljs | 30 +- .../forms/controls/fonts_combobox.cljs | 30 +- .../management/forms/controls/input.cljs | 28 +- .../tokens/management/forms/validators.cljs | 28 +- 8 files changed, 329 insertions(+), 104 deletions(-) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 619bc5e2b8..5cd9aab425 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -48,6 +48,32 @@ 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] diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 3ce9047f3c..237e179d5d 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -95,7 +95,7 @@ test.describe("Tokens - creation", () => { await toggleDropdownButton.click(); const option = page.getByRole("option", { name: "my-token" }); await expect(option).toBeVisible(); - const resolvedValue = option.getByText('3'); + const resolvedValue = option.getByText("3"); await expect(resolvedValue).toBeVisible(); await option.click(); await expect( @@ -1956,8 +1956,9 @@ test.describe("User can't create groups that clash with token names", () => { await valueField.fill(value); // Check that the value has an error - const errorNode = - tokensUpdateCreateModal.getByText(`Group name of ${name} conflicts with a token of the same name in another active set.`); + const errorNode = tokensUpdateCreateModal.getByText( + `Group name of ${name} conflicts with a token of the same name in another active set.`, + ); await expect(errorNode).toBeVisible(); @@ -1968,10 +1969,12 @@ test.describe("User can't create groups that clash with token names", () => { await expect(submitButton).toBeDisabled(); }; - test("User can't create Border Radius token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Border Radius token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -1981,10 +1984,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Border Radius", "rad1.bad", "Value", "10"); }); - test("User can't create Color token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Color token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -1994,10 +1999,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Color", "col1.bad", "Value", "red"); }); - test("User can't create Dimensions token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Dimensions token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2007,10 +2014,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Dimensions", "dim1.bad", "Value", "100"); }); - test("User can't create Font Size token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Font Size token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2020,10 +2029,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Font Size", "fsiz1.bad", "Value", "16"); }); - test("User can't create Font Weight token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Font Weight token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2033,10 +2044,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Font Weight", "wei1.bad", "Value", "400"); }); - test("User can't create Letter Spacing token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Letter Spacing token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2046,10 +2059,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Letter Spacing", "lspa1.bad", "Value", "1"); }); - test("User can't create Number token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Number token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2059,10 +2074,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Number", "num1.bad", "Value", "10"); }); - test("User can't create Rotation token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Rotation token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2072,10 +2089,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Rotation", "rot1.bad", "Value", "90"); }); - test("User can't create Sizing token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Sizing token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2085,10 +2104,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Sizing", "siz1.bad", "Value", "100"); }); - test("User can't create Spacing token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Spacing token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2098,10 +2119,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Spacing", "spa1.bad", "Value", "10"); }); - test("User can't create Stroke Width token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Stroke Width token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2111,10 +2134,12 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Stroke Width", "str1.bad", "Value", "2"); }); - test("User can't create Text Case token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Text Case token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2124,23 +2149,33 @@ test.describe("User can't create groups that clash with token names", () => { await createBadToken(page, "Text Case", "cas1.bad", "Value", "uppercase"); }); -test("User can't create Text Decoration token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Text Decoration token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); await createSet(tokenThemesSetsSidebar, "Second set"); - await createBadToken(page, "Text Decoration", "dec1.bad", "Value", "strike-through"); + await createBadToken( + page, + "Text Decoration", + "dec1.bad", + "Value", + "strike-through", + ); }); - test("User can't create Typography token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Typography token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2200,15 +2235,19 @@ test("User can't create Text Decoration token with group name that clashes with name: "Save", }); - const errorNode = tokensUpdateCreateModal.getByText("Group name of typ1.bad conflicts with a token of the same name in another active set."); + const errorNode = tokensUpdateCreateModal.getByText( + "Group name of typ1.bad conflicts with a token of the same name in another active set.", + ); await expect(errorNode).toHaveCount(1); await expect(submitButton).toBeDisabled(); }); - test("User can't create Shadow token with group name that clashes with existing token", async ({ page }) => { + test("User can't create Shadow token with group name that clashes with existing token", async ({ + page, + }) => { const { tokenThemesSetsSidebar, tokensSidebar } = await setupTokensFileRender(page, { - file: "workspace/get-file-tokens-all-types.json" + file: "workspace/get-file-tokens-all-types.json", }); await expect(tokensSidebar).toBeVisible(); @@ -2258,7 +2297,9 @@ test("User can't create Text Decoration token with group name that clashes with name: "Save", }); - const errorNode = tokensUpdateCreateModal.getByText("Group name of sha1.bad conflicts with a token of the same name in another active set."); + const errorNode = tokensUpdateCreateModal.getByText( + "Group name of sha1.bad conflicts with a token of the same name in another active set.", + ); await expect(errorNode).toHaveCount(1); await expect(submitButton).toBeDisabled(); }); @@ -2462,6 +2503,139 @@ test.describe("Tokens tab - edition", () => { await valueSaturationSelector.click({ position: { x: 0, y: 0 } }); await expect(valueField).toHaveValue(/^rgba(.*)$/); }); + + test("User sees self-reference error when editing a token to create a circular reference", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } = + await setupEmptyTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + + // Create first token "base" with value "10" + const addTokenButton = tokensTabPanel.getByRole("button", { + name: "Add Token: Border Radius", + }); + await addTokenButton.click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + await createToken(page, "Border radius", "base", "Value", "combobox", "10"); + + await unfoldTokenType(tokensTabPanel, "border radius"); + await expect( + tokensTabPanel.getByRole("button", { name: "base" }), + ).toBeEnabled(); + + // Create second token "linear" referencing "base" + await createToken( + page, + "Border radius", + "linear", + "Value", + "combobox", + "{base} * 0.25", + ); + + await expect( + tokensTabPanel.getByRole("button", { name: "linear" }), + ).toBeEnabled(); + + // Open the edit modal for "base" + const baseToken = tokensTabPanel.getByRole("button", { name: "base" }); + await expect(baseToken).toBeVisible(); + await baseToken.click({ button: "right" }); + await expect(tokenContextMenuForToken).toBeVisible(); + const editOption = tokenContextMenuForToken + .getByRole("listitem") + .filter({ hasText: "Edit token" }); + await expect(editOption).toBeVisible(); + await editOption.click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + // Change value to reference "linear.0.25", creating a circular reference + const editValueField = tokensUpdateCreateModal.getByRole("combobox", { + name: "Value", + }); + await editValueField.fill("{linear}"); + + // The circular reference error should appear gracefully + const selfRefError = tokensUpdateCreateModal.getByText( + "Token has self reference", + ); + await expect(selfRefError).toBeVisible({ timeout: 5000 }); + + // Save button should be disabled + const editSubmitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await expect(editSubmitButton).toBeDisabled(); + }); + + test("User sees self-reference error for a three-step circular reference (A → B → C → A)", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } = + await setupEmptyTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + + // Create first token "a" with value "10" + const addTokenButton = tokensTabPanel.getByRole("button", { + name: "Add Token: Border Radius", + }); + + await createToken( + page, + "Border radius", + "First", + "Value", + "combobox", + "10", + ); + await createToken( + page, + "Border radius", + "Second", + "Value", + "combobox", + "{First}", + ); + await createToken( + page, + "Border radius", + "Third", + "Value", + "combobox", + "{Second}", + ); + + // Edit "First" to reference "Third", completing the cycle A → B → C → A + const tokenA = tokensTabPanel.getByRole("button", { name: "First" }); + await tokenA.click({ button: "right" }); + await expect(tokenContextMenuForToken).toBeVisible(); + await tokenContextMenuForToken.getByText("Edit token").click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + const editValueField = tokensUpdateCreateModal.getByRole("combobox", { + name: "Value", + }); + await editValueField.fill("{Third}"); + + // The circular reference error should appear gracefully + const selfRefError = tokensUpdateCreateModal.getByText( + "Token has self reference", + ); + await expect(selfRefError).toBeVisible({ timeout: 5000 }); + + // Save button should be disabled + const editSubmitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await expect(editSubmitButton).toBeDisabled(); + }); }); test.describe("Tokens tab - delete", () => { @@ -2496,7 +2670,14 @@ test("BUG: 14262 Token pill must be highlighted when value references a token in await expect(tokensSidebar).toBeVisible(); await unfoldTokenType(tokensSidebar, "Border radius"); - await createToken(page, "Border radius", "base-radius", "Value", "textbox", "20"); + await createToken( + page, + "Border radius", + "base-radius", + "Value", + "textbox", + "20", + ); await createToken( page, "Border radius", @@ -2529,7 +2710,14 @@ test("BUG: 14262 Token pill must be highlighted when value references a token in .getByRole("button", { name: "New set" }) .getByRole("checkbox") .click(); - await createToken(page, "Border radius", "new-ref", "Value", "textbox", "{base-radius}"); + await createToken( + page, + "Border radius", + "new-ref", + "Value", + "textbox", + "{base-radius}", + ); // Pill is highlighted if the referenced token is in a different disabled set than the token with the reference const newBrokenTokenPill = tokensSidebar.getByRole("button", { diff --git a/frontend/playwright/ui/specs/tokens/helpers.js b/frontend/playwright/ui/specs/tokens/helpers.js index 4280c574e1..8418c2b6a2 100644 --- a/frontend/playwright/ui/specs/tokens/helpers.js +++ b/frontend/playwright/ui/specs/tokens/helpers.js @@ -76,6 +76,7 @@ const setupEmptyTokensFileRender = async (page, options = {}) => { tokenSetItems: workspacePage.tokenSetItems, tokensSidebar: workspacePage.tokensSidebar, tokenSetGroupItems: workspacePage.tokenSetGroupItems, + tokenContextMenuForToken: workspacePage.tokenContextMenuForToken, tokenContextMenuForSet: workspacePage.tokenContextMenuForSet, }; }; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 4399040099..ce328d5be6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -73,18 +73,20 @@ (dissoc (:name prev-token)) (update (:name token) #(ctob/make-token (merge % prev-token token))))] - (->> (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 (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)}))))))))) (defn- hex->color-obj [hex] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index c69d877aaf..d730e12618 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -50,20 +50,22 @@ (dissoc (:name prev-token)) (update (:name token) #(ctob/make-token (merge % prev-token token))))] - (->> (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 (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))}))))))))) (mf/defc value-combobox* [{:keys [name tokens token token-type empty-to-end ref] :rest props}] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index cd52d1bcd0..b89fc0f9ec 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -68,20 +68,22 @@ tokens (update tokens (:name token) #(ctob/make-token (merge % prev-token token)))] - (->> (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 (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))}))))))))) (mf/defc fonts-combobox* [{:keys [token tokens name] :rest props}] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index a1411ca73d..22c7fa27fd 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -172,19 +172,21 @@ (dissoc (:name prev-token)) (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)) - 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 (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))}))))))))) (mf/defc input* [{:keys [name tokens token] :rest props}] 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 4fb9be3e4f..e7c41cb236 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 @@ -39,20 +39,22 @@ :always (update (:name token) #(ctob/make-token (merge % prev-token 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) + (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) - :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)]