🎉 Duplicate token group (#8886)

This commit is contained in:
Xaviju 2026-04-10 10:42:35 +02:00 committed by GitHub
parent a803bde2ff
commit 9e4c8981be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 271 additions and 147 deletions

View File

@ -18,6 +18,7 @@
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
- Copy and paste entire rows in existing table (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8498)
- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137)
- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653)
- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568)
- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713)
- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466)
@ -84,7 +85,6 @@
- Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858)
- Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803)
## 2.14.1
### :sparkles: New features & Enhancements
@ -108,7 +108,6 @@
- Ensure path content is always PathData when saving
- Fix error when get-parent-with-data encounters non-Element nodes
## 2.14.0
### :boom: Breaking changes & Deprecations

View File

@ -2011,48 +2011,4 @@ test.describe("Tokens tab - delete", () => {
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(colorToken).not.toBeVisible();
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar } = await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
// Expand color tokens
await unfoldTokenType(tokensSidebar, "color");
// Verify that the node and child token are visible before deletion
const colorNode = tokensSidebar.getByRole("button", {
name: "colors",
exact: true,
});
const colorNodeToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
});
// Select a node and right click on it to open context menu
await expect(colorNode).toBeVisible();
await expect(colorNodeToken).toBeVisible();
await colorNode.click({ button: "right" });
// select "Delete" from the context menu
const deleteNodeButton = page.getByRole("button", {
name: "Delete",
exact: true,
});
await expect(deleteNodeButton).toBeVisible();
await deleteNodeButton.click();
// Verify that the node is removed
await expect(colorNode).not.toBeVisible();
// Verify that child token is also removed
await expect(colorNodeToken).not.toBeVisible();
// Save the type button to verify that expands/folds
const tokenTypeButton = await tokensSidebar.getByRole("button", {
name: "Color",
exact: true,
});
await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false");
});
});

View File

@ -332,6 +332,34 @@ const unfoldTokenType = async (tokensTabPanel, type) => {
}
};
const createToken = async (page, type, name, textFieldName, value) => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
flags: ["enable-token-shadow"],
});
// Create base token
await tokensTabPanel
.getByRole("button", { name: `Add Token: ${type}` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(name);
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: textFieldName,
});
await colorField.fill(value);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
export {
setupEmptyTokensFile,
setupEmptyTokensFileRender,
@ -341,4 +369,5 @@ export {
setupTypographyTokensFileRender,
testTokenCreationFlow,
unfoldTokenType,
createToken,
};

View File

@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
import {
createToken,
setupTokensFileRender,
setupTypographyTokensFileRender,
} from "./helpers";
@ -14,34 +15,6 @@ test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
const createToken = async (page, type, name, textFieldName, value) => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
flags: ["enable-token-shadow"],
});
// Create base token
await tokensTabPanel
.getByRole("button", { name: `Add Token: ${type}` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(name);
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: textFieldName,
});
await colorField.fill(value);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
const createTokenCombobox = async (page, type, name, textFieldName, value) => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@ -636,62 +609,6 @@ test.describe("Remapping a single token", () => {
});
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);

View File

