From a056412df065cd002fb19af83904e8e54cc4fbee Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 8 Jun 2026 15:38:52 +0100 Subject: [PATCH 1/8] feat: add mermaid export functionality with copy and download options Signed-off-by: Cheryl Kong --- .../src/core/index.ts | 1 + .../src/core/mermaidExport.ts | 43 +++++++++++++++++ .../src/i18n/locales/en.ts | 3 ++ .../src/side-panel/SidePanel.tsx | 47 ++++++++++++++++++- 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts diff --git a/packages/serverless-workflow-diagram-editor/src/core/index.ts b/packages/serverless-workflow-diagram-editor/src/core/index.ts index f6362e8..2a34217 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/index.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/index.ts @@ -19,3 +19,4 @@ export * from "./graph"; export * from "./taskDetails"; export * from "./taskSubType"; export * from "./elkjs"; +export * from "./mermaidExport"; diff --git a/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts b/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts new file mode 100644 index 0000000..be94742 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { convertToMermaidCode } from "@serverlessworkflow/sdk"; +import type { Specification } from "@serverlessworkflow/sdk"; + +/** + * Converts a workflow model to Mermaid diagram code + * @param workflow - The workflow object (parsed from JSON/YAML) + * @returns Mermaid diagram code as a string + */ +export function exportToMermaid(workflow: Specification.Workflow): string { + return convertToMermaidCode(workflow); +} + +export function copyMermaidToClipboard(mermaidCode: string): Promise { + return navigator.clipboard.writeText(mermaidCode); +} + +export function downloadMermaidFile(mermaidCode: string, filename: string = "mermaid.mmd"): void { + const blob = new Blob([mermaidCode], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts index 1ba204e..0db71c0 100644 --- a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts @@ -36,6 +36,9 @@ export const en = { "sidebar.sectionSource": "Source", "sidebar.viewSource": "View source", "sidebar.noDetails": "No additional details for this node", + "sidebar.exportMermaid": "Export to Mermaid", + "sidebar.exportMermaid.copy": "Copy Mermaid Code", + "sidebar.exportMermaid.download": "Download as Mermaid File", } as const; export type TranslationKeys = keyof typeof en; diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx index 5578c07..cef7bb5 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -17,14 +17,22 @@ import * as React from "react"; import type * as RF from "@xyflow/react"; import { useI18n } from "@serverlessworkflow/i18n"; -import { Workflow, Info, Box } from "lucide-react"; -import { Sidebar, SidebarContent, SidebarHeader, useSidebar } from "@/components/ui/sidebar"; +import { Workflow, Info, Box, Copy, Download } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + useSidebar, + SidebarFooter, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; import { taskNodeConfigMap, type LeafNodeType } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; +import { exportToMermaid, copyMermaidToClipboard, downloadMermaidFile } from "@/core"; export function SidePanel() { const { model, nodes, selectedNodeId } = useDiagramEditorContext(); @@ -55,6 +63,27 @@ export function SidePanel() { setOpen(selectedNodeId !== null); }, [selectedNodeId, setOpen]); + const handleCopyMermaid = async () => { + if (!model) return; + try { + const mermaidCode = exportToMermaid(model); + await copyMermaidToClipboard(mermaidCode); + console.log("mermaid copied to clipboard"); + } catch (error) { + console.error("Failed to copy mermaid code:", error); + } + }; + + const handleDownloadMermaid = () => { + if (!model) return; + try { + const mermaidCode = exportToMermaid(model); + downloadMermaidFile(mermaidCode); + } catch (error) { + console.error("Failed to download mermaid file:", error); + } + }; + return ( @@ -92,6 +121,20 @@ export function SidePanel() { )} + + {model !== null && !selectedNode && ( + <> + + + + )} + ); } From 19194a12fe833ee798cc8aa8c260b84965bc69f9 Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 8 Jun 2026 17:20:04 +0100 Subject: [PATCH 2/8] test: add mermaid export tests Signed-off-by: Cheryl Kong --- .../tests/core/mermaidExport.test.ts | 33 +++++++++++++++++++ .../tests/side-panel/SidePanel.test.tsx | 32 ++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/serverless-workflow-diagram-editor/tests/core/mermaidExport.test.ts diff --git a/packages/serverless-workflow-diagram-editor/tests/core/mermaidExport.test.ts b/packages/serverless-workflow-diagram-editor/tests/core/mermaidExport.test.ts new file mode 100644 index 0000000..6cb5819 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/core/mermaidExport.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { exportToMermaid } from "../../src/core/mermaidExport"; +import { parseWorkflow } from "../../src/core/workflowSdk"; +import { BASIC_VALID_WORKFLOW_YAML } from "../fixtures/workflows"; + +describe("exportToMermaid", () => { + it("converts a valid workflow to Mermaid code", () => { + const { model } = parseWorkflow(BASIC_VALID_WORKFLOW_YAML); + expect(model).not.toBeNull(); + + const mermaidCode = exportToMermaid(model!); + expect(mermaidCode).toBeTruthy(); + expect(typeof mermaidCode).toBe("string"); + // Mermaid diagrams start with flowchart + expect(mermaidCode).toMatch(/flowchart/i); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx index 2721415..6bfdf74 100644 --- a/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx @@ -39,4 +39,36 @@ describe("SidePanel", () => { expect(screen.queryByTestId("workflow-info")).not.toBeInTheDocument(); }); + + it("renders export buttons when model is present and no node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + renderWithProviders(, { model, selectedNodeId: null }); + expect(screen.getByText(/Copy Mermaid Code/i)).toBeInTheDocument(); + expect(screen.getByText(/Download as Mermaid File/i)).toBeInTheDocument(); + }); + + it("does not render export buttons when a node is selected", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + const mockNode = { + id: "some-node-id", + type: "set", + position: { x: 0, y: 0 }, + data: { label: "Test Node" }, + }; + + renderWithProviders(, { + model, + selectedNodeId: "some-node-id", + nodes: [mockNode], + }); + expect(screen.queryByText(/Copy Mermaid Code/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Download as Mermaid File/i)).not.toBeInTheDocument(); + }); + + it("does not render export buttons when model is null", () => { + renderWithProviders(, { model: null }); + + expect(screen.queryByText(/Copy Mermaid Code/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Download as Mermaid File/i)).not.toBeInTheDocument(); + }); }); From 07bff754bc208fd797213e632d4d2c76c8448117 Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 9 Jun 2026 09:37:49 +0100 Subject: [PATCH 3/8] add changeset Signed-off-by: Cheryl Kong --- .changeset/mermaid-export.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mermaid-export.md diff --git a/.changeset/mermaid-export.md b/.changeset/mermaid-export.md new file mode 100644 index 0000000..65d06c4 --- /dev/null +++ b/.changeset/mermaid-export.md @@ -0,0 +1,5 @@ +--- +"@serverlessworkflow/diagram-editor": minor +--- + +Add mermaid export functionality From f1fa049cc41685ff98c835eb85148a7f979250cb Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 9 Jun 2026 11:57:08 +0100 Subject: [PATCH 4/8] feat: add copied confirmation and fix copilot complaints Signed-off-by: Cheryl Kong --- .changeset/mermaid-export.md | 8 +++++ .../src/core/mermaidExport.ts | 11 ++++++- .../src/side-panel/SidePanel.tsx | 6 ++-- .../tests/side-panel/SidePanel.test.tsx | 30 ++++++++++++++++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.changeset/mermaid-export.md b/.changeset/mermaid-export.md index 65d06c4..93bb496 100644 --- a/.changeset/mermaid-export.md +++ b/.changeset/mermaid-export.md @@ -3,3 +3,11 @@ --- Add mermaid export functionality +flowchart TD +root-entry-node(( )) +root-exit-node((( ))) +/do/0/getPet["getPet"] +/do/0/getPet --> root-exit-node +root-entry-node --> /do/0/getPet + +classDef hidden width: 1px, height: 1px; diff --git a/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts b/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts index be94742..f2125f4 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts @@ -27,10 +27,17 @@ export function exportToMermaid(workflow: Specification.Workflow): string { } export function copyMermaidToClipboard(mermaidCode: string): Promise { + if (typeof navigator === "undefined" || !navigator.clipboard) { + return Promise.reject(new Error("Clipboard API is not available in this environment")); + } return navigator.clipboard.writeText(mermaidCode); } export function downloadMermaidFile(mermaidCode: string, filename: string = "mermaid.mmd"): void { + if (typeof document === "undefined") { + throw new Error("Document API is not available in this environment"); + } + const blob = new Blob([mermaidCode], { type: "text/plain" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -39,5 +46,7 @@ export function downloadMermaidFile(mermaidCode: string, filename: string = "mer document.body.appendChild(link); link.click(); document.body.removeChild(link); - URL.revokeObjectURL(url); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 10); } diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx index cef7bb5..3beeba8 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -38,6 +38,7 @@ export function SidePanel() { const { model, nodes, selectedNodeId } = useDiagramEditorContext(); const { setOpen } = useSidebar(); const { t } = useI18n(); + const [isCopied, setIsCopied] = React.useState(false); const selectedNode = React.useMemo( () => @@ -68,7 +69,8 @@ export function SidePanel() { try { const mermaidCode = exportToMermaid(model); await copyMermaidToClipboard(mermaidCode); - console.log("mermaid copied to clipboard"); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); } catch (error) { console.error("Failed to copy mermaid code:", error); } @@ -126,7 +128,7 @@ export function SidePanel() { <> + + + ); +} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx index 5c867ca..69f2e3e 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -17,7 +17,7 @@ import * as React from "react"; import type * as RF from "@xyflow/react"; import { useI18n } from "@serverlessworkflow/i18n"; -import { Workflow, Info, Box, Copy, Download } from "lucide-react"; +import { Workflow, Info, Box } from "lucide-react"; import { Sidebar, SidebarContent, @@ -25,20 +25,18 @@ import { useSidebar, SidebarFooter, } from "@/components/ui/sidebar"; -import { Button } from "@/components/ui/button"; import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; +import { MermaidActions } from "@/side-panel/MermaidActions"; import { taskNodeConfigMap, type LeafNodeType } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; -import { exportToMermaid, copyMermaidToClipboard, downloadMermaidFile } from "@/core"; export function SidePanel() { const { model, nodes, selectedNodeId } = useDiagramEditorContext(); const { setOpen } = useSidebar(); const { t } = useI18n(); - const [isCopied, setIsCopied] = React.useState(false); const selectedNode = React.useMemo( () => @@ -64,45 +62,6 @@ export function SidePanel() { setOpen(selectedNodeId !== null); }, [selectedNodeId, setOpen]); - const copyTimeoutRef = React.useRef | null>(null); - React.useEffect(() => { - return () => { - if (copyTimeoutRef.current) { - clearTimeout(copyTimeoutRef.current); - } - }; - }, []); - - const handleCopyMermaid = async () => { - if (!model) return; - try { - const mermaidCode = exportToMermaid(model); - await copyMermaidToClipboard(mermaidCode); - setIsCopied(true); - - if (copyTimeoutRef.current) { - clearTimeout(copyTimeoutRef.current); - } - - copyTimeoutRef.current = setTimeout(() => { - setIsCopied(false); - copyTimeoutRef.current = null; - }, 2000); - } catch (error) { - console.error("Failed to copy mermaid code:", error); - } - }; - - const handleDownloadMermaid = () => { - if (!model) return; - try { - const mermaidCode = exportToMermaid(model); - downloadMermaidFile(mermaidCode); - } catch (error) { - console.error("Failed to download mermaid file:", error); - } - }; - return ( @@ -141,18 +100,7 @@ export function SidePanel() { )} - {model !== null && selectedNodeId === null && ( - <> - - - - )} + {model !== null && selectedNodeId === null && } ); diff --git a/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts b/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts new file mode 100644 index 0000000..8f25f1f --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { copyToClipboard } from "../../src/lib/clipboard"; +import { describe, it, expect, vi } from "vitest"; + +describe("copyToClipboard", () => { + it("copies text to clipboard successfully", async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + }); + + const testCode = "flowchart TD\n A --> B"; + await copyToClipboard(testCode); + expect(mockWriteText).toHaveBeenCalledWith(testCode); + }); + + it("rejects if clipboard API is not available", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + writable: true, + }); + + await expect(copyToClipboard("test")).rejects.toThrow( + "Clipboard API is not available in this environment", + ); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts b/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts new file mode 100644 index 0000000..0dd57b3 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { downloadFile } from "../../src/lib/download"; +import { describe, it, expect, vi } from "vitest"; + +describe("downloadFile", () => { + it("creates and triggers file download", () => { + const mockClick = vi.fn(); + const mockAppendChild = vi.fn(); + const mockRemoveChild = vi.fn(); + const mockCreateElement = vi.fn().mockReturnValue({ + click: mockClick, + href: "", + download: "", + }); + + globalThis.document.createElement = mockCreateElement; + globalThis.document.body.appendChild = mockAppendChild; + globalThis.document.body.removeChild = mockRemoveChild; + globalThis.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock-url"); + globalThis.URL.revokeObjectURL = vi.fn(); + + const testCode = "flowchart TD\n A --> B"; + downloadFile(testCode, "test.mmd"); + + expect(mockCreateElement).toHaveBeenCalledWith("a"); + expect(mockClick).toHaveBeenCalled(); + expect(mockAppendChild).toHaveBeenCalled(); + expect(mockRemoveChild).toHaveBeenCalled(); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx index b554e82..0a94fe3 100644 --- a/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx @@ -22,6 +22,8 @@ import { parseWorkflow } from "../../src/core/workflowSdk"; import { renderWithProviders } from "../test-utils/render-helpers"; import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows"; import * as mermaidExport from "../../src/core/mermaidExport"; +import * as clipboard from "../../src/lib/clipboard"; +import * as download from "../../src/lib/download"; describe("SidePanel", () => { it("renders sidebar with workflow info when model is present", () => { @@ -77,7 +79,7 @@ describe("SidePanel", () => { it("should call copyMermaidToClipboard when copy button is clicked", async () => { const user = userEvent.setup(); const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); - const copySpy = vi.spyOn(mermaidExport, "copyMermaidToClipboard").mockResolvedValue(undefined); + const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); vi.spyOn(mermaidExport, "exportToMermaid").mockReturnValue("mermaid code"); renderWithProviders(, { model }); @@ -90,14 +92,14 @@ describe("SidePanel", () => { it("should call downloadMermaidFile when download button is clicked", async () => { const user = userEvent.setup(); const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); - const downloadSpy = vi.spyOn(mermaidExport, "downloadMermaidFile").mockImplementation(() => {}); + const downloadSpy = vi.spyOn(download, "downloadFile").mockImplementation(() => {}); vi.spyOn(mermaidExport, "exportToMermaid").mockReturnValue("mermaid code"); renderWithProviders(, { model }); const downloadButton = screen.getByText(/Download as Mermaid File/i); await user.click(downloadButton); - expect(downloadSpy).toHaveBeenCalledWith("mermaid code"); + expect(downloadSpy).toHaveBeenCalledWith("mermaid code", "test-wf.mmd"); }); afterEach(() => { From 2e759d6029fe509485f3940bbdce7b94ed13933f Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 15 Jun 2026 13:24:55 +0100 Subject: [PATCH 8/8] fix copilot complaints Signed-off-by: Cheryl Kong --- .../src/i18n/locales/en.ts | 2 + .../src/side-panel/MermaidActions.tsx | 11 +++- .../tests/lib/clipboard.test.ts | 14 ++++- .../tests/lib/download.test.ts | 24 +++++--- .../tests/side-panel/MermaidActions.test.tsx | 58 +++++++++++++++++++ .../tests/side-panel/SidePanel.test.tsx | 36 +----------- 6 files changed, 97 insertions(+), 48 deletions(-) create mode 100644 packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx diff --git a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts index 7118ef8..a8ee394 100644 --- a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts @@ -39,6 +39,8 @@ export const en = { "sidebar.exportMermaid.copy": "Copy Mermaid Code", "sidebar.exportMermaid.download": "Download as Mermaid File", "sidebar.exportMermaid.copied": "Copied!", + "sidebar.exportMermaid.copyError": "Failed to copy mermaid code. Please try again.", + "sidebar.exportMermaid.downloadError": "Failed to download mermaid file. Please try again.", } as const; export type TranslationKeys = keyof typeof en; diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx index d3968ea..32765a5 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx @@ -53,7 +53,7 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re }, 2000); } catch (error) { console.error("Failed to copy mermaid code:", error); - alert("Failed to copy mermaid code. Please try again."); + alert(t("sidebar.exportMermaid.copyError")); } }; @@ -61,11 +61,16 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re if (!model) return; try { const mermaidCode = exportToMermaid(model); - const filename = `${model.document?.name || "workflow"}.mmd`; + const sanitizedName = (model.document?.name || "workflow") + .replace(/[/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .trim() + .substring(0, 200); + const filename = `${sanitizedName}.mmd`; downloadFile(mermaidCode, filename); } catch (error) { console.error("Failed to download mermaid file:", error); - alert("Failed to download mermaid file. Please try again."); + alert(t("sidebar.exportMermaid.downloadError")); } }; diff --git a/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts b/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts index 8f25f1f..01a97ac 100644 --- a/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts @@ -15,14 +15,25 @@ */ import { copyToClipboard } from "../../src/lib/clipboard"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; describe("copyToClipboard", () => { + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + + afterEach(() => { + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + delete (navigator as unknown as { clipboard?: Clipboard }).clipboard; + } + }); + it("copies text to clipboard successfully", async () => { const mockWriteText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { value: { writeText: mockWriteText }, writable: true, + configurable: true, }); const testCode = "flowchart TD\n A --> B"; @@ -34,6 +45,7 @@ describe("copyToClipboard", () => { Object.defineProperty(navigator, "clipboard", { value: undefined, writable: true, + configurable: true, }); await expect(copyToClipboard("test")).rejects.toThrow( diff --git a/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts b/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts index 0dd57b3..a152340 100644 --- a/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts @@ -15,29 +15,35 @@ */ import { downloadFile } from "../../src/lib/download"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; describe("downloadFile", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("creates and triggers file download", () => { const mockClick = vi.fn(); const mockAppendChild = vi.fn(); const mockRemoveChild = vi.fn(); - const mockCreateElement = vi.fn().mockReturnValue({ + const mockElement = { click: mockClick, href: "", download: "", - }); + }; - globalThis.document.createElement = mockCreateElement; - globalThis.document.body.appendChild = mockAppendChild; - globalThis.document.body.removeChild = mockRemoveChild; - globalThis.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock-url"); - globalThis.URL.revokeObjectURL = vi.fn(); + vi.spyOn(document, "createElement").mockReturnValue( + mockElement as unknown as HTMLAnchorElement, + ); + vi.spyOn(document.body, "appendChild").mockImplementation(mockAppendChild); + vi.spyOn(document.body, "removeChild").mockImplementation(mockRemoveChild); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(vi.fn()); const testCode = "flowchart TD\n A --> B"; downloadFile(testCode, "test.mmd"); - expect(mockCreateElement).toHaveBeenCalledWith("a"); + expect(document.createElement).toHaveBeenCalledWith("a"); expect(mockClick).toHaveBeenCalled(); expect(mockAppendChild).toHaveBeenCalled(); expect(mockRemoveChild).toHaveBeenCalled(); diff --git a/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx new file mode 100644 index 0000000..fbd5b0c --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MermaidActions } from "../../src/side-panel/MermaidActions"; +import { parseWorkflow } from "../../src/core/workflowSdk"; +import { renderWithProviders } from "../test-utils/render-helpers"; +import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows"; +import * as clipboard from "../../src/lib/clipboard"; +import * as core from "../../src/core"; +import * as download from "../../src/lib/download"; + +describe("MermaidActions", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should call copyMermaidToClipboard when copy button is clicked", async () => { + const user = userEvent.setup(); + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); + vi.spyOn(core, "exportToMermaid").mockReturnValue("mermaid code"); + + renderWithProviders(, { model }); + const copyButton = screen.getByText(/Copy Mermaid Code/i); + await user.click(copyButton); + + expect(copySpy).toHaveBeenCalledWith("mermaid code"); + }); + + it("should call downloadMermaidFile when download button is clicked", async () => { + const user = userEvent.setup(); + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + const downloadSpy = vi.spyOn(download, "downloadFile").mockImplementation(() => {}); + vi.spyOn(core, "exportToMermaid").mockReturnValue("mermaid code"); + + renderWithProviders(, { model }); + const downloadButton = screen.getByText(/Download as Mermaid File/i); + await user.click(downloadButton); + + expect(downloadSpy).toHaveBeenCalledWith("mermaid code", "test-wf.mmd"); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx index 0a94fe3..6bfdf74 100644 --- a/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/SidePanel.test.tsx @@ -14,16 +14,12 @@ * limitations under the License. */ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { SidePanel } from "../../src/side-panel/SidePanel"; import { parseWorkflow } from "../../src/core/workflowSdk"; import { renderWithProviders } from "../test-utils/render-helpers"; import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows"; -import * as mermaidExport from "../../src/core/mermaidExport"; -import * as clipboard from "../../src/lib/clipboard"; -import * as download from "../../src/lib/download"; describe("SidePanel", () => { it("renders sidebar with workflow info when model is present", () => { @@ -75,34 +71,4 @@ describe("SidePanel", () => { expect(screen.queryByText(/Copy Mermaid Code/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Download as Mermaid File/i)).not.toBeInTheDocument(); }); - - it("should call copyMermaidToClipboard when copy button is clicked", async () => { - const user = userEvent.setup(); - const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); - const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); - vi.spyOn(mermaidExport, "exportToMermaid").mockReturnValue("mermaid code"); - - renderWithProviders(, { model }); - const copyButton = screen.getByText(/Copy Mermaid Code/i); - await user.click(copyButton); - - expect(copySpy).toHaveBeenCalledWith("mermaid code"); - }); - - it("should call downloadMermaidFile when download button is clicked", async () => { - const user = userEvent.setup(); - const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); - const downloadSpy = vi.spyOn(download, "downloadFile").mockImplementation(() => {}); - vi.spyOn(mermaidExport, "exportToMermaid").mockReturnValue("mermaid code"); - - renderWithProviders(, { model }); - const downloadButton = screen.getByText(/Download as Mermaid File/i); - await user.click(downloadButton); - - expect(downloadSpy).toHaveBeenCalledWith("mermaid code", "test-wf.mmd"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); });