🎉 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
22 changed files with 687 additions and 59 deletions

View File

@@ -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

View File

@@ -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:

View File

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

View File

@@ -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:

View File

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

View File

@@ -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", {

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?)

View File

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

View File

@@ -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!

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
[: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"

View File

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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"