@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
import { setupTokensFileRender } from "./helpers";
import { createToken, setupTokensFileRender, unfoldTokenType } from "./helpers";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
@ -27,4 +27,161 @@ test.describe("Tokens - node tree", () => {
await tokensColorGroup.click();
await expect(colorToken).not.toBeVisible();
});
test("User renames a group", 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 duplicates a group", 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 "Duplicate" from the context menu
const duplicateNodeButton = page.getByRole("button", {
name: "Duplicate",
exact: true,
});
await expect(duplicateNodeButton).toBeVisible();
await duplicateNodeButton.click();
// Expect the duplicate modal to be visible, fill in the new name and submit
const tokenDuplicateNodeModal = page.getByTestId("token-rename-node-modal");
await expect(tokenDuplicateNodeModal).toBeVisible();
const nameField = tokenDuplicateNodeModal.getByRole("textbox", {
name: "Name",
});
await nameField.fill("darker");
const submitButton = tokenDuplicateNodeModal.getByRole("button", {
name: "Duplicate",
});
await submitButton.click();
// Verify that the node has been duplicated and tokens are visible
const darkerNode = tokensSidebar.getByRole("button", {
name: "darker",
exact: true,
});
const darkerNodeToken = tokensSidebar.getByRole("button", {
name: "darker.primary",
});
await expect(darkerNode).toBeVisible();
await expect(darkerNodeToken).toBeVisible();
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar } = await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
// Expand color tokens
await unfoldTokenType(tokensSidebar, "color");
// Verify that the node and child token are visible before deletion
const colorNode = tokensSidebar.getByRole("button", {
name: "colors",
exact: true,
});
const colorNodeToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
});
// Select a node and right click on it to open context menu
await expect(colorNode).toBeVisible();
await expect(colorNodeToken).toBeVisible();
await colorNode.click({ button: "right" });
// select "Delete" from the context menu
const deleteNodeButton = page.getByRole("button", {
name: "Delete",
exact: true,
});
await expect(deleteNodeButton).toBeVisible();
await deleteNodeButton.click();
// Verify that the node is removed
await expect(colorNode).not.toBeVisible();
// Verify that child token is also removed
await expect(colorNodeToken).not.toBeVisible();
// Save the type button to verify that expands/folds
const tokenTypeButton = await tokensSidebar.getByRole("button", {
name: "Color",
exact: true,
});
await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false");
});
});

View File

