diff --git a/.changeset/png-export.md b/.changeset/png-export.md new file mode 100644 index 00000000..8d331425 --- /dev/null +++ b/.changeset/png-export.md @@ -0,0 +1,5 @@ +--- +"@openworkflowspec/diagram-editor": minor +--- + +add png export button for react flow diagram diff --git a/packages/open-workflow-diagram-editor/package.json b/packages/open-workflow-diagram-editor/package.json index f78d9e7b..3e5285e5 100644 --- a/packages/open-workflow-diagram-editor/package.json +++ b/packages/open-workflow-diagram-editor/package.json @@ -49,6 +49,7 @@ "clsx": "catalog:", "elkjs": "catalog:", "fast-equals": "catalog:", + "html-to-image": "catalog:", "js-yaml": "catalog:", "radix-ui": "catalog:", "sonner": "catalog:", diff --git a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts index 55298bd5..4dbe7c83 100644 --- a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts @@ -43,6 +43,7 @@ export const en = { "sidebar.exportMermaid.copy": "Copy Mermaid Code", "sidebar.exportMermaid.download": "Download as Mermaid File", "sidebar.exportMermaid.copied": "Copied!", + "sidebar.exportPng.download": "Download as PNG", "aria.minimap.hide": "Hide minimap", "aria.minimap.show": "Show minimap", "aria.badge": "Badge:", diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts new file mode 100644 index 00000000..db869c8d --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2021-Present The Open 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 { toPng } from "html-to-image"; +import type { ReactFlowInstance } from "@xyflow/react"; + +const PADDING = 40; +const SCALE = 3; + +export async function exportDiagramAsPng( + reactFlowInstance: ReactFlowInstance, + filename: string, + container?: HTMLElement | null, +): Promise { + if (typeof document === "undefined") { + throw new Error("Document API is not available in this environment"); + } + + const root = container ?? document; + const viewport = root.querySelector(".react-flow__viewport"); + if (!viewport) { + throw new Error("React Flow viewport element not found"); + } + + const nodes = reactFlowInstance.getNodes(); + if (nodes.length === 0) { + throw new Error("No nodes to export"); + } + + const { x: minX, y: minY, width, height } = reactFlowInstance.getNodesBounds(nodes); + const contentWidth = width + PADDING * 2; + const contentHeight = height + PADDING * 2; + + // Edge colours are defined via CSS custom properties and Tailwind classes on + // ancestor elements. When html-to-image serialises the SVG, those rules are + // no longer in scope and stroke colours are lost. Fix: read the browser's + // fully-resolved computed stroke from each live element and set it as an + // inline style so the value is self-contained in the serialised output. + viewport.querySelectorAll(".edge-line").forEach((el) => { + el.style.stroke = getComputedStyle(el).stroke; + }); + + const backgroundColor = + getComputedStyle(viewport).getPropertyValue("--dec-canvas-bg").trim() || "#ffffff"; + + let dataUrl: string; + try { + dataUrl = await toPng(viewport, { + backgroundColor, + width: contentWidth, + height: contentHeight, + pixelRatio: SCALE, + style: { + transform: `translate(${-minX + PADDING}px, ${-minY + PADDING}px)`, + width: `${contentWidth}px`, + height: `${contentHeight}px`, + }, + filter: (node) => { + if (node instanceof HTMLLinkElement && node.rel === "stylesheet") { + return new URL(node.href, document.baseURI).origin === globalThis.location?.origin; + } + return true; + }, + }); + } finally { + // Clear inline strokes by re-querying the live DOM. Refs captured before + // toPng are stale — React Flow may have replaced SVG elements during the + // await when isExporting triggered a re-render. + viewport.querySelectorAll(".edge-line").forEach((el) => { + el.style.stroke = ""; + }); + } + + const link = document.createElement("a"); + link.download = filename; + link.href = dataUrl; + link.click(); +} diff --git a/packages/open-workflow-diagram-editor/src/lib/utils.ts b/packages/open-workflow-diagram-editor/src/lib/utils.ts index 534612bc..d1e08da0 100644 --- a/packages/open-workflow-diagram-editor/src/lib/utils.ts +++ b/packages/open-workflow-diagram-editor/src/lib/utils.ts @@ -19,3 +19,11 @@ import { type ClassValue, clsx } from "clsx"; export function cn(...inputs: ClassValue[]) { return clsx(inputs); } + +export function sanitizeFilename(name: string | undefined): string { + return (name || "workflow") + .replace(/[/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .trim() + .substring(0, 200); +} diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css index 43dfd5e1..e8afa379 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css @@ -450,7 +450,7 @@ } .dec-root .edge-line.condition { - @apply dec:stroke-blue-500; + stroke: var(--dec-edge-selected-condition); } /* Override React Flow's default selected edge styling to preserve original colors */ diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index 4d5bcbca..f45fc687 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -63,6 +63,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { submitModel, pendingViewportRestore, clearPendingViewportRestore, + isExporting, } = useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); @@ -223,7 +224,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onSelectionChange={onSelectionChange} - onlyRenderVisibleElements={true} + onlyRenderVisibleElements={!isExporting} zoomOnDoubleClick={false} elementsSelectable={true} panOnScroll={true} diff --git a/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx index a353f919..bf28cc3c 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -28,7 +28,7 @@ import { import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; -import { MermaidActions } from "@/side-panel/MermaidActions"; +import { WorkflowActions } from "@/side-panel/WorkflowActions"; import { getNodeVisualConfig } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; @@ -104,7 +104,7 @@ export function SidePanel() { {model !== null && selectedNodeId === null ? ( - + ) : null} diff --git a/packages/open-workflow-diagram-editor/src/side-panel/MermaidActions.tsx b/packages/open-workflow-diagram-editor/src/side-panel/WorkflowActions.tsx similarity index 65% rename from packages/open-workflow-diagram-editor/src/side-panel/MermaidActions.tsx rename to packages/open-workflow-diagram-editor/src/side-panel/WorkflowActions.tsx index 25a6153f..2f27a33d 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/MermaidActions.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/WorkflowActions.tsx @@ -16,18 +16,25 @@ import * as React from "react"; import { useI18n } from "@openworkflowspec/i18n"; -import { ClipboardPen, Download, ClipboardCheck } from "lucide-react"; +import { ClipboardPen, Download, ClipboardCheck, FileImage } from "lucide-react"; +import { useReactFlow, useStore } from "@xyflow/react"; import { Button } from "@/components/ui/button"; import { exportToMermaid } from "@/core"; import { copyToClipboard } from "@/lib/clipboard"; import { downloadFile } from "@/lib/download"; +import { exportDiagramAsPng } from "@/lib/exportPng"; +import { sanitizeFilename } from "@/lib/utils"; +import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import type { Specification } from "@openworkflowspec/sdk"; import { toast } from "sonner"; -export function MermaidActions({ model }: { model: Specification.Workflow }): React.JSX.Element { +export function WorkflowActions({ model }: { model: Specification.Workflow }): React.JSX.Element { const { t } = useI18n(); const [isCopied, setIsCopied] = React.useState(false); const copyTimeoutRef = React.useRef | null>(null); + const reactFlowInstance = useReactFlow(); + const diagramDomNode = useStore((s) => s.domNode); + const { isExporting, setIsExporting } = useDiagramEditorContext(); React.useEffect(() => { return () => { @@ -61,12 +68,7 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re 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`; + const filename = `${sanitizeFilename(model.document?.name)}.mmd`; downloadFile(mermaidCode, filename); toast.success(t("toast.download.success")); } catch (error) { @@ -76,6 +78,25 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re } }; + const handleExportPng = async () => { + try { + setIsExporting(true); + await new Promise((resolve) => setTimeout(resolve, 50)); + await exportDiagramAsPng( + reactFlowInstance, + `${sanitizeFilename(model.document?.name)}.png`, + diagramDomNode, + ); + toast.success(t("toast.download.success")); + } catch (error) { + toast.error(t("toast.download.error"), { + description: error instanceof Error ? error.message : undefined, + }); + } finally { + setIsExporting(false); + } + }; + return ( <> + ); } diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index dcf93340..045c34b0 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -29,11 +29,13 @@ export type DiagramEditorContextType = { edges: RF.Edge[]; taskReferences: Set; selectedNodeId: string | null; + isExporting: boolean; setLocale: React.Dispatch>; setNodes: React.Dispatch>; setEdges: React.Dispatch>; setSelectedNodeId: React.Dispatch>; + setIsExporting: React.Dispatch>; // Undo/redo — history API submitModel: ( diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index 845ca9b5..56bf2f2f 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -59,6 +59,7 @@ export const DiagramEditorContextProvider = React.forwardRef< const [nodes, setNodes] = React.useState([] as RF.Node[]); const [edges, setEdges] = React.useState([] as RF.Edge[]); const [selectedNodeId, setSelectedNodeId] = React.useState(null); + const [isExporting, setIsExporting] = React.useState(false); // Read isReadOnly directly from props — no local state copy. // This ensures useWorkflowHistory always receives the current value without @@ -191,6 +192,8 @@ export const DiagramEditorContextProvider = React.forwardRef< pendingViewportRestore, clearPendingViewportRestore, setContent, + isExporting, + setIsExporting, }), [ isReadOnly, @@ -214,6 +217,8 @@ export const DiagramEditorContextProvider = React.forwardRef< pendingViewportRestore, clearPendingViewportRestore, setContent, + isExporting, + setIsExporting, ], ); diff --git a/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml b/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml index 28e0cfcb..4b1b763c 100644 --- a/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml +++ b/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml @@ -22,7 +22,9 @@ do: - listenToGossips: listen: to: - any: [] + any: + - with: + type: com.fake-gossip-api.events.gossip.v1 until: "${ false }" foreach: item: event diff --git a/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts new file mode 100644 index 00000000..8680d98e --- /dev/null +++ b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2021-Present The Open 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 { exportDiagramAsPng } from "../../src/lib/exportPng"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { toPng } from "html-to-image"; +import type { ReactFlowInstance } from "@xyflow/react"; + +vi.mock("html-to-image", () => ({ + toPng: vi.fn().mockResolvedValue("data:image/png;base64,mock"), +})); + +function makeViewport(): void { + const viewport = document.createElement("div"); + viewport.className = "react-flow__viewport"; + document.body.appendChild(viewport); +} + +function makeInstance(nodes: object[]): ReactFlowInstance { + return { + getNodes: vi.fn().mockReturnValue(nodes), + getNodesBounds: vi.fn().mockReturnValue({ x: 0, y: 0, width: 100, height: 100 }), + } as unknown as ReactFlowInstance; +} + +describe("exportDiagramAsPng", () => { + let mockClick: ReturnType; + let mockLink: HTMLAnchorElement; + + beforeEach(() => { + makeViewport(); + + mockClick = vi.fn(); + mockLink = { click: mockClick, href: "", download: "" } as unknown as HTMLAnchorElement; + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + if (tag === "a") return mockLink; + return originalCreateElement(tag); + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + it("triggers a download with the given filename", async () => { + const instance = makeInstance([{ id: "1" }]); + await exportDiagramAsPng(instance, "diagram.png"); + + expect(mockLink.download).toBe("diagram.png"); + expect(mockLink.href).toBe("data:image/png;base64,mock"); + expect(mockClick).toHaveBeenCalledOnce(); + }); + + it("falls back to #ffffff when --dec-canvas-bg is not set", async () => { + const instance = makeInstance([{ id: "1" }]); + await exportDiagramAsPng(instance, "diagram.png"); + + expect(toPng).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ backgroundColor: "#ffffff" }), + ); + }); + + it("throws when there are no nodes", async () => { + const instance = makeInstance([]); + await expect(exportDiagramAsPng(instance, "diagram.png")).rejects.toThrow("No nodes to export"); + }); + + it("throws when the viewport element is not found", async () => { + document.body.innerHTML = ""; + const instance = makeInstance([{ id: "1" }]); + await expect(exportDiagramAsPng(instance, "diagram.png")).rejects.toThrow( + "React Flow viewport element not found", + ); + }); +}); diff --git a/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts b/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts new file mode 100644 index 00000000..153a33db --- /dev/null +++ b/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2021-Present The Open 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 { sanitizeFilename } from "../../src/lib/utils"; +import { describe, it, expect } from "vitest"; + +describe("sanitizeFilename", () => { + it("falls back to 'workflow' when name is empty or undefined", () => { + expect(sanitizeFilename(undefined)).toBe("workflow"); + expect(sanitizeFilename("")).toBe("workflow"); + }); + + it("replaces forbidden characters and whitespace with underscores", () => { + expect(sanitizeFilename('my workflow/name:with*bad?"<>|chars')).toBe( + "my_workflow_name_with_bad_____chars", + ); + }); + + it("truncates to 200 characters", () => { + expect(sanitizeFilename("a".repeat(250))).toHaveLength(200); + }); +}); diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx similarity index 61% rename from packages/open-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx rename to packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx index b65c7e1c..6eb63bd7 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx @@ -17,16 +17,17 @@ 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 { WorkflowActions } from "../../src/side-panel/WorkflowActions"; 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"; +import * as exportPng from "../../src/lib/exportPng"; import * as sonner from "sonner"; -describe("MermaidActions", () => { +describe("WorkflowActions", () => { const toastMock = vi.fn(); const MERMAID_CODE = "mermaid code"; @@ -41,7 +42,7 @@ describe("MermaidActions", () => { const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const copyButton = screen.getByRole("button", { name: /Copy Mermaid Code/i, @@ -60,7 +61,7 @@ describe("MermaidActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const copyButton = screen.getByRole("button", { name: /Copy Mermaid Code/i, @@ -79,7 +80,7 @@ describe("MermaidActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const downloadButton = screen.getByRole("button", { name: /Download as Mermaid File/i, @@ -101,7 +102,7 @@ describe("MermaidActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const downloadButton = screen.getByRole("button", { name: /Download as Mermaid File/i, @@ -111,4 +112,47 @@ describe("MermaidActions", () => { expect(toastMock).toHaveBeenCalledWith(expect.any(String), { description: "Download error" }); }); + + it("should disable the PNG button when isExporting is true", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + renderWithProviders(, { model, isExporting: true }); + + expect(screen.getByRole("button", { name: /Download as PNG/i })).toBeDisabled(); + }); + + it("should call exportDiagramAsPng with sanitized filename and show success toast", async () => { + const user = userEvent.setup(); + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + const exportSpy = vi.spyOn(exportPng, "exportDiagramAsPng").mockResolvedValue(undefined); + const setIsExporting = vi.fn(); + vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); + vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); + + renderWithProviders(, { model, setIsExporting }); + + await user.click(screen.getByRole("button", { name: /Download as PNG/i })); + await vi.waitFor(() => expect(exportSpy).toHaveBeenCalled()); + + expect(exportSpy).toHaveBeenCalledWith(expect.anything(), "test-wf.png", null); + expect(toastMock).toHaveBeenCalledWith(expect.any(String)); + expect(setIsExporting).toHaveBeenCalledWith(true); + expect(setIsExporting).toHaveBeenLastCalledWith(false); + }); + + it("should show error toast and still reset isExporting when PNG export fails", async () => { + const user = userEvent.setup(); + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + vi.spyOn(exportPng, "exportDiagramAsPng").mockRejectedValue(new Error("Export failed")); + const setIsExporting = vi.fn(); + vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); + vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); + + renderWithProviders(, { model, setIsExporting }); + + await user.click(screen.getByRole("button", { name: /Download as PNG/i })); + await vi.waitFor(() => expect(setIsExporting).toHaveBeenLastCalledWith(false)); + + expect(toastMock).toHaveBeenCalledWith(expect.any(String), { description: "Export failed" }); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index 3ab984dc..9d13e895 100644 --- a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import type * as React from "react"; import { render, type RenderOptions } from "@testing-library/react"; import { I18nProvider } from "@openworkflowspec/i18n"; import { @@ -21,6 +22,7 @@ import { type DiagramEditorContextType, } from "../../src/store/DiagramEditorContext"; import { SidebarProvider } from "../../src/components/ui/sidebar"; +import { ReactFlowProvider } from "@xyflow/react"; import { en } from "../../src/i18n/locales/en"; const noop = () => {}; @@ -42,12 +44,14 @@ export const createMockContextValue = ( edges: [], taskReferences: new Set(), selectedNodeId: null, + isExporting: false, // --- dispatch defaults --- setLocale: noop, setEdges: noop, setNodes: noop, setSelectedNodeId: noop, + setIsExporting: noop, setContent: noop, // --- history defaults --- @@ -74,11 +78,13 @@ export const renderWithProviders = ( const mockContext = createMockContextValue(contextValue); return render( - - - {ui} - - , + + + + {ui} + + + , renderOptions, ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a28b2a1..e44259df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ catalogs: fast-equals: specifier: ^6.0.2 version: 6.0.2 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 husky: specifier: ^9.1.7 version: 9.1.7 @@ -291,6 +294,9 @@ importers: fast-equals: specifier: 'catalog:' version: 6.0.2 + html-to-image: + specifier: 'catalog:' + version: 1.11.13 js-yaml: specifier: 'catalog:' version: 5.3.0 @@ -3075,6 +3081,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + human-id@4.2.1: resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} hasBin: true @@ -6410,6 +6419,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + human-id@4.2.1: {} husky@9.1.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06c4a81e..c4d6f74b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,6 +30,7 @@ catalog: clsx: ^2.1.1 elkjs: ^0.12.0 fast-equals: ^6.0.2 + html-to-image: ^1.11.13 husky: ^9.1.7 "js-yaml": ^5.3.0 jsdom: ^30.0.1