diff --git a/frontend/playwright/data/workspace/get-file-rename-page.json b/frontend/playwright/data/workspace/get-file-rename-page.json new file mode 100644 index 0000000000..03d0afb246 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-rename-page.json @@ -0,0 +1,308 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Rename Page Test File", + "~:revn": 1, + "~:modified-at": "~m1713873823633", + "~:id": "~uaaaaaaaa-0000-0000-0000-000000000001", + "~:is-shared": false, + "~:version": 46, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713536343369", + "~:data": { + "~:pages": [ + "~ubbbbbbbb-0000-0000-0000-000000000001", + "~ucccccccc-0000-0000-0000-000000000002", + "~udddddddd-0000-0000-0000-000000000003" + ], + "~:pages-index": { + "~ubbbbbbbb-0000-0000-0000-000000000001": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + {"~#point": {"~:x": 0, "~:y": 0}}, + {"~#point": {"~:x": 0.01, "~:y": 0}}, + {"~#point": {"~:x": 0.01, "~:y": 0.01}}, + {"~#point": {"~:x": 0, "~:y": 0.01}} + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": ["~ueeeeeee0-0000-0000-0000-000000000001"] + } + }, + "~ueeeeeee0-0000-0000-0000-000000000001": { + "~#shape": { + "~:y": 100, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 200, + "~:type": "~:rect", + "~:points": [ + {"~#point": {"~:x": 100, "~:y": 100}}, + {"~#point": {"~:x": 300, "~:y": 100}}, + {"~#point": {"~:x": 300, "~:y": 300}}, + {"~#point": {"~:x": 100, "~:y": 300}} + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ueeeeeee0-0000-0000-0000-000000000001", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 100, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 100, + "~:y": 100, + "~:width": 200, + "~:height": 200, + "~:x1": 100, + "~:y1": 100, + "~:x2": 300, + "~:y2": 300 + } + }, + "~:fills": [{"~:fill-color": "#B1B2B5", "~:fill-opacity": 1}], + "~:flip-x": null, + "~:ry": 0, + "~:height": 200, + "~:flip-y": null + } + } + }, + "~:id": "~ubbbbbbbb-0000-0000-0000-000000000001", + "~:name": "Page 1" + }, + "~ucccccccc-0000-0000-0000-000000000002": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + {"~#point": {"~:x": 0, "~:y": 0}}, + {"~#point": {"~:x": 0.01, "~:y": 0}}, + {"~#point": {"~:x": 0.01, "~:y": 0.01}}, + {"~#point": {"~:x": 0, "~:y": 0.01}} + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~ucccccccc-0000-0000-0000-000000000002", + "~:name": "Page 2" + }, + "~udddddddd-0000-0000-0000-000000000003": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + {"~#point": {"~:x": 0, "~:y": 0}}, + {"~#point": {"~:x": 0.01, "~:y": 0}}, + {"~#point": {"~:x": 0.01, "~:y": 0.01}}, + {"~#point": {"~:x": 0, "~:y": 0.01}} + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [{"~:fill-color": "#FFFFFF", "~:fill-opacity": 1}], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~udddddddd-0000-0000-0000-000000000003", + "~:name": "Page 3" + } + }, + "~:id": "~uaaaaaaaa-0000-0000-0000-000000000001", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [] + } +} diff --git a/frontend/playwright/ui/specs/rename-page.spec.js b/frontend/playwright/ui/specs/rename-page.spec.js new file mode 100644 index 0000000000..f5e070f3b2 --- /dev/null +++ b/frontend/playwright/ui/specs/rename-page.spec.js @@ -0,0 +1,127 @@ +import { test, expect } from "@playwright/test"; +import { WorkspacePage } from "../pages/WorkspacePage"; + +// UUIDs matching the fixture file `workspace/get-file-rename-page.json` +const FILE_ID = "aaaaaaaa-0000-0000-0000-000000000001"; +const PAGE1_ID = "bbbbbbbb-0000-0000-0000-000000000001"; // non-empty (has a rectangle) +const PAGE2_ID = "cccccccc-0000-0000-0000-000000000002"; // empty +const PAGE3_ID = "dddddddd-0000-0000-0000-000000000003"; // empty + +test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); +}); + +/** + * Returns a locator for the interactive body of a page item in the sitemap + * sidebar, identified by its page UUID. + */ +function getPageItem(page, pageId) { + return page.getByTestId(`page-${pageId}`); +} + +/** + * Returns the visible name span inside a page item (not in edit mode). + */ +function getPageNameSpan(page, pageId) { + return getPageItem(page, pageId).getByTestId("page-name"); +} + +/** + * Double-clicks a page item to enter rename mode, types the new name and + * confirms with Enter. + */ +async function renamePage(page, pageId, newName) { + const item = getPageItem(page, pageId); + await item.dblclick(); + const input = item.locator("input"); + await expect(input).toBeVisible(); + await input.selectText(); + await input.fill(newName); + await page.keyboard.press("Enter"); +} + +async function setupWorkspace(workspacePage) { + await workspacePage.setupEmptyFile(); + + // Override the file mock with the 3-page fixture. + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-rename-page.json", + ); + + // Mock update-file so rename commits don't fail. + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + + // The base WorkspacePage uses `this.pageName` (getByTestId("page-name")) as a + // readiness signal inside goToWorkspace. With 3 pages in the sidebar there are + // 3 matching elements, which violates Playwright's strict mode. Narrow the + // locator to the first occurrence so the internal readiness check can pass. + workspacePage.pageName = workspacePage.page + .getByTestId("page-name") + .first(); + + await workspacePage.goToWorkspace({ + fileId: FILE_ID, + pageId: PAGE1_ID, + pageName: "Page 1", + }); +} + +test("User renames a non-empty page to '---' — page is renamed, URL does not change", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspace(workspacePage); + + // Confirm we are on Page 1. + await expect(getPageNameSpan(page, PAGE1_ID)).toHaveText("Page 1"); + + const urlBefore = page.url(); + expect(urlBefore).toContain(`page-id=${PAGE1_ID}`); + + // Rename the non-empty page to "---". + await renamePage(page, PAGE1_ID, "---"); + + // The page name should have changed to "---". + await expect(getPageNameSpan(page, PAGE1_ID)).toHaveText("---"); + + // The URL must still point to the same page (no navigation triggered). + await expect(page).toHaveURL(new RegExp(`page-id=${PAGE1_ID}`)); + + // The page must NOT be rendered as a separator (it still has shapes). + await expect( + getPageItem(page, PAGE1_ID).getByTestId("page-separator"), + ).not.toBeVisible(); +}); + +test("User renames an empty page to '---' — page becomes a separator and URL changes", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspace(workspacePage); + + // Navigate to the second page (empty) by clicking it in the sitemap. + const page2Item = getPageItem(page, PAGE2_ID); + await page2Item.click(); + + // Wait until the URL reflects the navigation to Page 2. + await expect(page).toHaveURL(new RegExp(`page-id=${PAGE2_ID}`)); + + // Rename the empty page to "---". + await renamePage(page, PAGE2_ID, "---"); + + // Since the renamed page is empty, it must now be shown as a separator. + await expect( + getPageItem(page, PAGE2_ID).getByTestId("page-separator"), + ).toBeVisible(); + + // The application must have navigated away from the separator page to + // a different page (Page 1 or Page 3 depending on fallback logic). + await expect(page).not.toHaveURL(new RegExp(`page-id=${PAGE2_ID}`)); + + // The current URL must still be a workspace URL pointing to the same file. + await expect(page).toHaveURL(new RegExp(`file-id=${FILE_ID}`)); +}); diff --git a/frontend/src/app/main/data/workspace/pages.cljs b/frontend/src/app/main/data/workspace/pages.cljs index 222a4a5c0e..c68070732a 100644 --- a/frontend/src/app/main/data/workspace/pages.cljs +++ b/frontend/src/app/main/data/workspace/pages.cljs @@ -329,6 +329,9 @@ ptk/WatchEvent (watch [it state _] (let [page (dsh/lookup-page state id) + objects (:objects page) + empty-page? (and (= 1 (count objects)) + (= uuid/zero (first (keys objects)))) changes (-> (pcb/empty-changes it) (pcb/with-page page) (pcb/mod-page page {:name name})) @@ -342,7 +345,11 @@ separator? (= "---" (str/trim name))] (rx/concat (rx/of (dch/commit-changes changes)) + ;; Go to other page only if page is empty (only has the root shape) + ;; and the separator page is being renamed, otherwise user can rename + ;; any page to separator and be forced to go to another page (when (and separator? + empty-page? (= id (:current-page-id state)) (some? fallback-page-id)) (rx/of (dcm/go-to-workspace :page-id fallback-page-id))))))))