penpot/frontend/playwright/ui/pages/ShortcutsPage.js
andrés gonzález 85ba670278
💄 Unify copy in the shortcuts settings, modals and sidebar (#12174)
* 💄 Unify copy in the shortcuts settings, modals and sidebar

Use one term per concept across the customizable shortcuts UI: "Custom"
and "Customize" for user changes, "shortcut" for the keys, and "Reset"
with "default" to go back to the defaults.

Rewrite the conflict message so it says what saving does, fix typos and
grammar, unify the empty states and rename the font size actions.

Give the reset all button, the search placeholder, the import column,
the invalid import error and the edit button aria-label their own keys,
and update the e2e tests to the new copy.

Closes #12173

AI-assisted-by: claude-opus-5-5

* 💄 Reword the shortcuts sidebar link and custom empty state

The sidebar link no longer names a settings section that does not exist
in Penpot. The empty state of the custom tab now says there are no
custom shortcuts and points to the All tab by name.

AI-assisted-by: claude-opus-5-5
2026-10-08 12:00:17 +02:00

339 lines
9.1 KiB
JavaScript

import { expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage";
function decodeKeyword(value) {
if (typeof value === "string" && value.startsWith("~:")) {
return value.slice(2);
}
if (typeof value === "string" && value.startsWith("~$")) {
return value.slice(2);
}
return value;
}
function decodeTransit(data) {
if (Array.isArray(data)) {
if (data[0] === "^ ") {
const result = {};
for (let i = 1; i < data.length; i += 2) {
const key = decodeTransit(data[i]);
const value = decodeTransit(data[i + 1]);
result[key] = value;
}
return result;
}
return data.map(decodeTransit);
}
if (data !== null && typeof data === "object") {
const result = {};
for (const [key, value] of Object.entries(data)) {
result[decodeKeyword(key)] = decodeTransit(value);
}
return result;
}
return decodeKeyword(data);
}
function encodeKeyword(key) {
return `~:${key}`;
}
function encodeTransit(data) {
if (data === null || typeof data === "boolean" || typeof data === "number") {
return data;
}
if (typeof data === "string") {
return data;
}
if (Array.isArray(data)) {
return data.map(encodeTransit);
}
const result = {};
for (const [key, value] of Object.entries(data)) {
result[encodeKeyword(key)] = encodeTransit(value);
}
return result;
}
function decodeRequestBody(body) {
try {
return decodeTransit(JSON.parse(body));
} catch {
return null;
}
}
export class ShortcutsPage extends BaseWebSocketPage {
static async init(page) {
await super.init(page);
const profileText = await readFile(
"playwright/data/logged-in-user/get-profile-logged-in.json",
"utf-8",
);
const baseProfile = decodeTransit(JSON.parse(profileText));
let customShortcuts = null;
await page.route("**/api/main/methods/get-profile", (route) => {
const profile = JSON.parse(JSON.stringify(baseProfile));
if (customShortcuts) {
profile.props["custom-shortcuts"] = customShortcuts;
}
route.fulfill({
status: 200,
contentType: "application/transit+json",
body: JSON.stringify(encodeTransit(profile)),
});
});
await page.route(
"**/api/main/methods/update-profile-props",
async (route, request) => {
const decoded = decodeRequestBody(request.postData() ?? "{}");
if (decoded?.props && "custom-shortcuts" in decoded.props) {
customShortcuts = decoded.props["custom-shortcuts"];
}
route.fulfill({
status: 200,
contentType: "application/transit+json",
body: "{}",
});
},
);
await super.mockRPC(
page,
"get-teams",
"logged-in-user/get-teams-default.json",
);
}
static async initWithShortcuts(page) {
await super.init(page);
await super.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-teams": "logged-in-user/get-teams-default.json",
"update-profile-props":
"logged-in-user/update-profile-with-shortcuts.json",
});
}
static async initWithNoShortcuts(page) {
await super.init(page);
await super.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-no-shortcuts.json",
"get-teams": "logged-in-user/get-teams-default.json",
"update-profile-props":
"logged-in-user/update-profile-with-shortcuts.json",
});
}
constructor(page) {
super(page);
this.shortcutsSection = page.getByRole("region", { name: /shortcuts/i });
this.searchInput = page.getByRole("textbox", { name: /shortcuts/i });
this.allTab = page.getByRole("tab", { name: "All" });
this.personalizedTab = page.getByRole("tab", { name: "Custom" });
this.disabledTab = page.getByRole("tab", { name: "Disabled" });
this.restoreAllButton = page.getByRole("button", {
name: /reset all/i,
});
this.importExportButton = page.getByRole("button", {
name: /import\/export/i,
});
this.fileInput = page.locator('input[type="file"]');
}
async goToShortcuts() {
await this.page.goto("/?screen=settings-shortcuts");
await expect(this.shortcutsSection).toBeVisible();
}
async searchForShortcut(term) {
await this.searchInput.fill(term);
}
async clearSearch() {
await this.searchInput.clear();
}
async clickTab(tabName) {
const tab = this.page.getByRole("tab", { name: tabName });
await tab.click();
}
async getShortcutRow(shortcutName) {
return this.page.getByRole("listitem", {
name: new RegExp(`^${shortcutName}$`, "i"),
includeHidden: true,
});
}
async expandSubsection(subsectionName) {
const subsectionButton = this.page.getByRole("button", {
name: subsectionName,
});
await subsectionButton.click();
}
async clickEditShortcut(shortcutName) {
const button = this.page.getByRole("button", {
name: new RegExp(`Edit ${shortcutName}`, "i"),
});
await button.click();
const recordingArea = this.page.getByText("Press the new keys");
await expect(recordingArea).toBeVisible();
await recordingArea.focus();
}
async pressKey(key) {
await this.page.keyboard.press(key);
}
async pressKeyCombo(keys) {
for (const key of keys) {
await this.page.keyboard.down(key);
}
for (const key of [...keys].reverse()) {
await this.page.keyboard.up(key);
}
}
async saveShortcut() {
const saveButton = this.page.getByRole("button", { name: /save/i });
await saveButton.click();
}
async cancelEdit() {
const cancelButton = this.page.getByRole("button", { name: /cancel/i });
await cancelButton.click();
}
async resetShortcut(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
const resetButton = row.getByRole("button", { name: /reset/i });
await resetButton.click();
}
async disableShortcut(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await row
.getByRole("button", { name: new RegExp(`Edit ${shortcutName}`, "i") })
.click();
const disableButton = this.page.getByRole("button", { name: /disable/i });
await disableButton.click();
}
async expectShortcutCustomized(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toHaveAttribute("data-customized", "true");
}
async expectShortcutNotCustomized(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).not.toHaveAttribute("data-customized", "true");
}
async expectShortcutHasConflict(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toHaveAttribute("data-conflict", "true");
}
async expectShortcutVisible(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toBeVisible();
}
async expectShortcutHidden(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).not.toBeVisible();
}
async restoreAllShortcuts() {
await this.restoreAllButton.click();
const confirmButton = this.page.getByRole("button", {
name: "Reset",
exact: true,
});
await confirmButton.click();
}
async exportShortcuts() {
await this.importExportButton.click();
const exportButton = this.page.getByRole("menuitem", { name: /export/i });
await exportButton.click();
}
async importShortcuts(jsonData) {
await this.importExportButton.click();
const importButton = this.page.getByRole("menuitem", { name: /import$/i });
await importButton.click();
const buffer = Buffer.from(JSON.stringify(jsonData), "utf-8");
await this.fileInput.setInputFiles({
name: "shortcuts.json",
mimeType: "application/json",
buffer,
});
}
async importShortcutsRaw(content) {
await this.importExportButton.click();
const importButton = this.page.getByRole("menuitem", { name: /import$/i });
await importButton.click();
const data =
typeof content === "string" ? content : JSON.stringify(content);
const buffer = Buffer.from(data, "utf-8");
await this.fileInput.setInputFiles({
name: "shortcuts.json",
mimeType: "application/json",
buffer,
});
}
async confirmImportApply() {
const applyButton = this.page.getByRole("button", {
name: "Import",
exact: true,
});
await applyButton.click();
}
async getExportedJson() {
const [download] = await Promise.all([
this.page.waitForEvent("download"),
this.exportShortcuts(),
]);
expect(download.suggestedFilename()).toMatch(
/^penpot-shortcuts-Princesa_Leia-\d{4}-\d{2}-\d{2}\.json$/,
);
const path = await download.path();
const content = await readFile(path, "utf-8");
return JSON.parse(content);
}
async expectShortcutDisabled(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toHaveAttribute("data-customized", "true");
await expect(row.locator("use[href*='broken-link']")).toBeVisible();
}
}
export default ShortcutsPage;