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 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..157c8f9 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/core/mermaidExport.ts @@ -0,0 +1,27 @@ +/* + * 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); +} 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..3f7125a 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,11 @@ export const en = { "sidebar.sectionSource": "Source", "sidebar.viewSource": "View source", "sidebar.noDetails": "No additional details for this node", + "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/lib/clipboard.ts b/packages/serverless-workflow-diagram-editor/src/lib/clipboard.ts new file mode 100644 index 0000000..4b753db --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/lib/clipboard.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export function copyToClipboard(text: 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(text); +} diff --git a/packages/serverless-workflow-diagram-editor/src/lib/download.ts b/packages/serverless-workflow-diagram-editor/src/lib/download.ts new file mode 100644 index 0000000..0daaa87 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/lib/download.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. + */ + +export function downloadFile(content: string, filename: string, mimeType = "text/plain"): void { + if (typeof document === "undefined") { + throw new Error("Document API is not available in this environment"); + } + + const blob = new Blob([content], { type: mimeType }); + 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); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 100); +} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx new file mode 100644 index 0000000..32765a5 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx @@ -0,0 +1,89 @@ +/* + * 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 * as React from "react"; +import { useI18n } from "@serverlessworkflow/i18n"; +import { ClipboardPen, Download, ClipboardCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { exportToMermaid } from "@/core"; +import { copyToClipboard } from "@/lib/clipboard"; +import { downloadFile } from "@/lib/download"; +import type { Specification } from "@serverlessworkflow/sdk"; + +export function MermaidActions({ model }: { model: Specification.Workflow }): React.JSX.Element { + const { t } = useI18n(); + const [isCopied, setIsCopied] = React.useState(false); + 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 copyToClipboard(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); + alert(t("sidebar.exportMermaid.copyError")); + } + }; + + const handleDownloadMermaid = () => { + if (!model) return; + try { + const mermaidCode = exportToMermaid(model); + 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(t("sidebar.exportMermaid.downloadError")); + } + }; + + return ( + <> + + + + ); +} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx index 2c1ce88..9d073c3 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx @@ -69,7 +69,10 @@ export function NodeDetailsView({ node }: NodeDetailsViewProps) { <>
- + )}
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 14a451f..b8b6ae4 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -18,10 +18,17 @@ 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 { + Sidebar, + SidebarContent, + SidebarHeader, + useSidebar, + SidebarFooter, +} from "@/components/ui/sidebar"; import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; +import { MermaidActions } from "@/side-panel/MermaidActions"; import { getNodeVisualConfig } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; @@ -90,6 +97,9 @@ export function SidePanel() { )} + + {model !== null && selectedNodeId === null && } + ); } 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/lib/clipboard.test.ts b/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts new file mode 100644 index 0000000..01a97ac --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/lib/clipboard.test.ts @@ -0,0 +1,55 @@ +/* + * 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, 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"; + await copyToClipboard(testCode); + expect(mockWriteText).toHaveBeenCalledWith(testCode); + }); + + it("rejects if clipboard API is not available", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + writable: true, + configurable: 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..a152340 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/lib/download.test.ts @@ -0,0 +1,51 @@ +/* + * 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, 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 mockElement = { + click: mockClick, + href: "", + download: "", + }; + + 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(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/NodeDetailsView.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx index 72f6177..af847ec 100644 --- a/packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx @@ -82,7 +82,8 @@ describe("NodeDetailsView", () => { expect(screen.getByRole("heading", { name: "Source" })).toBeInTheDocument(); expect(container.querySelector(".dec-sidebar-yaml-summary")?.textContent).toBe("View source"); expect(container.querySelector(".dec-sidebar-yaml-pre")?.textContent).toBe( - 'call: http\nwith:\n endpoint: https://api.example.com\n') + "call: http\nwith:\n endpoint: https://api.example.com\n", + ); }); it("renders node details message when the task has no task", () => { 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(); + }); });