Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/mermaid-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

Add mermaid export functionality
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * from "./graph";
export * from "./taskDetails";
export * from "./taskSubType";
export * from "./elkjs";
export * from "./mermaidExport";
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export const en = {
"sidebar.noDetails": "No additional details for this node",
"node.entry": "Entry",
"node.exit": "Exit",
"sidebar.exportMermaid.copy": "Copy Mermaid Code",
"sidebar.exportMermaid.download": "Download as Mermaid File",
"sidebar.exportMermaid.copied": "Copied!",
} as const;

export type TranslationKeys = keyof typeof en;
22 changes: 22 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (typeof navigator === "undefined" || !navigator.clipboard) {
return Promise.reject(new Error("Clipboard API is not available in this environment"));
}
return navigator.clipboard.writeText(text);
}
33 changes: 33 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/lib/download.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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<ReturnType<typeof setTimeout> | null>(null);
Comment thread
cheryl7114 marked this conversation as resolved.
Comment thread
cheryl7114 marked this conversation as resolved.

React.useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);

const handleCopyMermaid = async () => {
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);
Comment thread
cheryl7114 marked this conversation as resolved.
Comment thread
cheryl7114 marked this conversation as resolved.
} catch (error) {
console.error("Failed to copy mermaid code:", error);
// TODO: Create component to show errors to users
}
Comment thread
cheryl7114 marked this conversation as resolved.
Comment thread
cheryl7114 marked this conversation as resolved.
};

const handleDownloadMermaid = () => {
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);
Comment thread
cheryl7114 marked this conversation as resolved.
Comment thread
cheryl7114 marked this conversation as resolved.
} catch (error) {
console.error("Failed to download mermaid file:", error);
// TODO: Create component to show errors to users
}
Comment thread
cheryl7114 marked this conversation as resolved.
};

return (
<>
<Button onClick={handleCopyMermaid} variant="outline" size="sm">
{isCopied ? <ClipboardCheck /> : <ClipboardPen />}
{isCopied ? t("sidebar.exportMermaid.copied") : t("sidebar.exportMermaid.copy")}
</Button>
<Button onClick={handleDownloadMermaid} variant="outline" size="sm">
<Download />
{t("sidebar.exportMermaid.download")}
</Button>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export function NodeDetailsView({ node }: NodeDetailsViewProps) {
<>
<div className="dec-sidebar-section-spacer" />
<SectionHeader label={t("sidebar.sectionSource")} />
<YamlField yaml={yaml.dump(task, { indent: 2, lineWidth: -1 })} summary={t("sidebar.viewSource")} />
<YamlField
yaml={yaml.dump(task, { indent: 2, lineWidth: -1 })}
summary={t("sidebar.viewSource")}
/>
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,6 +97,9 @@ export function SidePanel() {
</>
)}
</SidebarContent>
<SidebarFooter>
{model !== null && selectedNodeId === null && <MermaidActions model={model} />}
</SidebarFooter>
</Sidebar>
);
}
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment thread
cheryl7114 marked this conversation as resolved.
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);
});
});
Original file line number Diff line number Diff line change
@@ -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,
});
Comment thread
cheryl7114 marked this conversation as resolved.

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",
);
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,9 @@ describe("taskNodeConfig", () => {
expect(getNodeVisualConfig("not-a-node-type")).toBeUndefined();
});

it.each(terminalNodeTypes)(
"returns undefined for terminal node type %s",
(terminal) => {
expect(getNodeVisualConfig(terminal)).toBeUndefined();
},
);
it.each(terminalNodeTypes)("returns undefined for terminal node type %s", (terminal) => {
expect(getNodeVisualConfig(terminal)).toBeUndefined();
});

it("returns undefined when type is undefined", () => {
expect(getNodeVisualConfig(undefined)).toBeUndefined();
Expand Down
Loading