### What problem does this PR solve? feat: test buildNodesAndEdgesFromDSLComponents #918 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.7.0
| import '@testing-library/jest-dom'; | |||||
| import 'umi/test-setup'; |
| import { Config, configUmiAlias, createConfig } from 'umi/test'; | |||||
| export default async () => { | |||||
| return (await configUmiAlias({ | |||||
| ...createConfig({ | |||||
| target: 'browser', | |||||
| jsTransformer: 'esbuild', | |||||
| // config opts for esbuild , it will pass to esbuild directly | |||||
| jsTransformerOpts: { jsx: 'automatic' }, | |||||
| }), | |||||
| setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'], | |||||
| collectCoverageFrom: [ | |||||
| '**/*.{ts,tsx,js,jsx}', | |||||
| '!.umi/**', | |||||
| '!.umi-test/**', | |||||
| '!.umi-production/**', | |||||
| '!.umirc.{js,ts}', | |||||
| '!.umirc.*.{js,ts}', | |||||
| '!jest.config.{js,ts}', | |||||
| '!coverage/**', | |||||
| '!dist/**', | |||||
| '!config/**', | |||||
| '!mock/**', | |||||
| ], | |||||
| // if you require some es-module npm package, please uncomment below line and insert your package name | |||||
| // transformIgnorePatterns: ['node_modules/(?!.*(lodash-es|your-es-pkg-name)/)'] | |||||
| coverageThreshold: { | |||||
| global: { | |||||
| lines: 1, | |||||
| }, | |||||
| }, | |||||
| })) as Config.InitialOptions; | |||||
| }; |
| "postinstall": "umi setup", | "postinstall": "umi setup", | ||||
| "lint": "umi lint --eslint-only", | "lint": "umi lint --eslint-only", | ||||
| "setup": "umi setup", | "setup": "umi setup", | ||||
| "start": "npm run dev" | |||||
| "start": "npm run dev", | |||||
| "test": "jest --no-cache --coverage" | |||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@ant-design/icons": "^5.2.6", | "@ant-design/icons": "^5.2.6", | ||||
| "antd": "^5.12.7", | "antd": "^5.12.7", | ||||
| "axios": "^1.6.3", | "axios": "^1.6.3", | ||||
| "classnames": "^2.5.1", | "classnames": "^2.5.1", | ||||
| "dagre": "^0.8.5", | |||||
| "dayjs": "^1.11.10", | "dayjs": "^1.11.10", | ||||
| "eventsource-parser": "^1.1.2", | "eventsource-parser": "^1.1.2", | ||||
| "i18next": "^23.7.16", | "i18next": "^23.7.16", | ||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@react-dev-inspector/umi4-plugin": "^2.0.1", | "@react-dev-inspector/umi4-plugin": "^2.0.1", | ||||
| "@testing-library/jest-dom": "^6.4.5", | |||||
| "@testing-library/react": "^15.0.7", | |||||
| "@types/dagre": "^0.7.52", | |||||
| "@types/jest": "^29.5.12", | |||||
| "@types/lodash": "^4.14.202", | "@types/lodash": "^4.14.202", | ||||
| "@types/react": "^18.0.33", | "@types/react": "^18.0.33", | ||||
| "@types/react-copy-to-clipboard": "^5.0.7", | "@types/react-copy-to-clipboard": "^5.0.7", | ||||
| "@types/react-dom": "^18.0.11", | "@types/react-dom": "^18.0.11", | ||||
| "@types/react-syntax-highlighter": "^15.5.11", | "@types/react-syntax-highlighter": "^15.5.11", | ||||
| "@types/testing-library__jest-dom": "^6.0.0", | |||||
| "@types/uuid": "^9.0.8", | "@types/uuid": "^9.0.8", | ||||
| "@types/webpack-env": "^1.18.4", | "@types/webpack-env": "^1.18.4", | ||||
| "@umijs/lint": "^4.1.1", | "@umijs/lint": "^4.1.1", | ||||
| "@umijs/plugins": "^4.1.0", | "@umijs/plugins": "^4.1.0", | ||||
| "cross-env": "^7.0.3", | "cross-env": "^7.0.3", | ||||
| "jest": "^29.7.0", | |||||
| "jest-environment-jsdom": "^29.7.0", | |||||
| "prettier": "^3.2.4", | "prettier": "^3.2.4", | ||||
| "prettier-plugin-organize-imports": "^3.2.4", | "prettier-plugin-organize-imports": "^3.2.4", | ||||
| "prettier-plugin-packagejson": "^2.4.9", | "prettier-plugin-packagejson": "^2.4.9", | ||||
| "react-dev-inspector": "^2.0.1", | "react-dev-inspector": "^2.0.1", | ||||
| "ts-node": "^10.9.2", | |||||
| "typescript": "^5.0.3", | "typescript": "^5.0.3", | ||||
| "umi-plugin-icons": "^0.1.1" | "umi-plugin-icons": "^0.1.1" | ||||
| } | } |
| OnConnect, | OnConnect, | ||||
| OnEdgesChange, | OnEdgesChange, | ||||
| OnNodesChange, | OnNodesChange, | ||||
| Position, | |||||
| addEdge, | addEdge, | ||||
| applyEdgeChanges, | applyEdgeChanges, | ||||
| applyNodeChanges, | applyNodeChanges, | ||||
| import FlowDrawer from '../flow-drawer'; | import FlowDrawer from '../flow-drawer'; | ||||
| import { useHandleDrop, useShowDrawer } from '../hooks'; | import { useHandleDrop, useShowDrawer } from '../hooks'; | ||||
| import { initialEdges, initialNodes } from '../mock'; | |||||
| import { getLayoutedElements } from '../utils'; | |||||
| import { TextUpdaterNode } from './node'; | import { TextUpdaterNode } from './node'; | ||||
| const nodeTypes = { textUpdater: TextUpdaterNode }; | const nodeTypes = { textUpdater: TextUpdaterNode }; | ||||
| const initialNodes = [ | |||||
| { | |||||
| sourcePosition: Position.Left, | |||||
| targetPosition: Position.Right, | |||||
| id: 'node-1', | |||||
| type: 'textUpdater', | |||||
| position: { x: 400, y: 100 }, | |||||
| data: { label: 123 }, | |||||
| }, | |||||
| { | |||||
| sourcePosition: Position.Right, | |||||
| targetPosition: Position.Left, | |||||
| id: '1', | |||||
| data: { label: 'Hello' }, | |||||
| position: { x: 0, y: 50 }, | |||||
| type: 'input', | |||||
| }, | |||||
| { | |||||
| sourcePosition: Position.Right, | |||||
| targetPosition: Position.Left, | |||||
| id: '2', | |||||
| data: { label: 'World' }, | |||||
| position: { x: 200, y: 50 }, | |||||
| }, | |||||
| ]; | |||||
| const initialEdges = [ | |||||
| { id: '1-2', source: '1', target: '2', label: 'to the', type: 'step' }, | |||||
| ]; | |||||
| interface IProps { | interface IProps { | ||||
| sideWidth: number; | sideWidth: number; | ||||
| } | } | ||||
| function FlowCanvas({ sideWidth }: IProps) { | function FlowCanvas({ sideWidth }: IProps) { | ||||
| const [nodes, setNodes] = useState<Node[]>(initialNodes); | |||||
| const [edges, setEdges] = useState<Edge[]>(initialEdges); | |||||
| const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( | |||||
| initialNodes, | |||||
| initialEdges, | |||||
| 'LR', | |||||
| ); | |||||
| const [nodes, setNodes] = useState<Node[]>(layoutedNodes); | |||||
| const [edges, setEdges] = useState<Edge[]>(layoutedEdges); | |||||
| const { ref, menu, onNodeContextMenu, onPaneClick } = | const { ref, menu, onNodeContextMenu, onPaneClick } = | ||||
| useHandleNodeContextMenu(sideWidth); | useHandleNodeContextMenu(sideWidth); | ||||
| const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); | const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); |
| RocketOutlined, | RocketOutlined, | ||||
| SendOutlined, | SendOutlined, | ||||
| } from '@ant-design/icons'; | } from '@ant-design/icons'; | ||||
| import { Position } from 'reactflow'; | |||||
| export const componentList = [ | export const componentList = [ | ||||
| { name: 'Begin', icon: <SendOutlined />, description: '' }, | { name: 'Begin', icon: <SendOutlined />, description: '' }, | ||||
| { name: 'Generate', icon: <MergeCellsOutlined />, description: '' }, | { name: 'Generate', icon: <MergeCellsOutlined />, description: '' }, | ||||
| ]; | ]; | ||||
| export const initialNodes = [ | |||||
| { | |||||
| sourcePosition: Position.Left, | |||||
| targetPosition: Position.Right, | |||||
| id: 'node-1', | |||||
| type: 'textUpdater', | |||||
| position: { x: 0, y: 0 }, | |||||
| // position: { x: 400, y: 100 }, | |||||
| data: { label: 123 }, | |||||
| }, | |||||
| { | |||||
| sourcePosition: Position.Right, | |||||
| targetPosition: Position.Left, | |||||
| id: '1', | |||||
| data: { label: 'Hello' }, | |||||
| position: { x: 0, y: 0 }, | |||||
| // position: { x: 0, y: 50 }, | |||||
| type: 'input', | |||||
| }, | |||||
| { | |||||
| sourcePosition: Position.Right, | |||||
| targetPosition: Position.Left, | |||||
| id: '2', | |||||
| data: { label: 'World' }, | |||||
| position: { x: 0, y: 0 }, | |||||
| // position: { x: 200, y: 50 }, | |||||
| }, | |||||
| ]; | |||||
| export const initialEdges = [ | |||||
| { id: '1-2', source: '1', target: '2', label: 'to the', type: 'step' }, | |||||
| ]; | |||||
| export const dsl = { | export const dsl = { | ||||
| components: { | components: { | ||||
| begin: { | begin: { | ||||
| component_name: 'Begin', | component_name: 'Begin', | ||||
| params: {}, | params: {}, | ||||
| }, | }, | ||||
| downstream: ['Answer:China'], | |||||
| upstream: [], | |||||
| downstream: ['Answer:China'], // other edge target is downstream, edge source is current node id | |||||
| upstream: [], // edge source is upstream, edge target is current node id | |||||
| }, | }, | ||||
| 'Answer:China': { | 'Answer:China': { | ||||
| obj: { | obj: { |
| import { dsl } from './mock'; | |||||
| import { buildNodesAndEdgesFromDSLComponents } from './utils'; | |||||
| test('buildNodesAndEdgesFromDSLComponents', () => { | |||||
| const { edges, nodes } = buildNodesAndEdgesFromDSLComponents(dsl.components); | |||||
| expect(nodes.length).toEqual(4); | |||||
| expect(edges.length).toEqual(4); | |||||
| expect(edges).toEqual( | |||||
| expect.arrayContaining([ | |||||
| expect.objectContaining({ | |||||
| source: 'begin', | |||||
| target: 'Answer:China', | |||||
| }), | |||||
| expect.objectContaining({ | |||||
| source: 'Answer:China', | |||||
| target: 'Retrieval:China', | |||||
| }), | |||||
| expect.objectContaining({ | |||||
| source: 'Retrieval:China', | |||||
| target: 'Generate:China', | |||||
| }), | |||||
| expect.objectContaining({ | |||||
| source: 'Generate:China', | |||||
| target: 'Answer:China', | |||||
| }), | |||||
| ]), | |||||
| ); | |||||
| }); |
| import { DSLComponents } from '@/interfaces/database/flow'; | import { DSLComponents } from '@/interfaces/database/flow'; | ||||
| import dagre from 'dagre'; | |||||
| import { Edge, Node, Position } from 'reactflow'; | import { Edge, Node, Position } from 'reactflow'; | ||||
| import { v4 as uuidv4 } from 'uuid'; | import { v4 as uuidv4 } from 'uuid'; | ||||
| export const buildNodesFromDSLComponents = (data: DSLComponents) => { | |||||
| const buildEdges = ( | |||||
| operatorIds: string[], | |||||
| currentId: string, | |||||
| allEdges: Edge[], | |||||
| isUpstream = false, | |||||
| ) => { | |||||
| operatorIds.forEach((cur) => { | |||||
| const source = isUpstream ? cur : currentId; | |||||
| const target = isUpstream ? currentId : cur; | |||||
| if (!allEdges.some((e) => e.source === source && e.target === target)) { | |||||
| allEdges.push({ | |||||
| id: uuidv4(), | |||||
| label: '', | |||||
| type: 'step', | |||||
| source: source, | |||||
| target: target, | |||||
| }); | |||||
| } | |||||
| }); | |||||
| }; | |||||
| export const buildNodesAndEdgesFromDSLComponents = (data: DSLComponents) => { | |||||
| const nodes: Node[] = []; | const nodes: Node[] = []; | ||||
| const edges: Edge[] = []; | |||||
| let edges: Edge[] = []; | |||||
| Object.entries(data).forEach(([key, value]) => { | Object.entries(data).forEach(([key, value]) => { | ||||
| const downstream = [...value.downstream]; | const downstream = [...value.downstream]; | ||||
| targetPosition: Position.Right, | targetPosition: Position.Right, | ||||
| }); | }); | ||||
| // intermediate node | |||||
| // The first and last nodes do not need to be considered | |||||
| if (upstream.length > 0 && downstream.length > 0) { | |||||
| for (let i = 0; i < upstream.length; i++) { | |||||
| const up = upstream[i]; | |||||
| for (let j = 0; j < downstream.length; j++) { | |||||
| const down = downstream[j]; | |||||
| edges.push({ | |||||
| id: uuidv4(), | |||||
| label: '', | |||||
| type: 'step', | |||||
| source: up, | |||||
| target: down, | |||||
| }); | |||||
| } | |||||
| } | |||||
| } | |||||
| buildEdges(upstream, key, edges, true); | |||||
| buildEdges(downstream, key, edges, false); | |||||
| }); | |||||
| return { nodes, edges }; | |||||
| }; | |||||
| const dagreGraph = new dagre.graphlib.Graph(); | |||||
| dagreGraph.setDefaultEdgeLabel(() => ({})); | |||||
| const nodeWidth = 172; | |||||
| const nodeHeight = 36; | |||||
| export const getLayoutedElements = ( | |||||
| nodes: Node[], | |||||
| edges: Edge[], | |||||
| direction = 'TB', | |||||
| ) => { | |||||
| const isHorizontal = direction === 'LR'; | |||||
| dagreGraph.setGraph({ rankdir: direction }); | |||||
| nodes.forEach((node) => { | |||||
| dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); | |||||
| }); | |||||
| edges.forEach((edge) => { | |||||
| dagreGraph.setEdge(edge.source, edge.target); | |||||
| }); | |||||
| dagre.layout(dagreGraph); | |||||
| nodes.forEach((node) => { | |||||
| const nodeWithPosition = dagreGraph.node(node.id); | |||||
| node.targetPosition = isHorizontal ? Position.Left : Position.Top; | |||||
| node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; | |||||
| // We are shifting the dagre node position (anchor=center center) to the top left | |||||
| // so it matches the React Flow node anchor point (top left). | |||||
| node.position = { | |||||
| x: nodeWithPosition.x - nodeWidth / 2, | |||||
| y: nodeWithPosition.y - nodeHeight / 2, | |||||
| }; | |||||
| return node; | |||||
| }); | }); | ||||
| return { nodes, edges }; | |||||
| }; | }; |
| { | { | ||||
| "extends": "./src/.umi/tsconfig.json", | "extends": "./src/.umi/tsconfig.json", | ||||
| "@@/*": [ | "@@/*": [ | ||||
| "src/.umi/*" | |||||
| "src/.umi/*", | |||||
| ], | ], | ||||
| } | } |