diff --git a/CHANGES.md b/CHANGES.md index e15be66cd8..99e393bc5b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ ### :boom: Breaking changes & Deprecations ### :rocket: Epics and highlights + - Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) ### :sparkles: New features & Enhancements @@ -15,11 +16,10 @@ - Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248) - Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313) - Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474) - +- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137) ### :bug: Bugs fixed - ## 2.15.0 (Unreleased) ### :boom: Breaking changes & Deprecations diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index e8f1208058..071b28a4e7 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -147,6 +147,27 @@ #(and (some? tokens-tree) (not (ctob/token-name-path-exists? % tokens-tree)))]]) +(defn make-node-token-name-schema + "Dynamically generates a schema to check a token node 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." + [active-tokens tokens-tree node] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (-> cto/schema:token-node-name + (sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + (fn [name] + (let [current-path (:path node) + current-name (:name node) + new-tokens (ctob/update-tokens-group active-tokens current-path current-name name)] + (and (some? new-tokens) + (some (fn [[token-name _]] + (not (ctob/token-name-path-exists? token-name tokens-tree))) + new-tokens))))]]) + (def schema:token-description [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) @@ -165,6 +186,11 @@ (when (and name value) (not (cto/token-value-self-reference? name value))))]]) +(defn make-node-token-schema + [active-tokens tokens-tree node] + [:map + [:name (make-node-token-name-schema active-tokens tokens-tree node)]]) + (defn convert-dtcg-token "Convert token attributes as they come from a decoded json, with DTCG types, to internal types. Eg. From this: diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 15e5168a0b..2d4b5b0395 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -136,6 +136,9 @@ (def token-name-validation-regex #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") +(def token-node-name-validation-regex + #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") + (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, @@ -153,6 +156,14 @@ :gen/gen sg/text} token-ref-validation-regex]) +(def schema:token-node-name + "A token node name can contains letters, numbers, underscores and the character $, but + not start with $ or a dot, 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 "TokenNodeName" + :gen/gen sg/text} + token-node-name-validation-regex]) + (def schema:token-type [::sm/one-of {:decode/json (fn [type] (if (string? type) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 63cd87e393..b219e60e01 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -153,6 +153,18 @@ tokens)] (group-by :type tokens'))) +(defn rename-path + "Renames a node or token path segment with a new name. + If token is provided, it renames a token path, otherwise it renames a node path." + ([node new-name] + (rename-path node nil new-name)) + ([node token new-name] + (let [element (if token (:name token) (:path node)) + split-path (cpn/split-path element :separator ".") + updated-split-element-name (assoc split-path (:depth node) new-name) + new-element-path (cpn/join-path updated-split-element-name :separator "." :with-spaces? false)] + new-element-path))) + ;; === Token Set (defprotocol ITokenSet @@ -1490,6 +1502,30 @@ Will return a value that matches this schema: (seq) (boolean))))) +(defn update-tokens-group + "Updates the active tokens path when renaming a group node. + - Filters tokens whose path matches the current path prefix + - Replaces the token name with the new name + - Updates the :path value in the token object + + active-tokens: map of token-name to token-object for all active tokens in the set + current-path: the path of the group being renamed, e.g. \"foo.bar\" + current-name: the current name of the group being renamed, e.g. \"bar\" + new-name: the new name for the group being renamed, e.g. \"baz\"" + + [active-tokens current-path current-name new-name] + (let [path-prefix (str/replace current-path current-name "")] + (mapv (fn [[token-path token-obj]] + (if (str/starts-with? token-path path-prefix) + (let [new-token-path (str/replace token-path current-name new-name) + new-token-obj (-> token-obj + (assoc :name new-token-path) + (cond-> (:path token-obj) + (assoc :path (str/replace (:path token-obj) current-name new-name))))] + [new-token-path new-token-obj]) + [token-path token-obj])) + active-tokens))) + ;; === Import / Export from JSON format ;; Supported formats: diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index 92f9b9bef2..b74341c965 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -108,7 +108,9 @@ export class WorkspacePage extends BaseWebSocketPage { } async waitForIdle() { - await this.page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve))); + await this.page.evaluate( + () => new Promise((resolve) => globalThis.requestIdleCallback(resolve)), + ); } }; @@ -190,6 +192,7 @@ export class WorkspacePage extends BaseWebSocketPage { this.tokensUpdateCreateModal = page.getByTestId( "token-update-create-modal", ); + this.tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); this.tokenThemeUpdateCreateModal = page.getByTestId( "token-theme-update-create-modal", ); @@ -224,7 +227,7 @@ export class WorkspacePage extends BaseWebSocketPage { async #waitForWebSocketReadiness() { // TODO: find a better event to settle whether the app is ready to receive notifications via ws - await expect(this.pageName).toHaveText("Page 1", { timeout: 30000 }) + await expect(this.pageName).toHaveText("Page 1", { timeout: 30000 }); } async sendPresenceMessage(fixture) { @@ -309,7 +312,7 @@ export class WorkspacePage extends BaseWebSocketPage { async clickWithDragViewportAt(x, y, width, height) { await this.page.waitForTimeout(100); const box = await this.viewport.boundingBox(); - if (!box) throw new Error('Viewport not visible'); + if (!box) throw new Error("Viewport not visible"); const startX = box.x + x; const startY = box.y + y; @@ -362,7 +365,9 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.keyboard.press("T"); await this.page.waitForTimeout(timeToWait); - const layersCountBefore = await this.layers.getByTestId("layer-row").count(); + const layersCountBefore = await this.layers + .getByTestId("layer-row") + .count(); await this.clickAndMove(x1, y1, x2, y2); if (initialText) { @@ -385,10 +390,13 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.keyboard.press("ControlOrMeta+C"); } // wait for the clipboard to be updated - await this.page.waitForFunction(async () => { - const content = await navigator.clipboard.readText() - return content !== ""; - }, { timeout: 1000 }); + await this.page.waitForFunction( + async () => { + const content = await navigator.clipboard.readText(); + return content !== ""; + }, + { timeout: 1000 }, + ); } async cut(kind = "keyboard", locator = undefined) { @@ -399,13 +407,15 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.keyboard.press("ControlOrMeta+X"); } // wait for the clipboard to be updated - await this.page.waitForFunction(async () => { - const content = await navigator.clipboard.readText() - return content !== ""; - }, { timeout: 1000 }); + await this.page.waitForFunction( + async () => { + const content = await navigator.clipboard.readText(); + return content !== ""; + }, + { timeout: 1000 }, + ); await this.page.waitForTimeout(3000); - } /** diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index ac72a2cc7c..153f1dc25d 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -914,7 +914,9 @@ test.describe("Tokens - creation", () => { const emptyNameError = "Name should be at least 1 character"; const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = - await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] }); + await setupEmptyTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); // Open modal const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -1130,7 +1132,9 @@ test.describe("Tokens - creation", () => { const emptyNameError = "Name should be at least 1 character"; const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = - await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] }); + await setupEmptyTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); // Open modal const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -1576,7 +1580,8 @@ test.describe("Tokens - creation", () => { const nameField = tokensUpdateCreateModal.getByLabel("Name"); await nameField.fill(newTokenTitle); - const referenceTabButton = tokensUpdateCreateModal.getByTestId("reference-opt"); + const referenceTabButton = + tokensUpdateCreateModal.getByTestId("reference-opt"); await referenceTabButton.click(); const referenceField = tokensUpdateCreateModal.getByRole("textbox", { diff --git a/frontend/playwright/ui/specs/tokens/helpers.js b/frontend/playwright/ui/specs/tokens/helpers.js index 63c54af0f9..8f8974e40f 100644 --- a/frontend/playwright/ui/specs/tokens/helpers.js +++ b/frontend/playwright/ui/specs/tokens/helpers.js @@ -161,6 +161,7 @@ const setupTokensFileRender = async (page, options = {}) => { workspacePage, tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal, tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal, + tokensRenameNodeModal: workspacePage.tokensRenameNodeModal, tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar, tokenSetItems: workspacePage.tokenSetItems, tokenSetGroupItems: workspacePage.tokenSetGroupItems, diff --git a/frontend/playwright/ui/specs/tokens/remapping.spec.js b/frontend/playwright/ui/specs/tokens/remapping.spec.js index 4563b491b3..90eb658a77 100644 --- a/frontend/playwright/ui/specs/tokens/remapping.spec.js +++ b/frontend/playwright/ui/specs/tokens/remapping.spec.js @@ -123,7 +123,7 @@ const createCompositeDerivedToken = async (page, type, name, reference) => { await expect(tokensUpdateCreateModal).not.toBeVisible(); }; -test.describe("Remapping Tokens", () => { +test.describe("Remapping a single token", () => { test.describe("Box Shadow Token Remapping", () => { test("User renames box shadow token with alias references", async ({ page, @@ -634,3 +634,148 @@ test.describe("Remapping Tokens", () => { }); }); }); + +test.describe("Remapping group of tokens", () => { + test("User renames a group - no remap", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + // Create multiple tokens in a group + await createToken(page, "Color", "dark.primary", "Value", "#000000"); + await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + + // Verify that the node and child token are visible before deletion + const darkNode = tokensSidebar.getByRole("button", { + name: "dark", + exact: true, + }); + const darkNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(darkNode).toBeVisible(); + await expect(darkNodeToken).toBeVisible(); + await darkNode.click({ button: "right" }); + + // select "Rename" from the context menu + const renameNodeButton = page.getByRole("button", { + name: "Rename", + exact: true, + }); + await expect(renameNodeButton).toBeVisible(); + await renameNodeButton.click(); + + // Expect the rename modal to be visible, fill in the new name and submit + const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenRenameNodeModal).toBeVisible(); + + const nameField = tokenRenameNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("darker"); + + const submitButton = tokenRenameNodeModal.getByRole("button", { + name: "Rename", + }); + await submitButton.click(); + + // Ensure that the remapping modal does not appear + const remappingModal = page.getByTestId("token-remapping-modal"); + await expect(remappingModal).not.toBeVisible(); + + // Verify that the node has been renamed and tokens are still visible + const darkerNode = tokensSidebar.getByRole("button", { + name: "darker", + exact: true, + }); + + await expect(darkerNode).toBeVisible(); + }); + + test("User renames a group - and remaps", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + const workspacePage = new WasmWorkspacePage(page); + const rightSidebar = workspacePage.rightSidebar; + + // Create multiple tokens in a group + await createToken(page, "Color", "light.primary", "Value", "#FFFFFF"); + await createToken(page, "Color", "light.secondary", "Value", "#EEEEEE"); + + // Verify that the node and child token are visible before deletion + const lightNode = tokensSidebar.getByRole("button", { + name: "light", + exact: true, + }); + const lightNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(lightNode).toBeVisible(); + await expect(lightNodeToken).toBeVisible(); + + // Apply token to a shape to ensure remapping modal appears with applied token reference + await page.getByRole("tab", { name: "Layers" }).click(); + await page + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .first() + .click(); + + await page.getByRole("tab", { name: "Tokens" }).click(); + const lightPrimaryToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + await lightPrimaryToken.click(); + + // Right click on the node to rename + + await lightNode.click({ button: "right" }); + const renameNodeButton = page.getByRole("button", { + name: "Rename", + exact: true, + }); + await expect(renameNodeButton).toBeVisible(); + await renameNodeButton.click(); + + // Expect the rename modal to be visible, fill in the new name and submit + const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenRenameNodeModal).toBeVisible(); + + const nameField = tokenRenameNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("lighter"); + + const submitButton = tokenRenameNodeModal.getByRole("button", { + name: "Rename", + }); + await submitButton.click(); + + // Ensure that the remapping modal appears and confirm remap + const remappingModal = page.getByTestId("token-remapping-modal"); + await expect(remappingModal).toBeVisible({ timeout: 5000 }); + + const confirmButton = remappingModal.getByRole("button", { + name: "remap tokens", + }); + await confirmButton.click(); + + // Verify that the node has been renamed and tokens are still visible + const lighterNode = tokensSidebar.getByRole("button", { + name: "lighter", + exact: true, + }); + + await expect(lighterNode).toBeVisible(); + + // Verify that the applied token reference has been updated in the right sidebar for the selected shape + const fillSection = rightSidebar.getByTestId("fill-section"); + await expect(fillSection).toBeVisible(); + + const tokenReference = fillSection.getByLabel("lighter.primary", { + exact: true, + }); + await expect(tokenReference).toBeVisible(); + }); +}); 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 4daccd05b8..22bd7789fb 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -456,6 +456,34 @@ (rx/of (dch/commit-changes changes) (ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type}))))))) +(defn bulk-update-tokens + [set-id token-ids type old-path new-path] + (dm/assert! (uuid? set-id)) + (dm/assert! (every? uuid? token-ids)) + (ptk/reify ::bulk-update-tokens + ptk/WatchEvent + (watch [it state _] + (let [token-set (if set-id + (lookup-token-set state set-id) + (lookup-token-set state)) + data (dsh/lookup-file-data state) + changes (reduce (fn [changes token-id] + (let [token (-> (get-tokens-lib state) + (ctob/get-token (ctob/get-id token-set) token-id)) + new-name (str/replace (:name token) old-path new-path) + token' (->> (merge token {:name new-name}) + (into {}) + (ctob/make-token))] + (pcb/set-token changes (ctob/get-id token-set) token-id token'))) + (-> (pcb/empty-changes it) + (pcb/with-library-data data)) + + token-ids)] + (toggle-token-path (str (name type) "." old-path)) + (toggle-token-path (str (name type) "." new-path)) + (rx/of (dch/commit-changes changes) + (ptk/data-event ::ev/event {::ev/name "bulk-update-tokens" :type type})))))) + (defn delete-token [set-id token-id] (dm/assert! (uuid? set-id)) diff --git a/frontend/src/app/main/data/workspace/tokens/remapping.cljs b/frontend/src/app/main/data/workspace/tokens/remapping.cljs index fac4eeb40e..0992501f4c 100644 --- a/frontend/src/app/main/data/workspace/tokens/remapping.cljs +++ b/frontend/src/app/main/data/workspace/tokens/remapping.cljs @@ -150,6 +150,18 @@ (rx/of (dch/commit-changes token-changes)))))) +(defn bulk-remap-tokens + "Helper function to remap a batch of tokens, used for node renaming" + [tokens-in-path new-tokens] + (ptk/reify ::bulk-remap-tokens + ptk/WatchEvent + (watch [_ _ _] + (rx/concat + (map (fn [old-token new-token] + (remap-tokens (:name old-token) (:name new-token))) + tokens-in-path + new-tokens))))) + (defn validate-token-remapping "Validate that a token remapping operation is safe to perform" [old-name new-name] diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 9aede980cf..c0426dcfaa 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -67,24 +67,38 @@ (mf/defc form-submit* [{:keys [disabled on-submit] :rest props}] + (let [form (mf/use-ctx context) - disabled? (or (and (some? form) - (or (not (:valid @form)) - (seq (:async-errors @form)) - (seq (:extra-errors @form)))) - (true? disabled)) + form-state (when form @form) + + disabled? (mf/use-memo + (mf/deps form form-state disabled) + (fn [] + (boolean + (or (nil? form) + (true? disabled) + (not (:valid form-state)) + (seq (:async-errors form-state)) + (seq (:extra-errors form-state)))))) + handle-key-down-save (mf/use-fn - (mf/deps on-submit form) + (mf/deps on-submit form disabled?) (fn [e] - (when (or (k/enter? e) (k/space? e)) + (when (and (or (k/enter? e) (k/space? e)) (not disabled?)) (dom/prevent-default e) (on-submit form e)))) props - (mf/spread-props props {:disabled disabled? - :on-key-down handle-key-down-save - :type "submit"})] + (mf/spread-props props {:on-key-down handle-key-down-save + :type "submit"}) + + props + (if disabled? + (mf/spread-props props {:disabled true + :on-key-down handle-key-down-save + :type "submit"}) + props)] [:> button* props])) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 6014b0614a..bc96444370 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -36,6 +36,7 @@ [app.main.ui.workspace.tokens.import] [app.main.ui.workspace.tokens.import.modal] [app.main.ui.workspace.tokens.management.forms.modals] + [app.main.ui.workspace.tokens.management.forms.rename-node-modal] [app.main.ui.workspace.tokens.remapping-modal] [app.main.ui.workspace.tokens.settings] [app.main.ui.workspace.tokens.themes.create-modal] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index ba6ea893f2..20a4198431 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -195,7 +195,7 @@ (dom/set-attribute! checkbox "indeterminate" true) (dom/remove-attribute! checkbox "indeterminate")))) - [:div {:class (stl/css :fill-section)} + [:div {:class (stl/css :fill-section) :data-testid "fill-section"} [:div {:class (stl/css :fill-title)} [:> title-bar* {:collapsable has-fills? :collapsed (not open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index 0ff4deafa4..7e077cc1f4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -2,12 +2,17 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.path-names :as cpn] [app.common.types.shape.layout :as ctsl] [app.common.types.tokens-lib :as ctob] [app.config :as cf] + [app.main.data.helpers :as dh] + [app.main.data.modal :as modal] [app.main.data.style-dictionary :as sd] [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.data.workspace.tokens.remapping :as remap] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] @@ -17,6 +22,7 @@ [app.main.ui.workspace.tokens.management.node-context-menu :refer [token-node-context-menu*]] [app.util.array :as array] [app.util.i18n :refer [tr]] + [cljs.pprint :as pp] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -124,6 +130,17 @@ (mf/with-memo [tokens-by-type] (get-sorted-token-groups tokens-by-type)) + ;; Filter tokens by their path and return the tokens + filter-tokens-by-path + (mf/use-fn + (fn [tokens-filtered-by-type node] + (->> tokens-filtered-by-type + (filter (fn [token] + (let [token-path (cpn/split-path (:name token) :separator ".") + _ (pp/pprint {:token-path token-path :count (count token-path)})] + (and (> (count token-path) 0) + (str/starts-with? (:name token) (str (:path node) "."))))))))) + ;; Filter tokens by their path and return their ids filter-tokens-by-path-ids (mf/use-fn @@ -132,7 +149,7 @@ (->> selected-token-set-tokens (filter (fn [token] (let [[_ token-value] token] - (and (= (:type token-value) type) (str/starts-with? (:name token-value) path))))) + (and (= (:type token-value) type) (str/starts-with? (:name token-value) (str path ".")))))) (mapv (fn [token] (let [[_ token-value] token] (:id token-value))))))) @@ -176,7 +193,88 @@ ;; Remove from unfolded tree path (if remaining-tokens? (st/emit! (dwtl/toggle-token-path (str (name type) "." path))) - (st/emit! (dwtl/toggle-token-path (name type)))))))] + (st/emit! (dwtl/toggle-token-path (name type))))))) + + bulk-rename-tokens-in-path + ;; Rename tokens in bulk affected by a node rename. + (mf/use-fn + (mf/deps filter-tokens-by-path-ids selected-token-set-id) + (fn [node type new-node-name] + (let [old-path (:path node) + new-path (ctob/rename-path node new-node-name) + tokens-in-path-ids (filter-tokens-by-path-ids type old-path)] + (st/emit! + (modal/hide) + (dwtl/bulk-update-tokens selected-token-set-id tokens-in-path-ids type old-path new-path))))) + + bulk-remap-tokens-in-path + ;; Remap tokens in bulk affected by a node rename. + ;; It will update the token names and propagate the changes to the workspace. + (mf/use-fn + (mf/deps filter-tokens-by-path filter-tokens-by-path-ids selected-token-set-tokens selected-token-set-id) + (fn [node type new-node-name] + (let [old-path (:path node) + ;; Get tokens in path to remap their names after remapping the node + tokens-by-type (ctob/group-by-type selected-token-set-tokens) + tokens-filtered-by-type (get tokens-by-type type) + tokens-in-path (filter-tokens-by-path tokens-filtered-by-type node) + tokens-in-path-ids (filter-tokens-by-path-ids type old-path) + new-node-path (ctob/rename-path node new-node-name) + new-tokens (map (fn [token] + (let [new-token-path (ctob/rename-path node token new-node-name)] + (assoc token :name new-token-path))) + tokens-in-path)] + (st/emit! + (dwtl/bulk-update-tokens selected-token-set-id tokens-in-path-ids type old-path new-node-path) + (remap/bulk-remap-tokens tokens-in-path new-tokens) + (dwtp/propagate-workspace-tokens) + (modal/hide))))) + + on-remap-node-warning + ;; If there are tokens that will be affected by the node rename, we show the remap modal + (mf/use-fn + (mf/deps bulk-remap-tokens-in-path bulk-rename-tokens-in-path) + (fn [node type new-node-name] + (let [remap-data {:new-name new-node-name + :old-name (:name node) + :type "node"} + remap-handler #(bulk-remap-tokens-in-path node type new-node-name) + rename-handler #(bulk-rename-tokens-in-path node type new-node-name)] + (st/emit! + (modal/hide) + (modal/show :tokens/remapping-confirmation {:remap-data remap-data + :on-remap remap-handler + :on-rename rename-handler}))))) + + on-rename-node + ;; When user renames a node, we need to check if there are tokens that will be affected by this change. + ;; If there are, we display the remap modal, otherwise, we rename the tokens directly. + (mf/use-fn + (mf/deps selected-token-set-tokens filter-tokens-by-path on-remap-node-warning bulk-rename-tokens-in-path) + (fn [node type new-node-name] + (let [state @st/state + file-data (dh/lookup-file-data state) + tokens-by-type (ctob/group-by-type selected-token-set-tokens) + tokens-filtered-by-type (get tokens-by-type type) + tokens-in-current-path (filter-tokens-by-path tokens-filtered-by-type node) + _ (pp/pprint {:tokens-in-current-path tokens-in-current-path}) + token-references-count (reduce (fn [count token] + (+ count (remap/count-token-references file-data (:name token)))) + 0 + tokens-in-current-path)] + (if (> token-references-count 0) + (on-remap-node-warning node type new-node-name) + (bulk-rename-tokens-in-path node type new-node-name))))) + + open-rename-node-modal + ;; When user renames a node, we display a form modal + (mf/use-fn + (mf/deps selected-token-set-tokens on-rename-node) + (fn [node type] + (let [on-rename-node-handler #(on-rename-node node type %)] + (st/emit! (modal/show :tokens/rename-node {:node node + :tokens-in-active-set selected-token-set-tokens + :on-rename on-rename-node-handler})))))] (mf/with-effect [tokens-lib selected-token-set-id] (when (and tokens-lib @@ -190,7 +288,8 @@ [:* [:& token-context-menu {:on-delete-token delete-token}] - [:> token-node-context-menu* {:on-delete-node delete-node}] + [:> token-node-context-menu* {:on-rename-node open-rename-node-modal + :on-delete-node delete-node}] [:> selected-set-info* {:tokens-lib tokens-lib :selected-token-set-id selected-token-set-id}] 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 8225a52887..b968395e7c 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 @@ -160,13 +160,13 @@ on-remap-token (mf/use-fn (mf/deps token) - (fn [valid-token name old-name description] + (fn [valid-token new-name old-name description] (st/emit! (dwtl/update-token (:id token) - {:name name + {:name new-name :value (:value valid-token) :description description}) - (remap/remap-tokens old-name name) + (remap/remap-tokens old-name new-name) (dwtp/propagate-workspace-tokens) (modal/hide!)))) @@ -203,11 +203,12 @@ is-rename (and (= action "edit") (not= name old-name)) references-count (remap/count-token-references file-data old-name) on-remap #(on-remap-token valid-token name old-name description) - on-rename #(on-rename-token valid-token name description)] + on-rename #(on-rename-token valid-token name description) + remap-data {:new-name name + :old-name old-name + :type "token"}] (if (and is-rename (> references-count 0)) - (st/emit! (modal/show :tokens/remapping-confirmation {:old-token-name old-name - :new-token-name name - :references-count references-count + (st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data :on-remap on-remap :on-rename on-rename})) (st/emit! diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs new file mode 100644 index 0000000000..c58d244b6a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs @@ -0,0 +1,117 @@ +(ns app.main.ui.workspace.tokens.management.forms.rename-node-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.files.tokens :as cfo] + [app.common.types.tokens-lib :as ctob] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.forms :as fc] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as kbd] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(mf/defc rename-node-form* + [{:keys [node active-tokens tokens-tree on-close on-submit]}] + (let [make-schema #(cfo/make-node-token-schema active-tokens tokens-tree node) + + schema + (mf/with-memo [active-tokens] + (make-schema)) + + initial (mf/with-memo [node] + {:name (:name node)}) + + form (fm/use-form :schema schema + :initial initial) + + on-submit (mf/use-fn + (mf/deps form on-submit) + (fn [] + (let [name (get-in @form [:clean-data :name])] + (when (and (get-in @form [:touched :name]) (not= name (:name node))) + (on-submit name))))) + + is-disabled? (or (not (:valid @form)) + (not (get-in @form [:touched :name])) + (= (get-in @form [:clean-data :name]) (:name node))) + + new-path (mf/with-memo [@form node] + (let [new-name (get-in @form [:clean-data :name]) + path (str (:path node)) + new-path (str/replace path (:name node) new-name)] + new-path))] + + [:* + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :form-modal-title)} + (tr "workspace.tokens.rename-group")] + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:> fc/form-input* {:id "rename-node" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.token-name") + :max-length 255 + :variant "comfortable" + :hint-type "hint" + :hint-message (tr "workspace.tokens.rename-group-name-hint" new-path) + :auto-focus true}] + [:div {:class (stl/css :form-actions)} + [:> button* {:variant "secondary" + :name "cancel" + :on-click on-close} (tr "labels.cancel")] + [:> fc/form-submit* {:variant "primary" + :disabled is-disabled? + :name "rename"} (tr "labels.rename")]]]])) + +(mf/defc rename-node-modal + {::mf/register modal/components + ::mf/register-as :tokens/rename-node} + [{:keys [node tokens-in-active-set on-rename]}] + + (let [tokens-tree-in-selected-set + (mf/with-memo [tokens-in-active-set node] + (-> (ctob/tokens-tree tokens-in-active-set) + (d/dissoc-in (:name node)))) + + close-modal + (mf/use-fn + (fn [] + (st/emit! (modal/hide)))) + + rename + (mf/use-fn + (mf/deps on-rename) + (fn [new-name] + (on-rename new-name))) + + on-key-down + (mf/use-fn + (mf/deps [close-modal]) + (fn [event] + (when (kbd/esc? event) + (close-modal))))] + + [:div {:class (stl/css :modal-overlay) + :on-key-down on-key-down + :data-testid "token-rename-node-modal"} + [:div {:class (stl/css :modal-dialog)} + [:> icon-button* {:class (stl/css :close-btn) + :on-click close-modal + :aria-label (tr "labels.close") + :variant "ghost" + :icon i/close}] + [:> rename-node-form* {:node node + :active-tokens tokens-in-active-set + :tokens-tree tokens-tree-in-selected-set + :on-close close-modal + :on-submit rename}]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss new file mode 100644 index 0000000000..71e0cda690 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss @@ -0,0 +1,46 @@ +// 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 + +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as t; + +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + --modal-title-foreground-color: var(--color-foreground-primary); + --modal-text-foreground-color: var(--color-foreground-secondary); + + @extend .modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + background-color: var(--overlay-color); +} + +.close-btn { + position: absolute; + inset-block-start: $sz-6; + inset-inline-end: $sz-6; +} + +.modal-dialog { + @extend .modal-container-base; + inline-size: 100%; + max-inline-size: 32rem; + max-block-size: unset; + user-select: none; + position: relative; +} + +.form-modal-title { + @include t.use-typography("headline-medium"); + color: var(--color-foreground-primary); +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index 4e272f7bdd..f98e761203 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -13,6 +13,7 @@ (def ^:private schema:token-node-context-menu [:map + [:on-rename-node fn?] [:on-delete-node fn?]]) (def ^:private tokens-node-menu-ref @@ -25,7 +26,7 @@ (mf/defc token-node-context-menu* {::mf/schema schema:token-node-context-menu} - [{:keys [on-delete-node]}] + [{:keys [on-rename-node on-delete-node]}] (let [mdata (mf/deref tokens-node-menu-ref) is-open? (boolean mdata) dropdown-ref (mf/use-ref) @@ -35,7 +36,13 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - + rename-node (mf/use-fn + (mf/deps mdata on-rename-node) + (fn [] + (let [node (get mdata :node) + type (get mdata :type)] + (when node + (on-rename-node node type))))) delete-node (mf/use-fn (mf/deps mdata) (fn [] @@ -75,6 +82,11 @@ :on-context-menu prevent-default} (when mdata [:ul {:class (stl/css :token-node-context-menu-list)} + [:li {:class (stl/css :token-node-context-menu-listitem)} + [:button {:class (stl/css :token-node-context-menu-action) + :type "button" + :on-click rename-node} + (tr "labels.rename")]] [:li {:class (stl/css :token-node-context-menu-listitem)} [:button {:class (stl/css :token-node-context-menu-action) :type "button" diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs index 198972a193..1ac692c484 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs @@ -20,27 +20,41 @@ [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(defn hide-remapping-modal +(defn- hide-remapping-modal "Hide the token remapping confirmation modal" [] (st/emit! (modal/hide))) +;; TODO: Uncomment when modal components support schema validation + +;; (def ^:private schema:remap-data +;; [:map +;; [:old-name :string] +;; [:new-name :string] +;; [:type [:enum "token" "node"]]]) + +;; (def ^:private schema:token-remapping-modal +;; [:map +;; [:remap-data [:maybe schema:remap-data]] +;; [:on-remap {:optional true} [:maybe fn?]] +;; [:on-rename {:optional true} [:maybe fn?]]]) + ;; Remapping Modal Component (mf/defc token-remapping-modal {::mf/register modal/components - ::mf/register-as :tokens/remapping-confirmation} - [{:keys [old-token-name new-token-name on-remap on-rename]}] - (let [remap-modal (get @st/state :remap-modal) + ::mf/register-as :tokens/remapping-confirmation + ;; TODO: Uncomment when modal components support schema validation + ;; ::mf/schema schema:token-remapping-modal + } + [{:keys [remap-data on-remap on-rename]}] + (let [old-name (:old-name remap-data) + new-name (:new-name remap-data) ;; Remap logic on confirm confirm-remap (mf/use-fn - (mf/deps on-remap remap-modal) + (mf/deps on-remap old-name new-name) (fn [] - ;; Call shared remapping logic - (let [old-token-name (:old-token-name remap-modal) - new-token-name (:new-token-name remap-modal)] - (st/emit! [:tokens/remap-tokens old-token-name new-token-name])) (when (fn? on-remap) (on-remap)))) @@ -83,9 +97,13 @@ :id "modal-title" :typography "headline-large" :class (stl/css :modal-title)} - (tr "workspace.tokens.remap-token-references-title" old-token-name new-token-name)]] + (if (= (:type remap-data) "token") + (tr "workspace.tokens.remap-token-references-title" old-name new-name) + (tr "workspace.tokens.remap-node-references-title" old-name new-name))]] [:div {:class (stl/css :modal-content)} - [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-effects")] + (if (= (:type remap-data) "token") + [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-token-warning-effects")] + [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-node-warning-effects")]) [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-time")]] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 586495e0ad..59edb27cf1 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -4272,6 +4272,26 @@ msgstr "Pàgines" msgid "workspace.sitemap" msgstr "Mapa del lloc" +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-references-title" +msgstr "Canviar el nom de `%s` a `%s` i remapejar tots els tokens d'aquest grup?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-token-warning-effects" +msgstr "Això canviarà totes les capes i referències que utilitzen el token antic." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Aquest procés pot trigar una mica" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group" +msgstr "Canviar nom del grup de tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Els teus tokens es renomenaran automàticament a %s.(sufix).(token)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Recursos" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index eeff80eeed..6b6516709e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8301,11 +8301,19 @@ msgstr "Remap tokens" msgid "workspace.tokens.remap-token-references-title" msgstr "Remap all tokens that use `%s` to `%s`?" -#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 -msgid "workspace.tokens.remap-warning-effects" +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-references-title" +msgstr "Rename `%s` to `%s` and remap all tokens in this group?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-token-warning-effects" msgstr "This will change all layers and references that use the old token name." -#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-warning-effects" +msgstr "This will update all tokens and references that use the old tokens name." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 msgid "workspace.tokens.remap-warning-time" msgstr "This action could take a while." @@ -8547,6 +8555,14 @@ msgstr "Renaming this token will break any reference to its old name" msgid "workspace.tokens.error-text-edition" msgstr "Tokens can't be applied while editing text. Select the text layer instead." +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group" +msgstr "Rename Tokens Group" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Your tokens will automatically be renamed to %s.(suffix).(tokenName)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Assets" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index ee377bef15..69e1aeb0bf 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8175,11 +8175,13 @@ msgstr "Actualizar tokens" msgid "workspace.tokens.remap-token-references-title" msgstr "¿Actualizar todas las referencias de `%s` a `%s`?" +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-references-title" +msgstr "¿Renombrar `%s` to `%s` y remapear todos los tokens de este grupo?" + #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 msgid "workspace.tokens.remap-warning-effects" -msgstr "" -"Esta acción actualizará todas las capas y referencias que usen el token " -"antiguo" +msgstr "Esta acción actualizará todas las capas y referencias que usen el token antiguo" #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 msgid "workspace.tokens.remap-warning-time" @@ -8404,6 +8406,14 @@ msgstr "" msgid "workspace.tokens.error-text-edition" msgstr "No se pueden aplicar tokens mientras se edita texto. Seleccione la capa de texto en su lugar." +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group" +msgstr "Renombrar grupo de tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Tus tokens serán automáticamente renombrados a %s.(sufijo).(token)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Recursos"