diff --git a/.changeset/read-only-mode.md b/.changeset/read-only-mode.md new file mode 100644 index 00000000..e1c48b20 --- /dev/null +++ b/.changeset/read-only-mode.md @@ -0,0 +1,5 @@ +--- +"@serverlessworkflow/diagram-editor": minor +--- + +Enable read-only mode locking nodes and edges on the canvas. diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css index 04188fdf..242c2cd2 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css @@ -56,6 +56,15 @@ .dec-root .react-flow__pane:active { cursor: grabbing !important; } + + /* hide handles in read-only mode */ + .dec-root .read-only .react-flow__handle { + visibility: hidden !important; + width: 0 !important; + height: 0 !important; + min-width: 0 !important; + min-height: 0 !important; + } } /* custom nodes */ diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index 6cb41c3b..9c9df833 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -47,7 +47,7 @@ export type DiagramProps = { export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow(); - const { model, nodes, edges, setNodes, setEdges } = useDiagramEditorContext(); + const { model, nodes, edges, isReadOnly, setNodes, setEdges } = useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); @@ -126,7 +126,11 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { }, [model, reactFlowInstance, setNodes, setEdges]); return ( -
+
{ }, }} data-testid={"react-flow-canvas"} + nodesDraggable={!isReadOnly} + nodesConnectable={!isReadOnly} > {minimapVisible && } diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx index bf1db18a..c93c7528 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx @@ -21,9 +21,49 @@ import { DiagramEditorContextProvider } from "../../../src/store/DiagramEditorCo import { SidebarProvider } from "../../../src/components/ui/sidebar"; import { I18nProvider } from "@serverlessworkflow/i18n"; import { en } from "../../../src/i18n/locales/en"; -import { ReactFlowProvider } from "@xyflow/react"; +import { ReactFlowProvider, ReactFlow } from "@xyflow/react"; import * as autoLayoutModule from "../../../src/react-flow/diagram/autoLayout"; +// Mock ReactFlow to capture props +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + ReactFlow: vi.fn((props) => { + return
; + }), + }; +}); + +/** + * Helper function to render the Diagram component with all required providers + * @param options - Configuration options for the diagram + * @param options.isReadOnly - Whether the diagram should be in read-only mode + * @param options.content - The workflow content to render + * @param options.locale - The locale to use for i18n + */ +function renderDiagram({ + isReadOnly = true, + content = "", + locale = "en", +}: { + isReadOnly?: boolean; + content?: string; + locale?: string; +} = {}) { + return render( + + + + + + + + + , + ); +} + describe("Diagram Component", () => { let applyAutoLayoutSpy: ReturnType; @@ -33,6 +73,9 @@ describe("Diagram Component", () => { nodes: [], edges: [], }); + + // Clear mock calls before each test + vi.mocked(ReactFlow).mockClear(); }); afterEach(() => { @@ -40,17 +83,7 @@ describe("Diagram Component", () => { }); it("render Diagram component and canvas", async () => { - render( - - - - - - - - - , - ); + renderDiagram({ isReadOnly: true }); const diagram = screen.getByTestId("diagram-container"); const canvas = screen.getByTestId("react-flow-canvas"); @@ -63,4 +96,89 @@ describe("Diagram Component", () => { expect(applyAutoLayoutSpy).toHaveBeenCalled(); }); }); + + it("should apply read-only class when isReadOnly is true", async () => { + renderDiagram({ isReadOnly: true }); + + const diagram = screen.getByTestId("diagram-container"); + + // Verify that the read-only class is applied + expect(diagram).toHaveClass("read-only"); + + await waitFor(() => { + expect(applyAutoLayoutSpy).toHaveBeenCalled(); + }); + }); + + it("should not apply read-only class when isReadOnly is false", async () => { + renderDiagram({ isReadOnly: false }); + + const diagram = screen.getByTestId("diagram-container"); + + // Verify that the read-only class is not applied + expect(diagram).not.toHaveClass("read-only"); + + await waitFor(() => { + expect(applyAutoLayoutSpy).toHaveBeenCalled(); + }); + }); + + it("should disable node interaction when isReadOnly is true", async () => { + renderDiagram({ isReadOnly: true }); + + const diagram = screen.getByTestId("diagram-container"); + + // Verify that the read-only class is applied + // This class applies CSS rule: .read-only .react-flow__handle { visibility: hidden !important; } + expect(diagram).toHaveClass("read-only"); + + // Verify ReactFlow canvas is rendered + const canvas = screen.getByTestId("react-flow-canvas"); + expect(canvas).toBeInTheDocument(); + + // Wait for ReactFlow to be called + await waitFor(() => { + expect(ReactFlow).toHaveBeenCalled(); + }); + + // Verify that ReactFlow was called with nodesDraggable={false} and nodesConnectable={false} + const mockReactFlow = vi.mocked(ReactFlow); + const lastCall = mockReactFlow.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const reactFlowProps = lastCall![0]; + expect(reactFlowProps.nodesDraggable).toBe(false); + expect(reactFlowProps.nodesConnectable).toBe(false); + + await waitFor(() => { + expect(applyAutoLayoutSpy).toHaveBeenCalled(); + }); + }); + + it("should enable node interaction when isReadOnly is false", async () => { + renderDiagram({ isReadOnly: false }); + + const diagram = screen.getByTestId("diagram-container"); + + // Verify that the read-only class is not applied + expect(diagram).not.toHaveClass("read-only"); + + // Verify ReactFlow canvas is rendered + const canvas = screen.getByTestId("react-flow-canvas"); + expect(canvas).toBeInTheDocument(); + + // Wait for ReactFlow to be called + await waitFor(() => { + expect(ReactFlow).toHaveBeenCalled(); + }); + + // Verify that ReactFlow was called with nodesDraggable={true} and nodesConnectable={true} + const mockReactFlow = vi.mocked(ReactFlow); + const reactFlowProps = mockReactFlow.mock.calls[mockReactFlow.mock.calls.length - 1][0]; + expect(reactFlowProps.nodesDraggable).toBe(true); + expect(reactFlowProps.nodesConnectable).toBe(true); + + await waitFor(() => { + expect(applyAutoLayoutSpy).toHaveBeenCalled(); + }); + }); });