From f120cf82d38707181a73f0285d5d99f2182fc55d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 22 Oct 2025 10:24:57 +0200 Subject: [PATCH] :sparkles: Select boards to export to PDF --- CHANGES.md | 1 + .../workspace/get-file-export-frames.json | 403 ++++++++++++++++++ .../playwright/ui/specs/export-frames.spec.js | 191 +++++++++ .../src/app/main/ui/workspace/main_menu.cljs | 7 +- 4 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 frontend/playwright/data/workspace/get-file-export-frames.json create mode 100644 frontend/playwright/ui/specs/export-frames.spec.js diff --git a/CHANGES.md b/CHANGES.md index 8080ea27e5..d43e088b6e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,7 @@ ### :heart: Community contributions (Thank you!) ### :sparkles: New features & Enhancements +- Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320) ### :bug: Bugs fixed diff --git a/frontend/playwright/data/workspace/get-file-export-frames.json b/frontend/playwright/data/workspace/get-file-export-frames.json new file mode 100644 index 0000000000..dd58f84e11 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-export-frames.json @@ -0,0 +1,403 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u5d1327cf-3054-8111-8005-328a160ff966", + "~: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": "New File 5", + "~:revn": 4, + "~:modified-at": "~m1761108835447", + "~:vern": 0, + "~:id": "~u8d102c33-b98f-81f6-8006-fdd4ea9780d7", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects" + ] + }, + "~:version": 67, + "~:project-id": "~u5d1327cf-3054-8111-8005-328a161446c2", + "~:created-at": "~m1761108772446", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u8d102c33-b98f-81f6-8006-fdd4ea9780d8" + ], + "~:pages-index": { + "~u8d102c33-b98f-81f6-8006-fdd4ea9780d8": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 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 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 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, + "~:r4": 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": [ + "~uc4dbee46-cbea-80d3-8006-fdd4ed673ac9", + "~uc4dbee46-cbea-80d3-8006-fdd4efef17bb" + ] + } + }, + "~uc4dbee46-cbea-80d3-8006-fdd4ed673ac9": { + "~#shape": { + "~:y": 296, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board 1", + "~:width": 190, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 624, + "~:y": 296 + } + }, + { + "~#point": { + "~:x": 814, + "~:y": 296 + } + }, + { + "~#point": { + "~:x": 814, + "~:y": 496 + } + }, + { + "~#point": { + "~:x": 624, + "~:y": 496 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~uc4dbee46-cbea-80d3-8006-fdd4ed673ac9", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 624, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 624, + "~:y": 296, + "~:width": 190, + "~:height": 200, + "~:x1": 624, + "~:y1": 296, + "~:x2": 814, + "~:y2": 496 + } + }, + "~:fills": [ + { + "~:fill-color": "#410bb2", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 200, + "~:flip-y": null, + "~:shapes": [] + } + }, + "~uc4dbee46-cbea-80d3-8006-fdd4efef17bb": { + "~#shape": { + "~:y": 383, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board 2", + "~:width": 226, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1117, + "~:y": 383 + } + }, + { + "~#point": { + "~:x": 1343, + "~:y": 383 + } + }, + { + "~#point": { + "~:x": 1343, + "~:y": 618 + } + }, + { + "~#point": { + "~:x": 1117, + "~:y": 618 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~uc4dbee46-cbea-80d3-8006-fdd4efef17bb", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 1117, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1117, + "~:y": 383, + "~:width": 226, + "~:height": 235, + "~:x1": 1117, + "~:y1": 383, + "~:x2": 1343, + "~:y2": 618 + } + }, + "~:fills": [ + { + "~:fill-color": "#e30808", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 235, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~u8d102c33-b98f-81f6-8006-fdd4ea9780d8", + "~:name": "Page 1" + } + }, + "~:id": "~u8d102c33-b98f-81f6-8006-fdd4ea9780d7", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } + } \ No newline at end of file diff --git a/frontend/playwright/ui/specs/export-frames.spec.js b/frontend/playwright/ui/specs/export-frames.spec.js new file mode 100644 index 0000000000..5a7f5bf932 --- /dev/null +++ b/frontend/playwright/ui/specs/export-frames.spec.js @@ -0,0 +1,191 @@ +import { test, expect } from "@playwright/test"; +import { WorkspacePage } from "../pages/WorkspacePage"; + +test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); +}); + +/** + * Helper function to setup workspace with frames for export testing + */ +async function setupWorkspaceWithFrames(workspacePage) { + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-export-frames.json", + ); + await workspacePage.mockRPC( + "get-file-object-thumbnails?file-id=*", + "workspace/get-file-object-thumbnails-blank.json", + ); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*", + "workspace/get-file-fragment-blank.json", + ); + await workspacePage.goToWorkspace({ + fileId: "8d102c33-b98f-81f6-8006-fdd4ea9780d7", + pageId: "8d102c33-b98f-81f6-8006-fdd4ea9780d8", + }); +} + +test.describe("Export frames to PDF", () => { + test("Export frames menu option is NOT visible when page has no frames", async ({ + page, + }) => { + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + + await workspacePage.goToWorkspace(); + + // Open main menu + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + + // The "Export frames to PDF" option should NOT be visible when there are no frames + await expect( + page.locator("#file-menu-export-frames"), + ).not.toBeVisible(); + }); + + test("Export frames menu option is visible when there are frames (even if not selected)", async ({ + page, + }) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspaceWithFrames(workspacePage); + + // Open main menu + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + + // The "Export frames to PDF" option should be visible when there are frames on the page + await expect( + page.locator("#file-menu-export-frames"), + ).toBeVisible(); + }); + + test("Export frames modal shows all frames when none are selected", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspaceWithFrames(workspacePage); + + // Don't select any frame + + // Open main menu + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + + // Click on "Export frames to PDF" + await page.locator("#file-menu-export-frames").click(); + + // The modal title should be correct + await expect(page.getByText("Export as PDF")).toBeVisible(); + + // Both frames should appear in the list + await expect(page.getByRole("button", { name: "Board 1" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Board 2" })).toBeVisible(); + + // The selection counter should show "2 of 2" + await expect(page.getByText("2 of 2 elements selected")).toBeVisible(); + }); + + test("Export frames modal shows only the selected frames", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspaceWithFrames(workspacePage); + + // Select Frame 1 + await workspacePage.clickLeafLayer("Board 1"); + + // Open main menu + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + + // Click on "Export frames to PDF" + await page.locator("#file-menu-export-frames").click(); + + // The modal title should be correct + await expect(page.getByText("Export as PDF")).toBeVisible(); + + // Only Frame 1 should appear in the list + // await page.getByRole("button", { name: "Board 1" }), + await expect(page.getByRole("button", { name: "Board 1" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Board 2" })).not.toBeVisible(); + + // The selection counter should show "1 of 1" + await expect(page.getByText("1 of 1 elements selected")).toBeVisible(); + }); + + test("User can deselect frames in the export modal", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspaceWithFrames(workspacePage); + + // Select Frame 1 + await workspacePage.clickLeafLayer("Board 1"); + + // Add Frame 2 to selection + await page.keyboard.down("Shift"); + await workspacePage.clickLeafLayer("Board 2"); + await page.keyboard.up("Shift"); + + // Open main menu and click export + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-export-frames").click(); + + // The modal should appear with 2 frames + await expect(page.getByText("2 of 2 elements selected")).toBeVisible(); + + // Click on the checkbox next to Frame 1 to deselect it + await page.getByRole("button", { name: "Board 1" }).click(); + + // Now only 1 frame should be selected + await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); + + // The export button should still be enabled + const exportButton = page.locator("input[type='button'][value='Export']"); + await expect(exportButton).toBeEnabled(); + }); + + test("Export button is disabled when all frames are deselected", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspaceWithFrames(workspacePage); + + // Select Frame 1 + await workspacePage.clickLeafLayer("Board 1"); + + // Open main menu and click export + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-export-frames").click(); + + // Deselect the frame + await page.getByRole("button", { name: "Board 1" }).click(); + + // Now 0 frames are selected + await expect(page.getByText("0 of 1 elements selected")).toBeVisible(); + + // // The export button should be disabled + await expect(page.getByRole("button", { name: "Export" , exact: true})).toBeDisabled(); + }); + + test("User can cancel the export modal", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await setupWorkspaceWithFrames(workspacePage); + + // Select Frame 1 + await workspacePage.clickLeafLayer("Board 1"); + + // Open main menu and click export + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-export-frames").click(); + + // Now only 1 frame should be selected + await expect(page.getByText("1 of 1 elements selected")).toBeVisible(); + + // Click cancel + await page.getByRole("button", { name: "Cancel" }).click(); + + // The modal should be hidden + await expect(page.getByText("0 of 1 elements selected")).not.toBeVisible(); + }); +}); + diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index e71929e508..494341cb5a 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -519,9 +519,14 @@ shared? (:is-shared file) objects (mf/deref refs/workspace-page-objects) - frames (->> (cfh/get-immediate-children objects uuid/zero) + selected (mf/deref refs/selected-shapes) + all-frames (->> (cfh/get-immediate-children objects uuid/zero) (filterv cfh/frame-shape?)) + ;; If there are selected frames, use only those. Otherwise, use all frames + selected-frames (filterv #(contains? selected (:id %)) all-frames) + frames (if (seq selected-frames) selected-frames all-frames) + perms (mf/use-ctx ctx/permissions) can-edit (:can-edit perms)