🎉 Rename token group (#8275)

* 🎉 Rename token group

* 📎 Add to CHANGES
This commit is contained in:
Xaviju 2026-03-19 22:54:21 +01:00 committed by GitHub
parent 8e7e6ffc2f
commit f8913c755d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 687 additions and 59 deletions

View File

@ -5,6 +5,7 @@
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights ### :rocket: Epics and highlights
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) - Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
### :sparkles: New features & Enhancements ### :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 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 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) - 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 ### :bug: Bugs fixed
## 2.15.0 (Unreleased) ## 2.15.0 (Unreleased)
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations

View File

@ -147,6 +147,27 @@
#(and (some? tokens-tree) #(and (some? tokens-tree)
(not (ctob/token-name-path-exists? % 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 (def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
@ -165,6 +186,11 @@
(when (and name value) (when (and name value)
(not (cto/token-value-self-reference? 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 (defn convert-dtcg-token
"Convert token attributes as they come from a decoded json, with DTCG types, to internal types. "Convert token attributes as they come from a decoded json, with DTCG types, to internal types.
Eg. From this: Eg. From this:

View File

@ -136,6 +136,9 @@
(def token-name-validation-regex (def token-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") #"^[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 (def schema:token-name
"A token name can contains letters, numbers, underscores the character $ and dots, but "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, not start with $ or end with a dot. The $ character does not have any special meaning,
@ -153,6 +156,14 @@
:gen/gen sg/text} :gen/gen sg/text}
token-ref-validation-regex]) 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 (def schema:token-type
[::sm/one-of {:decode/json (fn [type] [::sm/one-of {:decode/json (fn [type]
(if (string? type) (if (string? type)

View File

@ -153,6 +153,18 @@
tokens)] tokens)]
(group-by :type 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 ;; === Token Set
(defprotocol ITokenSet (defprotocol ITokenSet
@ -1490,6 +1502,30 @@ Will return a value that matches this schema:
(seq) (seq)
(boolean))))) (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 ;; === Import / Export from JSON format
;; Supported formats: ;; Supported formats:

View File

@ -108,7 +108,9 @@ export class WorkspacePage extends BaseWebSocketPage {
} }
async waitForIdle() { 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( this.tokensUpdateCreateModal = page.getByTestId(
"token-update-create-modal", "token-update-create-modal",
); );
this.tokenRenameNodeModal = page.getByTestId("token-rename-node-modal");
this.tokenThemeUpdateCreateModal = page.getByTestId( this.tokenThemeUpdateCreateModal = page.getByTestId(
"token-theme-update-create-modal", "token-theme-update-create-modal",
); );
@ -224,7 +227,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async #waitForWebSocketReadiness() { async #waitForWebSocketReadiness() {
// TODO: find a better event to settle whether the app is ready to receive notifications via ws // 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) { async sendPresenceMessage(fixture) {
@ -309,7 +312,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async clickWithDragViewportAt(x, y, width, height) { async clickWithDragViewportAt(x, y, width, height) {
await this.page.waitForTimeout(100); await this.page.waitForTimeout(100);
const box = await this.viewport.boundingBox(); 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 startX = box.x + x;
const startY = box.y + y; const startY = box.y + y;
@ -362,7 +365,9 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("T"); await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait); 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); await this.clickAndMove(x1, y1, x2, y2);
if (initialText) { if (initialText) {
@ -385,10 +390,13 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("ControlOrMeta+C"); await this.page.keyboard.press("ControlOrMeta+C");
} }
// wait for the clipboard to be updated // wait for the clipboard to be updated
await this.page.waitForFunction(async () => { await this.page.waitForFunction(
const content = await navigator.clipboard.readText() async () => {
return content !== ""; const content = await navigator.clipboard.readText();
}, { timeout: 1000 }); return content !== "";
},
{ timeout: 1000 },
);
} }
async cut(kind = "keyboard", locator = undefined) { async cut(kind = "keyboard", locator = undefined) {
@ -399,13 +407,15 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("ControlOrMeta+X"); await this.page.keyboard.press("ControlOrMeta+X");
} }
// wait for the clipboard to be updated // wait for the clipboard to be updated
await this.page.waitForFunction(async () => { await this.page.waitForFunction(
const content = await navigator.clipboard.readText() async () => {
return content !== ""; const content = await navigator.clipboard.readText();
}, { timeout: 1000 }); return content !== "";
},
{ timeout: 1000 },
);
await this.page.waitForTimeout(3000); await this.page.waitForTimeout(3000);
} }
/** /**

View File

@ -914,7 +914,9 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character"; const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] }); await setupEmptyTokensFileRender(page, {
flags: ["enable-token-shadow"],
});
// Open modal // Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); 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 emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] }); await setupEmptyTokensFileRender(page, {
flags: ["enable-token-shadow"],
});
// Open modal // Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@ -1576,7 +1580,8 @@ test.describe("Tokens - creation", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name"); const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newTokenTitle); await nameField.fill(newTokenTitle);
const referenceTabButton = tokensUpdateCreateModal.getByTestId("reference-opt"); const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click(); await referenceTabButton.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", { const referenceField = tokensUpdateCreateModal.getByRole("textbox", {

View File

@ -161,6 +161,7 @@ const setupTokensFileRender = async (page, options = {}) => {
workspacePage, workspacePage,
tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal, tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal,
tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal, tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal,
tokensRenameNodeModal: workspacePage.tokensRenameNodeModal,
tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar, tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar,
tokenSetItems: workspacePage.tokenSetItems, tokenSetItems: workspacePage.tokenSetItems,
tokenSetGroupItems: workspacePage.tokenSetGroupItems, tokenSetGroupItems: workspacePage.tokenSetGroupItems,

View File

@ -123,7 +123,7 @@ const createCompositeDerivedToken = async (page, type, name, reference) => {
await expect(tokensUpdateCreateModal).not.toBeVisible(); await expect(tokensUpdateCreateModal).not.toBeVisible();
}; };
test.describe("Remapping Tokens", () => { test.describe("Remapping a single token", () => {
test.describe("Box Shadow Token Remapping", () => { test.describe("Box Shadow Token Remapping", () => {
test("User renames box shadow token with alias references", async ({ test("User renames box shadow token with alias references", async ({
page, 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();
});
});

View File

@ -456,6 +456,34 @@
(rx/of (dch/commit-changes changes) (rx/of (dch/commit-changes changes)
(ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type}))))))) (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 (defn delete-token
[set-id token-id] [set-id token-id]
(dm/assert! (uuid? set-id)) (dm/assert! (uuid? set-id))

View File

@ -150,6 +150,18 @@
(rx/of (dch/commit-changes token-changes)))))) (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 (defn validate-token-remapping
"Validate that a token remapping operation is safe to perform" "Validate that a token remapping operation is safe to perform"
[old-name new-name] [old-name new-name]

View File

@ -67,24 +67,38 @@
(mf/defc form-submit* (mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}] [{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context) (let [form (mf/use-ctx context)
disabled? (or (and (some? form) form-state (when form @form)
(or (not (:valid @form))
(seq (:async-errors @form)) disabled? (mf/use-memo
(seq (:extra-errors @form)))) (mf/deps form form-state disabled)
(true? 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 handle-key-down-save
(mf/use-fn (mf/use-fn
(mf/deps on-submit form) (mf/deps on-submit form disabled?)
(fn [e] (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) (dom/prevent-default e)
(on-submit form e)))) (on-submit form e))))
props props
(mf/spread-props props {:disabled disabled? (mf/spread-props props {:on-key-down handle-key-down-save
:on-key-down handle-key-down-save :type "submit"})
:type "submit"})]
props
(if disabled?
(mf/spread-props props {:disabled true
:on-key-down handle-key-down-save
:type "submit"})
props)]
[:> button* props])) [:> button* props]))

View File

@ -36,6 +36,7 @@
[app.main.ui.workspace.tokens.import] [app.main.ui.workspace.tokens.import]
[app.main.ui.workspace.tokens.import.modal] [app.main.ui.workspace.tokens.import.modal]
[app.main.ui.workspace.tokens.management.forms.modals] [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.remapping-modal]
[app.main.ui.workspace.tokens.settings] [app.main.ui.workspace.tokens.settings]
[app.main.ui.workspace.tokens.themes.create-modal] [app.main.ui.workspace.tokens.themes.create-modal]

View File

@ -195,7 +195,7 @@
(dom/set-attribute! checkbox "indeterminate" true) (dom/set-attribute! checkbox "indeterminate" true)
(dom/remove-attribute! checkbox "indeterminate")))) (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)} [:div {:class (stl/css :fill-title)}
[:> title-bar* {:collapsable has-fills? [:> title-bar* {:collapsable has-fills?
:collapsed (not open?) :collapsed (not open?)

View File

@ -2,12 +2,17 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.path-names :as cpn]
[app.common.types.shape.layout :as ctsl] [app.common.types.shape.layout :as ctsl]
[app.common.types.tokens-lib :as ctob] [app.common.types.tokens-lib :as ctob]
[app.config :as cf] [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.style-dictionary :as sd]
[app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl] [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.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [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.main.ui.workspace.tokens.management.node-context-menu :refer [token-node-context-menu*]]
[app.util.array :as array] [app.util.array :as array]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[cljs.pprint :as pp]
[cuerdas.core :as str] [cuerdas.core :as str]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@ -124,6 +130,17 @@
(mf/with-memo [tokens-by-type] (mf/with-memo [tokens-by-type]
(get-sorted-token-groups 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 their path and return their ids
filter-tokens-by-path-ids filter-tokens-by-path-ids
(mf/use-fn (mf/use-fn
@ -132,7 +149,7 @@
(->> selected-token-set-tokens (->> selected-token-set-tokens
(filter (fn [token] (filter (fn [token]
(let [[_ token-value] 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] (mapv (fn [token]
(let [[_ token-value] token] (let [[_ token-value] token]
(:id token-value))))))) (:id token-value)))))))
@ -176,7 +193,88 @@
;; Remove from unfolded tree path ;; Remove from unfolded tree path
(if remaining-tokens? (if remaining-tokens?
(st/emit! (dwtl/toggle-token-path (str (name type) "." path))) (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] (mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib (when (and tokens-lib
@ -190,7 +288,8 @@
[:* [:*
[:& token-context-menu {:on-delete-token delete-token}] [:& 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-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}] :selected-token-set-id selected-token-set-id}]

View File

@ -160,13 +160,13 @@
on-remap-token on-remap-token
(mf/use-fn (mf/use-fn
(mf/deps token) (mf/deps token)
(fn [valid-token name old-name description] (fn [valid-token new-name old-name description]
(st/emit! (st/emit!
(dwtl/update-token (:id token) (dwtl/update-token (:id token)
{:name name {:name new-name
:value (:value valid-token) :value (:value valid-token)
:description description}) :description description})
(remap/remap-tokens old-name name) (remap/remap-tokens old-name new-name)
(dwtp/propagate-workspace-tokens) (dwtp/propagate-workspace-tokens)
(modal/hide!)))) (modal/hide!))))
@ -203,11 +203,12 @@
is-rename (and (= action "edit") (not= name old-name)) is-rename (and (= action "edit") (not= name old-name))
references-count (remap/count-token-references file-data old-name) references-count (remap/count-token-references file-data old-name)
on-remap #(on-remap-token valid-token name old-name description) 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)) (if (and is-rename (> references-count 0))
(st/emit! (modal/show :tokens/remapping-confirmation {:old-token-name old-name (st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data
:new-token-name name
:references-count references-count
:on-remap on-remap :on-remap on-remap
:on-rename on-rename})) :on-rename on-rename}))
(st/emit! (st/emit!

View File

@ -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}]]]))

View File

@ -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);
}

View File

@ -13,6 +13,7 @@
(def ^:private schema:token-node-context-menu (def ^:private schema:token-node-context-menu
[:map [:map
[:on-rename-node fn?]
[:on-delete-node fn?]]) [:on-delete-node fn?]])
(def ^:private tokens-node-menu-ref (def ^:private tokens-node-menu-ref
@ -25,7 +26,7 @@
(mf/defc token-node-context-menu* (mf/defc token-node-context-menu*
{::mf/schema schema: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) (let [mdata (mf/deref tokens-node-menu-ref)
is-open? (boolean mdata) is-open? (boolean mdata)
dropdown-ref (mf/use-ref) dropdown-ref (mf/use-ref)
@ -35,7 +36,13 @@
dropdown-direction-change* (mf/use-ref 0) dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5) top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 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 delete-node (mf/use-fn
(mf/deps mdata) (mf/deps mdata)
(fn [] (fn []
@ -75,6 +82,11 @@
:on-context-menu prevent-default} :on-context-menu prevent-default}
(when mdata (when mdata
[:ul {:class (stl/css :token-node-context-menu-list)} [: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)} [:li {:class (stl/css :token-node-context-menu-listitem)}
[:button {:class (stl/css :token-node-context-menu-action) [:button {:class (stl/css :token-node-context-menu-action)
:type "button" :type "button"

View File

@ -20,27 +20,41 @@
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(defn hide-remapping-modal (defn- hide-remapping-modal
"Hide the token remapping confirmation modal" "Hide the token remapping confirmation modal"
[] []
(st/emit! (modal/hide))) (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 ;; Remapping Modal Component
(mf/defc token-remapping-modal (mf/defc token-remapping-modal
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :tokens/remapping-confirmation} ::mf/register-as :tokens/remapping-confirmation
[{:keys [old-token-name new-token-name on-remap on-rename]}] ;; TODO: Uncomment when modal components support schema validation
(let [remap-modal (get @st/state :remap-modal) ;; ::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 ;; Remap logic on confirm
confirm-remap confirm-remap
(mf/use-fn (mf/use-fn
(mf/deps on-remap remap-modal) (mf/deps on-remap old-name new-name)
(fn [] (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) (when (fn? on-remap)
(on-remap)))) (on-remap))))
@ -83,9 +97,13 @@
:id "modal-title" :id "modal-title"
:typography "headline-large" :typography "headline-large"
:class (stl/css :modal-title)} :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)} [: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")]] [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-time")]]
[:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :action-buttons)}

View File

@ -4272,6 +4272,26 @@ msgstr "Pàgines"
msgid "workspace.sitemap" msgid "workspace.sitemap"
msgstr "Mapa del lloc" 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 #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
msgid "workspace.toolbar.assets" msgid "workspace.toolbar.assets"
msgstr "Recursos" msgstr "Recursos"

View File

@ -8301,11 +8301,19 @@ msgstr "Remap tokens"
msgid "workspace.tokens.remap-token-references-title" msgid "workspace.tokens.remap-token-references-title"
msgstr "Remap all tokens that use `%s` to `%s`?" msgstr "Remap all tokens that use `%s` to `%s`?"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-warning-effects" 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." 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" msgid "workspace.tokens.remap-warning-time"
msgstr "This action could take a while." 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" msgid "workspace.tokens.error-text-edition"
msgstr "Tokens can't be applied while editing text. Select the text layer instead." 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 #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
msgid "workspace.toolbar.assets" msgid "workspace.toolbar.assets"
msgstr "Assets" msgstr "Assets"

View File

@ -8175,11 +8175,13 @@ msgstr "Actualizar tokens"
msgid "workspace.tokens.remap-token-references-title" msgid "workspace.tokens.remap-token-references-title"
msgstr "¿Actualizar todas las referencias de `%s` a `%s`?" 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 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88
msgid "workspace.tokens.remap-warning-effects" msgid "workspace.tokens.remap-warning-effects"
msgstr "" msgstr "Esta acción actualizará todas las capas y referencias que usen el token antiguo"
"Esta acción actualizará todas las capas y referencias que usen el token "
"antiguo"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89
msgid "workspace.tokens.remap-warning-time" msgid "workspace.tokens.remap-warning-time"
@ -8404,6 +8406,14 @@ msgstr ""
msgid "workspace.tokens.error-text-edition" msgid "workspace.tokens.error-text-edition"
msgstr "No se pueden aplicar tokens mientras se edita texto. Seleccione la capa de texto en su lugar." 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 #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
msgid "workspace.toolbar.assets" msgid "workspace.toolbar.assets"
msgstr "Recursos" msgstr "Recursos"