Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-only-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

Enable read-only mode locking nodes and edges on the canvas.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
handreyrc marked this conversation as resolved.
}

/* custom nodes */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -126,7 +126,11 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
}, [model, reactFlowInstance, setNodes, setEdges]);

return (
<div ref={divRef} className="dec:h-full dec:relative" data-testid={"diagram-container"}>
<div
ref={divRef}
className={isReadOnly ? "dec:h-full dec:relative read-only" : "dec:h-full dec:relative"}
data-testid={"diagram-container"}
>
Comment thread
handreyrc marked this conversation as resolved.
Comment thread
handreyrc marked this conversation as resolved.
<RF.ReactFlow
nodeTypes={ReactFlowNodeTypes}
nodes={nodes}
Expand All @@ -151,6 +155,8 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
},
}}
data-testid={"react-flow-canvas"}
nodesDraggable={!isReadOnly}
nodesConnectable={!isReadOnly}
Comment thread
handreyrc marked this conversation as resolved.
>
Comment thread
handreyrc marked this conversation as resolved.
{minimapVisible && <RF.MiniMap pannable zoomable position={"top-right"} />}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-testid="react-flow-canvas" />;
}),
};
});
Comment thread
handreyrc marked this conversation as resolved.

/**
* 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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering is it worth moving this to common render-helpers file, there is some crossover with providers there taht could be used or you think thats overkill and should just stay here, wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it may be a bit overkill. I would be more inclined to move it to render-helpers if something more complex was happening there. At the end it is just a render call.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, yes, we can always move it later if we need to use it anywhere else, thanks

isReadOnly = true,
content = "",
locale = "en",
}: {
isReadOnly?: boolean;
content?: string;
locale?: string;
} = {}) {
return render(
<ReactFlowProvider>
<DiagramEditorContextProvider content={content} isReadOnly={isReadOnly} locale={locale}>
<I18nProvider locale="en" dictionaries={{ en }}>
Comment thread
handreyrc marked this conversation as resolved.
<SidebarProvider>
<Diagram />
</SidebarProvider>
</I18nProvider>
</DiagramEditorContextProvider>
</ReactFlowProvider>,
);
}
Comment thread
handreyrc marked this conversation as resolved.

describe("Diagram Component", () => {
let applyAutoLayoutSpy: ReturnType<typeof vi.spyOn>;

Expand All @@ -33,24 +73,17 @@ describe("Diagram Component", () => {
nodes: [],
edges: [],
});

// Clear mock calls before each test
vi.mocked(ReactFlow).mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it("render Diagram component and canvas", async () => {
render(
<ReactFlowProvider>
<DiagramEditorContextProvider content={""} isReadOnly={true} locale={"en"}>
<I18nProvider locale="en" dictionaries={{ en }}>
<SidebarProvider>
<Diagram />
</SidebarProvider>
</I18nProvider>
</DiagramEditorContextProvider>
</ReactFlowProvider>,
);
renderDiagram({ isReadOnly: true });

const diagram = screen.getByTestId("diagram-container");
const canvas = screen.getByTestId("react-flow-canvas");
Expand All @@ -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();
Comment thread
handreyrc marked this conversation as resolved.

// 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();
});
});
});
Loading