From 5ffec3e5e9eb585e7a9c68b16260c08d0b850341 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 27 May 2026 13:06:47 +0200 Subject: [PATCH 1/2] :bug: Fix shadow token creation --- .../playwright/ui/specs/tokens/crud.spec.js | 61 ++++++++++++++++--- .../forms/controls/color_input.cljs | 22 ++++++- .../management/forms/controls/input.cljs | 26 ++++++-- .../tokens/management/forms/shadow.cljs | 26 +++++--- frontend/translations/en.po | 4 ++ frontend/translations/es.po | 4 ++ 6 files changed, 120 insertions(+), 23 deletions(-) diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 99ec33ee46..d37a18c1ed 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -919,6 +919,7 @@ test.describe("Tokens - creation", () => { test("User creates shadow token", async ({ page }) => { const emptyNameError = "Name should be at least 1 character"; + const emptyFieldError = "This field cannot be empty"; const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = await setupEmptyTokensFileRender(page, { @@ -997,6 +998,14 @@ test.describe("Tokens - creation", () => { await expect(emptyNameErrorNode).toBeVisible(); await expect(submitButton).toBeDisabled(); + // 5. Empty fill -> disabled + error message + await offsetXField.fill("3"); // Fill should be touched in order to show the error message + await offsetXField.fill(""); + const emptyFieldErrorNode = + tokensUpdateCreateModal.getByText(emptyFieldError); + + await expect(emptyFieldErrorNode).toBeVisible(); + // // ------- SUCCESSFUL FIELDS ------- // @@ -1102,6 +1111,28 @@ test.describe("Tokens - creation", () => { await expect( tokensTabPanel.getByRole("button", { name: "my-token-2" }), ).toBeEnabled(); + + // + // ------- THIRD TOKEN WITH EMPTY BLUR AND SPREAD ------- + // + await addTokenButton.click(); + + await nameField.fill("my-token-3"); + await colorField.fill("red"); + + // 1. Empty blur and spread + + await blurField.fill(""); + + await spreadField.fill(""); + + await expect(submitButton).toBeEnabled(); + await submitButton.click(); + + await unfoldTokenType(tokensTabPanel, "shadow"); + await expect( + tokensTabPanel.getByRole("button", { name: "my-token-3" }), + ).toBeEnabled(); }); test("User cant submit empty typography token or reference", async ({ @@ -1691,13 +1722,21 @@ test.describe("Tokens - creation", () => { }); }); -test("User cannot create token with a conflicting name in other set", async ({ page }) => { - const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = - await setupTokensFileRender(page); +test("User cannot create token with a conflicting name in other set", async ({ + page, +}) => { + const { + tokensUpdateCreateModal, + tokenThemesSetsSidebar, + tokensSidebar, + tokenContextMenuForToken, + } = await setupTokensFileRender(page); await expect(tokensSidebar).toBeVisible(); - await tokenThemesSetsSidebar.getByRole('button', { name: 'light', exact: true }).click(); + await tokenThemesSetsSidebar + .getByRole("button", { name: "light", exact: true }) + .click(); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); await tokensTabPanel @@ -1721,8 +1760,11 @@ test("User cannot create token with a conflicting name in other set", async ({ p await nameField.fill("accent.default"); // An error message should appear and submit button should be disabled - await expect(tokensUpdateCreateModal.getByText('A token already exists at the path: accent.default')) - .toBeVisible() + await expect( + tokensUpdateCreateModal.getByText( + "A token already exists at the path: accent.default", + ), + ).toBeVisible(); await expect(submitButton).toBeDisabled(); @@ -1730,8 +1772,11 @@ test("User cannot create token with a conflicting name in other set", async ({ p await nameField.fill("colors.red"); // An error message should appear and submit button should be disabled - await expect(tokensUpdateCreateModal.getByText('A token already exists at the path: colors.red')) - .toBeVisible() + await expect( + tokensUpdateCreateModal.getByText( + "A token already exists at the path: colors.red", + ), + ).toBeVisible(); await expect(submitButton).toBeDisabled(); 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 3defb347d3..d2da1c89c3 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 @@ -311,21 +311,39 @@ (swap! form (fn [state] (-> state (assoc-in [:data :value value-subfield index field] (if trim? (str/trim value) value)) + (assoc-in [:touched :value value-subfield index field] true) + (update :errors dissoc :value) + (update :extra-errors dissoc :value) (update :errors clean-errors) (update :extra-errors clean-errors))))))) + (mf/defc indexed-color-input* [{:keys [name tokens token index value-subfield] :rest props}] (let [form (mf/use-ctx fc/context) input-name name token-name (get-in @form [:data :name] nil) - error - (get-in @form [:errors :value value-subfield index input-name]) + + touched? + (get-in @form [:touched :value value-subfield index input-name]) value (get-in @form [:data :value value-subfield index input-name] "") + ;; Resolution error for this specific field + indexed-error + (get-in @form [:errors :value value-subfield index input-name]) + + ;; Empty-field error: scoped to this layer so each shadow layer is + ;; evaluated independently from the others. + empty-error + (when (str/blank? value) + {:message (tr "errors.tokens.empty-field")}) + + error + (when touched? (or indexed-error empty-error)) + color-resolved (get-in @form [:data :value value-subfield index :color-result] "") 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 3e20c5a2e1..172d712a8b 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 @@ -383,22 +383,40 @@ (swap! form (fn [state] (-> state (assoc-in [:data :value value-subfield index field] (if trim? (str/trim value) value)) + (assoc-in [:touched :value value-subfield index field] true) + (update :errors dissoc :value) + (update :extra-errors dissoc :value) (update :errors clean-errors) (update :extra-errors clean-errors))))))) (mf/defc input-indexed* - [{:keys [name tokens token index value-subfield] :rest props}] + [{:keys [name tokens token index value-subfield nillable] :rest props}] (let [form (mf/use-ctx fc/context) input-name name token-name (get-in @form [:data :name] nil) + nillable (d/nilv nillable false) - error - (get-in @form [:errors :value value-subfield index input-name]) + touched? + (get-in @form [:touched :value value-subfield index input-name]) value-from-form (get-in @form [:data :value value-subfield index input-name] "") + ;; Resolution error for this specific field (e.g. missing reference) + indexed-error + (get-in @form [:errors :value value-subfield index input-name]) + + ;; Empty-field error: derived purely from the local field value so that + ;; each shadow layer is evaluated independently. + empty-error + (when (and (not nillable) + (str/blank? value-from-form)) + {:message (tr "errors.tokens.empty-field")}) + + error + (when touched? (or indexed-error empty-error)) + resolve-stream (mf/with-memo [token index input-name] (if-let [value (get-in token [:value value-subfield index input-name])] @@ -428,7 +446,7 @@ props (if error (mf/spread-props props {:hint-type "error" - :hint-message (:message error)}) + :hint-message (or (:message error) (tr "errors.field-missing"))}) props) props 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 c928cc785a..1a245fe8f9 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 @@ -57,10 +57,13 @@ (update :token-value (fn [value] (->> (or value []) (mapv (fn [shadow] - (d/update-when shadow :inset #(cond - (boolean? %) % - (= "true" %) true - :else false))))))) + (-> shadow + (d/update-when :inset #(cond + (boolean? %) % + (= "true" %) true + :else false)) + (update :blur #(if (str/blank? %) "0" %)) + (update :spread #(if (str/blank? %) "0" %)))))))) (assoc :validators [check-empty-shadow-token check-shadow-token-self-reference]))] @@ -160,6 +163,7 @@ {:aria-label (tr "workspace.tokens.shadow-blur") :placeholder (tr "workspace.tokens.shadow-blur") :name :blur + :nillable true :slot-start (mf/html [:span {:class (stl/css :visible-label)} (str (tr "workspace.tokens.shadow-blur") ":")]) :token blur-token @@ -172,6 +176,7 @@ {:aria-label (tr "workspace.tokens.shadow-spread") :placeholder (tr "workspace.tokens.shadow-spread") :name :spread + :nillable true :slot-start (mf/html [:span {:class (stl/css :visible-label)} (str (tr "workspace.tokens.shadow-spread") ":")]) :token spread-token @@ -310,15 +315,14 @@ ref-valid? (and reference (not (str/blank? reference))) shadows (get value :shadow) - ;; To be a valid shadow it must contain one on each valid values + ;; To be a valid shadow it must contain one on each valid values. + ;; blur and spread are optional and default to "0" when blank. valid-composite-shadow? (and (seq shadows) (every? - (fn [{:keys [offset-x offset-y blur spread color]}] + (fn [{:keys [offset-x offset-y color]}] (and (not (str/blank? offset-x)) (not (str/blank? offset-y)) - (not (str/blank? blur)) - (not (str/blank? spread)) (not (str/blank? color)))) shadows))] @@ -333,7 +337,11 @@ (vector? value) {:reference nil - :shadow value} + :shadow (mapv (fn [shadow] + (-> shadow + (update :blur #(if (str/blank? %) "0" %)) + (update :spread #(if (str/blank? %) "0" %)))) + value)} :else {:reference nil diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a28e60343c..73740718e0 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8461,6 +8461,10 @@ msgstr "Edit %s token" msgid "errors.tokens.empty-input" msgstr "Token value cannot be empty" +#: src/app/main/data/workspace/tokens/errors.cljs +msgid "errors.tokens.empty-field" +msgstr "This field cannot be empty" + #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 msgid "workspace.tokens.enter-token-name" msgstr "Enter %s token name" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4a22fb7a9b..bfe6852555 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8221,6 +8221,10 @@ msgstr "Editar token de %s" msgid "errors.tokens.empty-input" msgstr "El valor del token no puede estar vacío" +#: src/app/main/data/workspace/tokens/errors.cljs +msgid "errors.tokens.empty-field" +msgstr "Este campo no puede estar vacío" + #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 msgid "workspace.tokens.enter-token-name" msgstr "Introduce un nombre para el token %s" From 15d6df48f5853132c66e3a22ebbc1855d41968f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 27 May 2026 12:27:23 +0200 Subject: [PATCH 2/2] :bug: Fix default team showing up in count --- backend/src/app/rpc/commands/nitrate.clj | 5 ++--- .../test/backend_tests/rpc_management_nitrate_test.clj | 8 ++++---- backend/test/backend_tests/rpc_nitrate_test.clj | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 4f8a854d01..eb1fcdc07f 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -147,10 +147,9 @@ (defn get-leave-org-summary [cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count] - (let [{:keys [deletable-team-ids delete-default-team? detach-from-org-team-ids]} + (let [{:keys [deletable-team-ids detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete nil)] - {:teams-to-delete (+ (count deletable-team-ids) - (if delete-default-team? 1 0)) + {:teams-to-delete (count deletable-team-ids) :teams-to-transfer teams-to-transfer-count :teams-to-exit teams-to-exit-count :teams-to-detach (count detach-from-org-team-ids)})) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 8e65e4bd65..242a73e2df 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -1025,7 +1025,7 @@ :organization-id organization-id :default-team-id (:id org-team)}))] (t/is (th/success? out)) - (t/is (= {:teams-to-delete 1 + (t/is (= {:teams-to-delete 0 :teams-to-transfer 0 :teams-to-exit 0 :teams-to-detach 0} @@ -1052,7 +1052,7 @@ :organization-id organization-id :default-team-id (:id org-team)}))] (t/is (th/success? out)) - (t/is (= {:teams-to-delete 2 + (t/is (= {:teams-to-delete 1 :teams-to-transfer 0 :teams-to-exit 0 :teams-to-detach 0} @@ -1083,7 +1083,7 @@ :organization-id organization-id :default-team-id (:id org-team)}))] (t/is (th/success? out)) - (t/is (= {:teams-to-delete 1 + (t/is (= {:teams-to-delete 0 :teams-to-transfer 1 :teams-to-exit 0 :teams-to-detach 0} @@ -1113,7 +1113,7 @@ :organization-id organization-id :default-team-id (:id org-team)}))] (t/is (th/success? out)) - (t/is (= {:teams-to-delete 1 + (t/is (= {:teams-to-delete 0 :teams-to-transfer 0 :teams-to-exit 1 :teams-to-detach 0} diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 371adb9548..20e774ec99 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -307,7 +307,7 @@ :id organization-id :default-team-id your-penpot-id})] (t/is (th/success? out)) - (t/is (= {:teams-to-delete 1 + (t/is (= {:teams-to-delete 0 :teams-to-transfer 0 :teams-to-exit 0 :teams-to-detach 0}