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
3 changes: 3 additions & 0 deletions packages/serverless-workflow-diagram-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
"start": "storybook dev -p 6006 --no-open",
"build:storybook": "pnpm clean:storybook && storybook build --output-dir ./dist-storybook"
},
"dependencies": {
"@xyflow/react": "catalog:"
},
"devDependencies": {
"@chromatic-com/storybook": "catalog:",
"@storybook/addon-a11y": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,45 @@
* limitations under the License.
*/

import type { CSSProperties } from "react";
import * as React from "react";
import { Diagram, DiagramRef } from "../react-flow/diagram/Diagram";

const clickmeBtnStyle: CSSProperties = {
border: "2px solid blue",
borderRadius: "10px",
fontSize: "large",
fontWeight: "500",
background: "blue",
color: "white",
/**
* DiagramEditor component API
*/
export type DiagramEditorRef = {
doSomething: () => void; // TODO: to be implemented, it is just a placeholder
};

export type DiagramEditorProps = {
content: string;
isReadOnly: boolean;
locale: string;
ref?: React.Ref<DiagramEditorRef>;
};

export const DiagramEditor = (props: DiagramEditorProps) => {
//TODO: Implement the actual component this is just a placeholder
export const DiagramEditor = ({ ref }: DiagramEditorProps) => {
// TODO: i18n
// TODO: store, context
// TODO: ErrorBoundary / fallback

// Refs
const diagramDivRef = React.useRef<HTMLDivElement | null>(null);
const diagramRef = React.useRef<DiagramRef | null>(null);

// Allow imperatively controlling the Editor
React.useImperativeHandle(
ref,
() => ({
doSomething: () => {
// TODO: to be implemented, it is just a placeholder
},
}),
[],
);

return (
<>
<h1>Hello from DiagramEditor component!</h1>
<p>Read-only: {props.isReadOnly ? "true" : "false"}</p>
<p>Content: {props.content}</p>
<button style={clickmeBtnStyle} onClick={() => alert("Hello from Diagram!")}>
Click me!
</button>
<Diagram ref={diagramRef} divRef={diagramDivRef} />
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
* limitations under the License.
*/

import { expect, describe, it } from "vitest";
.diagram-container {
height: 100%;
position: relative;
}

describe("MyComponent", () => {
it("Just a sample test", () => {
expect(true).toBeTruthy();
});
});
.diagram-background {
--xy-background-pattern-color: #ccc;
background-color: #E5E4E2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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 * as RF from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "./Diagram.css";

const FIT_VIEW_OPTIONS: RF.FitViewOptions = { maxZoom: 1, minZoom: 0.1, duration: 400 };

// TODO: Nodes and Edges are hardcoded for now to generate a renderable basic workflow
// It shall be replaced by the actual implementation based on graph structure
const initialNodes: RF.Node[] = [
{ id: "n1", position: { x: 100, y: 0 }, data: { label: "Node 1" } },
{ id: "n2", position: { x: 100, y: 100 }, data: { label: "Node 2" } },
{ id: "n3", position: { x: 0, y: 200 }, data: { label: "Node 3" } },
{ id: "n4", position: { x: 200, y: 200 }, data: { label: "Node 4" } },
{ id: "n5", position: { x: 100, y: 300 }, data: { label: "Node 5" } },
];
const initialEdges: RF.Edge[] = [
{ id: "n1-n2", source: "n1", target: "n2" },
{ id: "n2-n3", source: "n2", target: "n3" },
{ id: "n2-n4", source: "n2", target: "n4" },
{ id: "n3-n5", source: "n3", target: "n5" },
{ id: "n4-n5", source: "n4", target: "n5" },
];

/**
* Diagram component API
*/
export type DiagramRef = {
doSomething: () => void; // TODO: to be implemented, it is just a placeholder
};

export type DiagramProps = {
divRef?: React.RefObject<HTMLDivElement | null>;
ref?: React.Ref<DiagramRef>;
};

export const Diagram = ({ divRef, ref }: DiagramProps) => {
const [minimapVisible, setMinimapVisible] = React.useState(false);

const [nodes, setNodes] = React.useState<RF.Node[]>(initialNodes);
const [edges, setEdges] = React.useState<RF.Edge[]>(initialEdges);

const onNodesChange = React.useCallback<RF.OnNodesChange>(
(changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)),
[],
);
const onEdgesChange = React.useCallback<RF.OnEdgesChange>(
(changes) => setEdges((edgesSnapshot) => RF.applyEdgeChanges(changes, edgesSnapshot)),
[],
);

React.useImperativeHandle(
ref,
() => ({
doSomething: () => {
// TODO: to be implemented, it is just a placeholder
},
}),
[],
);

return (
<div ref={divRef} className={"diagram-container"} data-testid={"diagram-container"}>
<RF.ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onlyRenderVisibleElements={true}
zoomOnDoubleClick={false}
elementsSelectable={true}
panOnScroll={true}
zoomOnScroll={false}
preventScrolling={true}
selectionOnDrag={true}
fitView
>
{minimapVisible && <RF.MiniMap pannable zoomable position={"top-right"} />}

<RF.Controls
fitViewOptions={FIT_VIEW_OPTIONS}
position={"bottom-right"}
showInteractive={false}
>
<RF.ControlButton onClick={() => setMinimapVisible(!minimapVisible)}>M</RF.ControlButton>
</RF.Controls>
<RF.Background className="diagram-background" variant={RF.BackgroundVariant.Cross} />
</RF.ReactFlow>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ type Story = StoryObj<typeof meta>;
export const Component: Story = {
args: {
isReadOnly: true,
content: "Sample Content",
locale: "en",
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
*/

import {
DiagramEditor as SWDiagramEditor,
DiagramEditor as Component,
DiagramEditorProps,
} from "../src/diagram-editor/DiagramEditor";

/** Primary UI component for user interaction */
export const DiagramEditor = ({ ...props }: DiagramEditorProps) => {
return (
<>
<SWDiagramEditor content={props.content} isReadOnly={props.isReadOnly} />
</>
<div style={{ height: "100vh" }}>
<Component isReadOnly={props.isReadOnly} locale={props.locale} />
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,24 @@
import { render, screen } from "@testing-library/react";
import { composeStories } from "@storybook/react-vite";
import * as stories from "../../stories/DiagramEditor.stories";
import userEvent from "@testing-library/user-event";
import { vi, test, expect, afterEach, describe } from "vitest";

// Composes all stories in the file
const { Component } = composeStories(stories);

describe("DiagramEditor component story", () => {
describe("Story - DiagramEditor component", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("Render DiagramEditor Component from story", async () => {
const content = "Sample Content";
test("Renders react flow Diagram component", async () => {
const locale = "en";
const isReadOnly = true;
const alertMock = vi.spyOn(window, "alert").mockImplementation(() => {});

render(<Component content={content} isReadOnly={isReadOnly} />);
render(<Component locale={locale} isReadOnly={isReadOnly} />);

const user = userEvent.setup();
const button = screen.getByRole("button", { name: /Click me!/i });
const reactFlowContainer = screen.getByTestId("diagram-container");

await user.click(button);

expect(alertMock).toHaveBeenCalledWith("Hello from Diagram!");
expect(reactFlowContainer).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,21 @@

import { render, screen } from "@testing-library/react";
import { DiagramEditor } from "../../src/diagram-editor";
import userEvent from "@testing-library/user-event";
import { vi, test, expect, afterEach, describe } from "vitest";

describe("DiagramEditor Component", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("Render DiagramEditor Component", async () => {
const content = "Sample Content";
test("Renders react flow Diagram component", async () => {
const locale = "en";
const isReadOnly = true;
const alertMock = vi.spyOn(window, "alert").mockImplementation(() => {});

render(<DiagramEditor content={content} isReadOnly={isReadOnly} />);
render(<DiagramEditor locale={locale} isReadOnly={isReadOnly} />);

const user = userEvent.setup();
const button = screen.getByRole("button", { name: /Click me!/i });
const reactFlowContainer = screen.getByTestId("diagram-container");

await user.click(button);

expect(alertMock).toHaveBeenCalledWith("Hello from Diagram!");
expect(reactFlowContainer).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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 { render, screen } from "@testing-library/react";
import { Diagram } from "../../../src/react-flow/diagram/Diagram";
import { vi, test, expect, afterEach, describe } from "vitest";

describe("Diagram Component", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("Renders react flow nodes", async () => {
render(<Diagram />);

const node1 = screen.getByText("Node 1");
const node2 = screen.getByText("Node 2");
const node3 = screen.getByText("Node 3");
const node4 = screen.getByText("Node 4");
const node5 = screen.getByText("Node 5");

expect(node1).toBeInTheDocument();
expect(node2).toBeInTheDocument();
expect(node3).toBeInTheDocument();
expect(node4).toBeInTheDocument();
expect(node5).toBeInTheDocument();
});
});
12 changes: 11 additions & 1 deletion packages/serverless-workflow-diagram-editor/tests/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,21 @@
* limitations under the License.
*/

import { afterEach } from "vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; // This extends vitest's expect with jest-dom matchers

// Run cleanup after each test to unmount React components and clean up the DOM
afterEach(() => {
cleanup();
});

// Mock ResizeObserver
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
1 change: 1 addition & 0 deletions packages/serverless-workflow-diagram-editor/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "vite/client"],
"baseUrl": ".",
"declaration": true,
"declarationMap": true,
Expand Down
Loading