@ -11,6 +11,8 @@
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.logic.tokens :as clt]
[app.common.path-names :as cpn]
[app.common.test-helpers.ids-map :as cthi]
[app.common.types.shape :as cts]
[app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid]
@ -461,6 +463,37 @@
(rx/of (create-token-with-set token)))))))
(defn bulk-create-tokens
[set-id token-ids type node new-node-name]
(assert (uuid? set-id) "expected uuid for `set-id`")
(assert (every? uuid? token-ids) "expected a collection of uuids for `token-ids`")
(assert (keyword? type) "expected keyword for `type`")
(assert (string? new-node-name) "expected string for `new-node-name`")
(ptk/reify ::bulk-create-tokens
ptk/WatchEvent
(watch [it state _]
(let [token-set (lookup-token-set state set-id)
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 (->
(cpn/split-path (:name token) :separator ".")
(assoc (:depth node) new-node-name)
(cpn/join-path :separator "." :with-spaces? false))
token' (->> (merge token {:name new-name
:id (cthi/new-id! (:name new-name))})
(into {})
(ctob/make-token))]
(pcb/set-token changes (ctob/get-id token-set) (:id token') token')))
(-> (pcb/empty-changes it)
(pcb/with-library-data data))
token-ids)]
(rx/of
(dch/commit-changes changes)
(ptk/data-event ::ev/event {::ev/name "bulk-create-tokens" :type type}))))))
(defn update-token
([id params] (update-token nil id params))
([set-id id params]

View File

@ -22,7 +22,6 @@
[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]))
@ -136,8 +135,7 @@
(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)})]
(let [token-path (cpn/split-path (:name token) :separator ".")]
(and (> (count token-path) 0)
(str/starts-with? (:name token) (str (:path node) ".")))))))))
@ -192,6 +190,8 @@
(st/emit! (dwtl/toggle-token-path (str (name type) "." path)))
(st/emit! (dwtl/close-token-type type))))))
bulk-rename-tokens-in-path
;; Rename tokens in bulk affected by a node rename.
(mf/use-fn
@ -254,7 +254,6 @@
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
@ -263,6 +262,13 @@
(on-remap-node-warning node type new-node-name)
(bulk-rename-tokens-in-path node type new-node-name)))))
on-duplicate-node
(fn [node type new-node-name]
(let [tokens-in-path-ids (filter-tokens-by-path-ids type (:path node))]
(st/emit!
(modal/hide)
(dwtl/bulk-create-tokens selected-token-set-id tokens-in-path-ids type node new-node-name))))
open-rename-node-modal
;; When user renames a node, we display a form modal
(mf/use-fn
@ -271,7 +277,18 @@
(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})))))]
:on-rename on-rename-node-handler})))))
open-duplicate-node-modal
(mf/use-fn
(mf/deps selected-token-set-tokens on-duplicate-node)
(fn [node type]
(let [on-duplicate-node-handler #(on-duplicate-node node type %)]
(st/emit! (modal/show :tokens/rename-node {:new-node-name (str (:name node) "-copy")
:node node
:variant "duplicate"
:tokens-in-active-set selected-token-set-tokens
:on-rename on-duplicate-node-handler})))))]
(mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib
@ -286,6 +303,7 @@
[:*
[:& token-context-menu {:on-delete-token delete-token}]
[:> token-node-context-menu* {:on-rename-node open-rename-node-modal
:on-duplicate-node open-duplicate-node-modal
:on-delete-node delete-node}]
[:> selected-set-info* {:tokens-lib tokens-lib

View File

@ -18,15 +18,15 @@
[rumext.v2 :as mf]))
(mf/defc rename-node-form*
[{:keys [node active-tokens tokens-tree on-close on-submit]}]
[{:keys [new-node-name node active-tokens tokens-tree variant 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)})
initial (mf/with-memo [node new-node-name]
{:name (d/nilv new-node-name (:name node))})
form (fm/use-form :schema schema
:initial initial)
@ -35,11 +35,10 @@
(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)))
(when (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)))
hint-path (mf/with-memo [@form node]
@ -64,7 +63,7 @@
:max-length 255
:variant "comfortable"
:hint-type "hint"
:hint-message (tr "workspace.tokens.rename-group-name-hint" hint-path)
:hint-message (when (= variant "rename") (tr "workspace.tokens.rename-group-name-hint" hint-path))
:auto-focus true}]
[:div {:class (stl/css :form-actions)}
[:> button* {:variant "secondary"
@ -72,14 +71,16 @@
:on-click on-close} (tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:disabled is-disabled?
:name "rename"} (tr "labels.rename")]]]))
:name "rename"} (if (= variant "rename") (tr "labels.rename") (tr "labels.duplicate"))]]]))
(mf/defc rename-node-modal
{::mf/register modal/components
::mf/register-as :tokens/rename-node}
[{:keys [node tokens-in-active-set on-rename]}]
[{:keys [new-node-name node tokens-in-active-set on-rename variant]}]
(let [tokens-tree-in-selected-set
(let [variant (d/nilv variant "rename") ;; "rename" or "duplicate"
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))))
@ -111,7 +112,9 @@
:aria-label (tr "labels.close")
:variant "ghost"
:icon i/close}]
[:> rename-node-form* {:node node
[:> rename-node-form* {:new-node-name new-node-name
:node node
:variant variant
:active-tokens tokens-in-active-set
:tokens-tree tokens-tree-in-selected-set
:on-close close-modal

View File

@ -27,7 +27,7 @@
(mf/defc token-node-context-menu*
{::mf/schema schema:token-node-context-menu}
[{:keys [on-rename-node on-delete-node]}]
[{:keys [on-rename-node on-duplicate-node on-delete-node]}]
(let [mdata (mf/deref tokens-node-menu-ref)
is-open? (boolean mdata)
dropdown-ref (mf/use-ref)
@ -44,6 +44,13 @@
type (get mdata :type)]
(when node
(on-rename-node node type)))))
duplicate-node (mf/use-fn
(mf/deps mdata on-duplicate-node)
(fn []
(let [node (get mdata :node)
type (get mdata :type)]
(when node
(on-duplicate-node node type)))))
container (hooks/use-portal-container)
delete-node (mf/use-fn
@ -90,6 +97,11 @@
: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"
:on-click duplicate-node}
(tr "labels.duplicate")]]
[:li {:class (stl/css :token-node-context-menu-listitem)}
[:button {:class (stl/css :token-node-context-menu-action)
:type "button"