├── .eslintrc.cjs ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── vite.svg ├── src ├── ActionNode.tsx ├── App.tsx ├── ConditionNode.tsx ├── CustomNodeBase.tsx ├── InputNode.tsx ├── build-flows.ts ├── custom-nodes.ts ├── index.css ├── main.tsx ├── vite-env.d.ts └── windmill │ ├── .gitignore │ ├── lib.ts │ ├── openapi.yaml │ └── openflow.openapi.yaml ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Flow to Windmill 2 | 3 | [Windmill](https://windmill.dev) is a low-code workflow engine. It offers a dashboard to edit workflows, and it covers you needs most of the time. 4 | 5 | But, it happens that you want to embed a flow editor in your own application, to let your users build their workflows from your application and not from Windmill's dashboard. 6 | 7 | Windmill builds in the open, and exposes an OpenAPI specification one can use to communicate with its API, and create or edit workflows. 8 | 9 | One great solution to create a flow editor in the React world is [React Flow](https://reactflow.dev/). You can customize everything. Nodes and edges are HTML elements that can even be styled with Tailwind CSS. 10 | 11 | I will explain how we can go from React Flow to Windmill. 12 | 13 | ## 1. Get the flow from React Flow 14 | 15 | With React Flow, a flow is made by two entities: nodes and edges. 16 | 17 | Nodes are wired by edges. A node can be alone, being wired to no other node. 18 | 19 | An edge has always a *source* and a *target*. Nodes can have several *handles*, leading edges to be wired to a specific *sourceHandle* and *targetHandle*. 20 | 21 | The most basic configuration for React Flow is: 22 | 23 | ```tsx 24 | import React, { useCallback } from 'react'; 25 | import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow'; 26 | import 'reactflow/dist/style.css'; 27 | 28 | const initialNodes = [ 29 | { id: '1', position: { x: 0, y: 0 }, data: { label: '1' } }, 30 | { id: '2', position: { x: 0, y: 100 }, data: { label: '2' } }, 31 | ]; 32 | const initialEdges = [{ id: 'e1-2', source: '1', target: '2' }]; 33 | 34 | export default function App() { 35 | const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); 36 | const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); 37 | 38 | const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), [setEdges]); 39 | 40 | return ( 41 |
42 | 49 |
50 | ); 51 | } 52 | ``` 53 | 54 | It's important to understand what data React Flow outputs. 55 | 56 | Nodes are defined as: 57 | 58 | ```ts 59 | import { type Node } from 'reactflow'; 60 | 61 | const node: Node = { 62 | id: '1', // The unique identifier of the node 63 | position: { x: 0, y: 0 }, // The coordinates of the node 64 | type: 'input', // React Flow supports some node types out-of-the-box, but you can also create your own types 65 | // Put any data you need in the `data` object. 66 | data: { 67 | label: 'Flow input', // The label is used by default nodes to display a text. 68 | }, 69 | }; 70 | ``` 71 | 72 | There are great chances that you'll want to create your own nodes as React components. See how [`customTypes`](https://github.com/Devessier/reactflow-to-windmill/blob/02953b9a04fe46a2b11df5ea748283379d4fd963/src/custom-nodes.ts#L5-L9) are [provided to the `` component](https://github.com/Devessier/reactflow-to-windmill/blob/02953b9a04fe46a2b11df5ea748283379d4fd963/src/App.tsx#L134). 73 | 74 | Edges are defined as: 75 | 76 | ```ts 77 | import { type Edge } from 'reactflow'; 78 | 79 | const edge: Edge = { 80 | id: 'e1-2', // The unique identifier of the edge 81 | source: '1', // The source node of the edge 82 | sourceHandle: 'default', // Nodes can have several handles, this is the id of the source handle the edge comes from 83 | target: '2', // The target node of the edge 84 | }; 85 | ``` 86 | 87 | Note that nodes have several source handles in one case when applied to a Windmill flow: for conditions. Each branch of a condition node will be a *sourceHandle*. 88 | 89 | I won't go too much into the details of implementing the UI for a Flow Builder with React Flow as it would change between two projects. Instead, I'm going to focus on transforming the output of React Flow, that is, a list of nodes and a list of edges, into a Windmill workflow. 90 | 91 | The code in this repository serves as a simplistic example of a Flow Builder built with React Flow. Feel free to use it as foundation for your own Flow Builder. 92 | 93 | ## 2. Nodes and edges to OpenFlow 94 | 95 | Windmill team has created an [OpenAPI specification describing the shape of a flow](https://github.com/windmill-labs/windmill/blob/d51fc57c42c526cf93336866d90ee7a9ff27a402/openflow.openapi.yaml), and gave it the name of **OpenFlow**. 96 | 97 | Windmill also exposes the [OpenAPI specification describing the routes its backend supports](https://github.com/windmill-labs/windmill/blob/d51fc57c42c526cf93336866d90ee7a9ff27a402/backend/windmill-api/openapi.yaml). 98 | 99 | By using a tool like [openapi-typescript-codegen](https://www.npmjs.com/package/openapi-typescript-codegen), we can generate TypeScript types for the OpenFlow specification, and functions to make requests to Windmill's backend. 100 | 101 | The main difficulty here is to take a list of nodes and a list of edges, and transform it into an OpenFlow, which is like a typed JSON object. 102 | 103 | Let's say we have several types of custom nodes. 104 | 105 | An input node, representing the beginning of a Windmill flow, taking some inputs. 106 | 107 | ```ts 108 | const inputNode = { 109 | id: '...', 110 | type: 'input', 111 | data: { 112 | type: 'input', 113 | properties: [ 114 | { 115 | id: '...', 116 | name: 'resource_id', 117 | type: 'number', 118 | required: true, 119 | }, 120 | ], 121 | }, 122 | }; 123 | ``` 124 | 125 | A condition node, representing logical branches and containing the JavaScript expression to evaluate for each condition. 126 | 127 | ```ts 128 | const conditionNode = { 129 | id: '...', 130 | type: 'condition', 131 | data: { 132 | type: 'condition', 133 | conditions: [ 134 | { 135 | id: '...', 136 | label: 'Is admin', 137 | expression: "authorization_level === 'ADMIN'", 138 | }, 139 | { 140 | id: '...', 141 | label: 'Is user', 142 | expression: "authorization_level === 'USER'", 143 | }, 144 | ], 145 | }, 146 | }; 147 | ``` 148 | 149 | Each Windmill flow has a *default* branch, which is taken when no other branch matches. It's not materialized in the conditions array as it's not configurable. 150 | 151 | Finally, we have a third node type: actions. Actions are scripts evaluated by Windmill and that can take parameters. 152 | 153 | ```ts 154 | const conditionNode = { 155 | id: '...', 156 | type: 'action', 157 | data: { 158 | type: 'action', 159 | actionName: 'f/shared/graphql_request', 160 | inputs: [ 161 | { 162 | id: '...', 163 | parameter: 'request_body', 164 | expression: "'body'", 165 | }, 166 | { 167 | id: '...', 168 | parameter: 'request_variables', 169 | expression: "{ resource_id: flow_input.resource_id }", 170 | }, 171 | ], 172 | }, 173 | }; 174 | ``` 175 | 176 | The list of nodes React Flow outputs is made of these custom nodes. To build the OpenFlow version of this, we first need to locate the input node from which the flow begins. 177 | 178 | ```ts 179 | const startNode = nodes.find((n) => n.data.type === "input"); 180 | ``` 181 | 182 | Then, we need to build the list of nodes that follow the start node. Each step of an OpenFlow is a `FlowModule` object. This type defines what should the step do (is it an action, a condition?), and allows configuring parameters like retries, timeout and summary. 183 | 184 | We'll build this `modules` array recursively. 185 | 186 | ```ts 187 | const startNode = nodes.find((n) => n.data.type === "input"); 188 | 189 | const modules = addNodesToModuleList({ 190 | initialNode: startNode, 191 | edges, 192 | nodes, 193 | modules: [], 194 | }); 195 | 196 | function addNodesToModuleList({ 197 | initialNode, 198 | edges, 199 | nodes, 200 | modules, 201 | }: { 202 | initialNode: FlowNode; 203 | nodes: FlowNode[]; 204 | edges: FlowEdge[]; 205 | modules: FlowModule[]; 206 | }) {} 207 | ``` 208 | 209 | The `addNodesToModuleList` function is going to branch based on the type of the currently processed node: `initialNode`. 210 | 211 | ```ts 212 | function addNodesToModuleList({ 213 | initialNode, 214 | edges, 215 | nodes, 216 | modules, 217 | }: { 218 | initialNode: FlowNode; 219 | nodes: FlowNode[]; 220 | edges: FlowEdge[]; 221 | modules: FlowModule[]; 222 | }) { 223 | switch (initialNode.data.type) { 224 | case "input": { 225 | break; 226 | } 227 | case "action": { 228 | break; 229 | } 230 | case "condition": { 231 | break; 232 | } 233 | default: { 234 | break; 235 | } 236 | } 237 | } 238 | ``` 239 | 240 | When `initialNode.data.type` is `"input"` or an unknown value, falling back to the `default` case, we want to do nothing. 241 | 242 | When reaching an action node, we want to add it to the modules list. 243 | 244 | ```ts 245 | function addNodesToModuleList({ 246 | initialNode, 247 | edges, 248 | nodes, 249 | modules, 250 | }: { 251 | initialNode: FlowNode; 252 | nodes: FlowNode[]; 253 | edges: FlowEdge[]; 254 | modules: FlowModule[]; 255 | }) { 256 | switch (initialNode.data.type) { 257 | case "action": { 258 | const formattedModule: FlowModule = { 259 | id: initialNode.id, 260 | value: { 261 | type: "script", 262 | path: initialNode.data.actionName!, 263 | input_transforms: Object.fromEntries( 264 | initialNode.data.inputs.map(({ parameter, expression }) => [ 265 | parameter, 266 | { 267 | type: "javascript", 268 | expr: expression, 269 | }, 270 | ]) 271 | ), 272 | }, 273 | }; 274 | 275 | modules.push(formattedModule); 276 | 277 | break; 278 | } 279 | } 280 | } 281 | ``` 282 | 283 | We create a `FlowModule` for a `script`. We transform the inputs of the node to an object called `input_transforms`. It will have this shape: 284 | 285 | ```ts 286 | const value = { 287 | input_transforms: { 288 | query: { 289 | type: "javascript", 290 | expr: "'query {}'", 291 | }, 292 | variables: { 293 | type: "javascript", 294 | expr: "({ account_id: flow_input.accountId })", 295 | } 296 | } 297 | } 298 | ``` 299 | 300 | After having processed the node, we want to process the next one respecting the flow order. 301 | 302 | ```ts 303 | function addNodesToModuleList({ 304 | initialNode, 305 | edges, 306 | nodes, 307 | modules, 308 | }: { 309 | initialNode: FlowNode; 310 | nodes: FlowNode[]; 311 | edges: FlowEdge[]; 312 | modules: FlowModule[]; 313 | }) { 314 | switch (initialNode.data.type) { /** ... */ } 315 | 316 | switch (initialNode.data.type) { 317 | case "condition": { 318 | break; 319 | } 320 | default: { 321 | const nextNodeResult = findNextNode({ 322 | currentNode: initialNode, 323 | edges, 324 | nodes, 325 | }); 326 | if (nextNodeResult.ok === false) { 327 | return modules; 328 | } 329 | 330 | return addNodesToModuleList({ 331 | initialNode: nextNodeResult.nextNode, 332 | edges, 333 | nodes, 334 | modules, 335 | }); 336 | } 337 | } 338 | } 339 | ``` 340 | 341 | In the second switch statement, we let the condition for `condition` nodes empty for now. Whatever the type of the node is, we try to find the next node and then start processing the next module by calling `addNodesToModuleList` recursively with the next node. 342 | 343 | Finding the next node means finding the edge beginning from the current node, and then finding the node targeted by this edge for a particular handle. 344 | 345 | ```ts 346 | function findNextNode({ 347 | currentNode, 348 | nodes, 349 | edges, 350 | }: { 351 | currentNode: FlowNode; 352 | nodes: FlowNode[]; 353 | edges: FlowEdge[]; 354 | }): { ok: true; nextNode: FlowNode } | { ok: false } { 355 | const edgeFromNode = edges.find((e) => e.source === currentNode.id); 356 | if (edgeFromNode === undefined) { 357 | return { ok: false }; 358 | } 359 | 360 | const targetNode = nodes.find((n) => n.id === edgeFromNode.target); 361 | if (targetNode === undefined) { 362 | return { ok: false }; 363 | } 364 | 365 | return { 366 | ok: true, 367 | nextNode: targetNode, 368 | }; 369 | } 370 | ``` 371 | 372 | Processing a condition node is a bit more difficult, as it will lead to as many modules list as it has branches. 373 | 374 | ```ts 375 | function addNodesToModuleList({ 376 | initialNode, 377 | edges, 378 | nodes, 379 | modules, 380 | }: { 381 | initialNode: FlowNode; 382 | nodes: FlowNode[]; 383 | edges: FlowEdge[]; 384 | modules: FlowModule[]; 385 | }) { 386 | switch (initialNode.data.type) { 387 | /** ... */ 388 | case "condition": { 389 | const defaultCaseEdge = edges.find( 390 | (edge) => 391 | edge.source === initialNode.id && edge.sourceHandle === "default" 392 | ); 393 | const defaultModules: FlowModule[] = []; 394 | 395 | if (defaultCaseEdge !== undefined) { 396 | const defaultCaseFirstNode = nodes.find( 397 | (node) => node.id === defaultCaseEdge.target 398 | ); 399 | if (defaultCaseFirstNode === undefined) { 400 | console.error("Could not find default case node for condition", { 401 | defaultCaseEdgeId: defaultCaseEdge.id, 402 | }); 403 | 404 | break; 405 | } 406 | 407 | addNodesToModuleList({ 408 | initialNode: defaultCaseFirstNode, 409 | edges, 410 | nodes, 411 | modules: defaultModules, 412 | }); 413 | } 414 | 415 | const branches: BranchOne["branches"] = []; 416 | 417 | for (const condition of ( 418 | initialNode as FlowNode & { data: { type: "condition" } } 419 | ).data.conditions) { 420 | const branchEdge = edges.find( 421 | (edge) => 422 | edge.source === initialNode.id && edge.sourceHandle === condition.id 423 | ); 424 | if (branchEdge === undefined) { 425 | console.error("Could not find case edge for condition", { 426 | initialNodeId: initialNode.id, 427 | }); 428 | 429 | break; 430 | } 431 | 432 | const branchFirstNode = nodes.find( 433 | (node) => node.id === branchEdge.target 434 | ); 435 | if (branchFirstNode === undefined) { 436 | console.error("Could not find case node for condition", { 437 | branchEdgeId: branchEdge.id, 438 | }); 439 | 440 | break; 441 | } 442 | 443 | const branchModules: FlowModule[] = []; 444 | 445 | addNodesToModuleList({ 446 | initialNode: branchFirstNode, 447 | edges, 448 | nodes, 449 | modules: branchModules, 450 | }); 451 | 452 | branches.push({ 453 | summary: condition.label, 454 | expr: condition.expression, 455 | modules: branchModules, 456 | }); 457 | } 458 | 459 | const conditionModule: FlowModule = { 460 | id: initialNode.id, 461 | summary: "", 462 | value: { 463 | type: "branchone", 464 | default: defaultModules, 465 | branches, 466 | }, 467 | }; 468 | 469 | modules.push(conditionModule); 470 | 471 | break; 472 | } 473 | } 474 | 475 | switch (initialNode.data.type) { /** ... */ } 476 | } 477 | ``` 478 | 479 | First, we check whether there is a default branch that should be processed. If so, we call `addNodesToModuleList` recursively to process all the child nodes of this branch. 480 | 481 | Then, we execute each branch the same way, and end up wiring all branches in the `conditionModule` object. 482 | 483 | In OpenFlow, a condition node is always the last module of its parent's `modules` because all branches, even the default one, are children of the condition node in `branches` property. As a consequence, the process we applied to the node above is all we need to do for condition nodes, there is no need to find the next node as we did for action nodes. 484 | 485 | ```ts 486 | function addNodesToModuleList({ 487 | initialNode, 488 | edges, 489 | nodes, 490 | modules, 491 | }: { 492 | initialNode: FlowNode; 493 | nodes: FlowNode[]; 494 | edges: FlowEdge[]; 495 | modules: FlowModule[]; 496 | }) { 497 | switch (initialNode.data.type) { /** ... */ } 498 | 499 | switch (initialNode.data.type) { 500 | case "condition": { 501 | /** 502 | * No other node can be put after a condition. 503 | * Nodes are necessarily put under either the default branch or a specfic branch. 504 | */ 505 | return modules; 506 | } 507 | default: { 508 | /** ... */ 509 | } 510 | } 511 | } 512 | ``` 513 | 514 | Finally, we use the modules to create the `OpenFlow` object. 515 | 516 | ```ts 517 | const openFlow: OpenFlow = { 518 | summary: "", 519 | description: "", 520 | value: { 521 | modules, 522 | }, 523 | schema: { 524 | $schema: "https://json-schema.org/draft/2020-12/schema", 525 | type: "object", 526 | properties: Object.fromEntries( 527 | startNode.data.properties.map((property) => [ 528 | property.name, 529 | { 530 | description: "", 531 | type: property.type.toLowerCase(), 532 | }, 533 | ]) 534 | ), 535 | required: [], 536 | }, 537 | } 538 | ``` 539 | 540 | The `schema` property defines the input the workflow takes. We construct a JSON Schema from the data held by the `startNode`. 541 | 542 | ## 3. Send the OpenFlow to Windmill 543 | 544 | Once you built an OpenFlow object, you may want to create or update a workflow in Windmill. We can do it by using the FlowService, automatically generated by `openapi-typescript-codegen`. 545 | 546 | ```ts 547 | const flowPath = `f/flows/id`; 548 | const requestParams = { 549 | workspace: "your-workspace", 550 | path: flowPath, 551 | requestBody: { 552 | path: flowPath, 553 | ...openFlow, 554 | }, 555 | }; 556 | 557 | if (workflowAlreadyExists === true) { 558 | await FlowService.updateFlow(requestParams); 559 | } else { 560 | await FlowService.createFlow(requestParams); 561 | } 562 | ``` 563 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Flow to Windmill 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactflow-to-windmill", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm run generate-windmill-sdk && vite", 8 | "build": "npm run generate-windmill-sdk && tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "generate-windmill-sdk": "openapi --input ./src/windmill/openapi.yaml --output ./src/windmill/gen --useOptions" 12 | }, 13 | "dependencies": { 14 | "@monaco-editor/react": "^4.5.2", 15 | "clsx": "^2.0.0", 16 | "nanoid": "^5.0.1", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "reactflow": "^11.9.2" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.2.15", 23 | "@types/react-dom": "^18.2.7", 24 | "@typescript-eslint/eslint-plugin": "^6.0.0", 25 | "@typescript-eslint/parser": "^6.0.0", 26 | "@vitejs/plugin-react": "^4.0.3", 27 | "autoprefixer": "^10.4.16", 28 | "eslint": "^8.45.0", 29 | "eslint-plugin-react-hooks": "^4.6.0", 30 | "eslint-plugin-react-refresh": "^0.4.3", 31 | "openapi-typescript-codegen": "^0.25.0", 32 | "postcss": "^8.4.31", 33 | "tailwindcss": "^3.3.3", 34 | "typescript": "^5.0.2", 35 | "vite": "^4.4.5" 36 | }, 37 | "volta": { 38 | "node": "20.8.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ActionNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { Handle, NodeProps, Position } from "reactflow"; 3 | import { CustomNodeBase } from "./CustomNodeBase"; 4 | 5 | export const ActionNode = memo((props: NodeProps) => { 6 | return ( 7 | 8 | 13 |
Action
14 | 19 |
20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { DragEvent, useCallback, useRef, useState } from "react"; 3 | import ReactFlow, { 4 | Connection, 5 | Controls, 6 | Edge, 7 | Node, 8 | ReactFlowInstance, 9 | ReactFlowProvider, 10 | addEdge, 11 | useEdgesState, 12 | useNodesState, 13 | } from "reactflow"; 14 | import "reactflow/dist/style.css"; 15 | import Editor from "@monaco-editor/react"; 16 | import { 17 | ActionNodeData, 18 | ConditionNodeData, 19 | CustomNodeData, 20 | InputNodeData, 21 | customNodes, 22 | } from "./custom-nodes"; 23 | import { buildFlowsFromNodesAndEdges } from "./build-flows"; 24 | 25 | const initialNodes: Node[] = [ 26 | { 27 | id: "input", 28 | position: { x: 250, y: 20 }, 29 | type: "app-input", 30 | data: { type: "input", properties: [] }, 31 | }, 32 | ]; 33 | const initialEdges: Edge[] = []; 34 | 35 | type NodeType = "app-action" | "app-condition"; 36 | 37 | function App() { 38 | const reactFlowWrapper = useRef(null); 39 | const [reactFlowInstance, setReactFlowInstance] = 40 | useState(null); 41 | const [nodes, setNodes, onNodesChange] = 42 | useNodesState(initialNodes); 43 | const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); 44 | 45 | const selectedNode = nodes.find((node) => node.selected === true); 46 | 47 | const panelToDisplay = 48 | selectedNode === undefined 49 | ? "json" 50 | : selectedNode.type === "app-input" 51 | ? "input" 52 | : selectedNode.type === "app-condition" 53 | ? "condition" 54 | : selectedNode.type === "app-action" 55 | ? "action" 56 | : undefined; 57 | 58 | console.log("selectedNode", selectedNode); 59 | 60 | const onConnect = useCallback( 61 | (params: Connection) => setEdges((eds) => addEdge(params, eds)), 62 | [setEdges] 63 | ); 64 | 65 | const availableNodeTypes: Record = { 66 | "app-action": "Action", 67 | "app-condition": "Condition", 68 | }; 69 | 70 | const onDragStart = (event: DragEvent, nodeType: NodeType) => { 71 | event.dataTransfer.setData("application/reactflow", nodeType); 72 | event.dataTransfer.effectAllowed = "move"; 73 | }; 74 | 75 | const onDragOver = useCallback((event: DragEvent) => { 76 | event.preventDefault(); 77 | event.dataTransfer.dropEffect = "move"; 78 | }, []); 79 | 80 | const onDrop = useCallback( 81 | (event: DragEvent) => { 82 | event.preventDefault(); 83 | 84 | const reactFlowBounds = reactFlowWrapper.current!.getBoundingClientRect(); 85 | const type = event.dataTransfer.getData("application/reactflow"); 86 | 87 | // check if the dropped element is valid 88 | if (typeof type === "undefined" || !type) { 89 | return; 90 | } 91 | 92 | const position = reactFlowInstance!.project({ 93 | x: event.clientX - reactFlowBounds.left, 94 | y: event.clientY - reactFlowBounds.top, 95 | }); 96 | 97 | setNodes((nds) => { 98 | if (type === "app-action") { 99 | return nds.concat({ 100 | id: nanoid(), 101 | position, 102 | type: "app-action", 103 | data: { type: "action", actionName: undefined, inputs: [] }, 104 | }); 105 | } 106 | 107 | if (type === "app-condition") { 108 | return nds.concat({ 109 | id: nanoid(), 110 | position, 111 | type: "app-condition", 112 | data: { type: "condition", conditions: [] }, 113 | }); 114 | } 115 | 116 | return nds; 117 | }); 118 | }, 119 | [reactFlowInstance, setNodes] 120 | ); 121 | 122 | return ( 123 | 124 |
125 |
126 |

127 | React Flow to Windmill 128 |

129 |
130 | 131 |
132 |
133 | 145 | 146 | 147 |
148 | 149 |
150 | {Object.entries(availableNodeTypes).map(([type, label]) => ( 151 |
onDragStart(event, type as NodeType)} 156 | > 157 | {label} 158 |
159 | ))} 160 |
161 |
162 | 163 |
164 | {panelToDisplay === "json" ? ( 165 |
166 |

JSON Workflow

167 | 168 | 169 |
170 | ) : panelToDisplay === "input" ? ( 171 | <> 172 |

Input

173 | 174 | } 176 | setNode={(node) => { 177 | setNodes((nodes) => 178 | nodes.map((n) => { 179 | if (node.id !== n.id) { 180 | return n; 181 | } 182 | 183 | return node; 184 | }) 185 | ); 186 | }} 187 | /> 188 | 189 | ) : panelToDisplay === "action" ? ( 190 | <> 191 |

Action

192 | 193 | } 195 | setNode={(node) => { 196 | setNodes((nodes) => 197 | nodes.map((n) => { 198 | if (node.id !== n.id) { 199 | return n; 200 | } 201 | 202 | return node; 203 | }) 204 | ); 205 | }} 206 | /> 207 | 208 | ) : panelToDisplay === "condition" ? ( 209 | <> 210 |

Condition

211 | 212 | } 214 | setNode={(node) => { 215 | setNodes((nodes) => 216 | nodes.map((n) => { 217 | if (node.id !== n.id) { 218 | return n; 219 | } 220 | 221 | return node; 222 | }) 223 | ); 224 | }} 225 | /> 226 | 227 | ) : null} 228 |
229 |
230 |
231 | ); 232 | } 233 | 234 | function WorkflowInputForm({ 235 | node, 236 | setNode, 237 | }: { 238 | node: Node; 239 | setNode: (node: Node) => void; 240 | }) { 241 | const [properties, setProperties] = useState(() => 242 | node.data.properties.concat({ 243 | id: nanoid(), 244 | name: "", 245 | type: "string", 246 | required: false, 247 | }) 248 | ); 249 | 250 | return ( 251 |
{ 254 | e.preventDefault(); 255 | 256 | setNode({ 257 | ...node, 258 | data: { 259 | ...node.data, 260 | properties: properties.filter( 261 | (property) => property.name.trim().length > 0 262 | ), 263 | }, 264 | }); 265 | }} 266 | > 267 | {properties.map(({ id, name, type }, index) => ( 268 |
269 | { 274 | setProperties( 275 | properties.map((p, i) => { 276 | if (i !== index) { 277 | return p; 278 | } 279 | 280 | return Object.assign({}, properties[index], { 281 | name: e.currentTarget.value, 282 | }); 283 | }) 284 | ); 285 | }} 286 | /> 287 | 288 | 312 | 313 | 325 |
326 | ))} 327 | 328 | 340 | 341 | 347 |
348 | ); 349 | } 350 | 351 | function ConditionBranchesForm({ 352 | node, 353 | setNode, 354 | }: { 355 | node: Node; 356 | setNode: (node: Node) => void; 357 | }) { 358 | const [conditions, setConditions] = useState(() => 359 | node.data.conditions.concat({ id: nanoid(), label: "", expression: "" }) 360 | ); 361 | 362 | return ( 363 |
{ 366 | e.preventDefault(); 367 | 368 | setNode({ 369 | ...node, 370 | data: { 371 | ...node.data, 372 | conditions: conditions.filter( 373 | (condition) => condition.label.trim().length > 0 374 | ), 375 | }, 376 | }); 377 | }} 378 | > 379 | {conditions.map(({ id, label, expression }, index) => ( 380 |
381 | { 386 | setConditions( 387 | conditions.map((p, i) => { 388 | if (i !== index) { 389 | return p; 390 | } 391 | 392 | return Object.assign({}, conditions[index], { 393 | label: e.currentTarget.value, 394 | }); 395 | }) 396 | ); 397 | }} 398 | /> 399 | 400 | { 405 | setConditions( 406 | conditions.map((p, i) => { 407 | if (i !== index) { 408 | return p; 409 | } 410 | 411 | return Object.assign({}, conditions[index], { 412 | expression: e.currentTarget.value, 413 | }); 414 | }) 415 | ); 416 | }} 417 | /> 418 | 419 | 431 |
432 | ))} 433 | 434 | 446 | 447 | 453 |
454 | ); 455 | } 456 | 457 | function ActionForm({ 458 | node, 459 | setNode, 460 | }: { 461 | node: Node; 462 | setNode: (node: Node) => void; 463 | }) { 464 | const [actionName, setActionName] = useState(node.data.actionName); 465 | const [inputs, setInputs] = useState(() => 466 | node.data.inputs.concat({ 467 | id: nanoid(), 468 | parameter: "", 469 | expression: "", 470 | }) 471 | ); 472 | 473 | return ( 474 |
{ 477 | e.preventDefault(); 478 | 479 | setNode({ 480 | ...node, 481 | data: { 482 | ...node.data, 483 | actionName, 484 | inputs: inputs.filter((input) => input.parameter.trim().length > 0), 485 | }, 486 | }); 487 | }} 488 | > 489 | { 494 | setActionName(e.currentTarget.value); 495 | }} 496 | /> 497 | 498 |

Parameters

499 | 500 | {inputs.map(({ id, parameter, expression }, index) => ( 501 |
502 | { 507 | setInputs( 508 | inputs.map((p, i) => { 509 | if (i !== index) { 510 | return p; 511 | } 512 | 513 | return Object.assign({}, inputs[index], { 514 | parameter: e.currentTarget.value, 515 | }); 516 | }) 517 | ); 518 | }} 519 | /> 520 | 521 | { 526 | setInputs( 527 | inputs.map((p, i) => { 528 | if (i !== index) { 529 | return p; 530 | } 531 | 532 | return Object.assign({}, inputs[index], { 533 | expression: e.currentTarget.value, 534 | }); 535 | }) 536 | ); 537 | }} 538 | /> 539 | 540 | 552 |
553 | ))} 554 | 555 | 561 |
562 | ); 563 | } 564 | 565 | function WorkflowDefinition({ 566 | nodes, 567 | edges, 568 | }: { 569 | nodes: Array>; 570 | edges: Array; 571 | }) { 572 | const flowDefinitionResult = buildFlowsFromNodesAndEdges({ 573 | nodes, 574 | edges, 575 | }); 576 | console.log("flowDefinitionResult", flowDefinitionResult); 577 | 578 | if (flowDefinitionResult.ok === false) { 579 | return

Failed to compile the workflow.

; 580 | } 581 | 582 | return ( 583 |
584 | 589 |
590 | ); 591 | } 592 | 593 | export default App; 594 | -------------------------------------------------------------------------------- /src/ConditionNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { Handle, NodeProps, Position } from "reactflow"; 3 | import { CustomNodeBase } from "./CustomNodeBase"; 4 | import { ConditionNodeData } from "./custom-nodes"; 5 | 6 | export const ConditionNode = memo((props: NodeProps) => { 7 | const sources = [ 8 | { id: "default", label: "Default Branch" }, 9 | ...props.data.conditions.map((condition) => ({ 10 | id: condition.id, 11 | label: condition.label, 12 | })), 13 | ]; 14 | 15 | return ( 16 | 17 | 22 | 23 |
Condition
24 | 25 |
26 | {sources.map((target, index) => ( 27 |
31 |

{target.label}

32 | 33 | 39 |
40 | ))} 41 |
42 |
43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /src/CustomNodeBase.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | 3 | export function CustomNodeBase({ 4 | isFocused, 5 | children, 6 | }: { 7 | isFocused: boolean; 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 |
17 | {children} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/InputNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { Handle, NodeProps, Position } from "reactflow"; 3 | import { CustomNodeBase } from "./CustomNodeBase"; 4 | 5 | export const InputNode = memo((props: NodeProps) => { 6 | return ( 7 | 8 |
Input
9 | 14 |
15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/build-flows.ts: -------------------------------------------------------------------------------- 1 | import { Edge, Node } from "reactflow"; 2 | import { BranchOne, FlowModule, FlowValue } from "./windmill/gen"; 3 | import { CustomNodeData } from "./custom-nodes"; 4 | 5 | type FlowNode = Node; 6 | type FlowEdge = Edge; 7 | 8 | function findNextNode({ 9 | currentNode, 10 | nodes, 11 | edges, 12 | }: { 13 | currentNode: FlowNode; 14 | nodes: FlowNode[]; 15 | edges: FlowEdge[]; 16 | }): { ok: true; nextNode: FlowNode } | { ok: false } { 17 | const edgeFromNode = edges.find((e) => e.source === currentNode.id); 18 | if (edgeFromNode === undefined) { 19 | return { ok: false }; 20 | } 21 | 22 | const targetNode = nodes.find((n) => n.id === edgeFromNode.target); 23 | if (targetNode === undefined) { 24 | return { ok: false }; 25 | } 26 | 27 | return { 28 | ok: true, 29 | nextNode: targetNode, 30 | }; 31 | } 32 | 33 | function addNodesToModuleList({ 34 | initialNode, 35 | edges, 36 | nodes, 37 | modules, 38 | }: { 39 | initialNode: FlowNode; 40 | nodes: FlowNode[]; 41 | edges: FlowEdge[]; 42 | modules: FlowModule[]; 43 | }) { 44 | switch (initialNode.data.type) { 45 | case "input": { 46 | break; 47 | } 48 | case "action": { 49 | const formattedModule: FlowModule = { 50 | id: initialNode.id, 51 | value: { 52 | type: "script", 53 | path: initialNode.data.actionName!, 54 | input_transforms: Object.fromEntries( 55 | initialNode.data.inputs.map(({ parameter, expression }) => [ 56 | parameter, 57 | { 58 | type: "javascript", 59 | expr: expression, 60 | }, 61 | ]) 62 | ), 63 | }, 64 | }; 65 | 66 | modules.push(formattedModule); 67 | 68 | break; 69 | } 70 | case "condition": { 71 | const defaultCaseEdge = edges.find( 72 | (edge) => 73 | edge.source === initialNode.id && edge.sourceHandle === "default" 74 | ); 75 | const defaultModules: FlowModule[] = []; 76 | 77 | if (defaultCaseEdge !== undefined) { 78 | const defaultCaseFirstNode = nodes.find( 79 | (node) => node.id === defaultCaseEdge.target 80 | ); 81 | if (defaultCaseFirstNode === undefined) { 82 | console.error("Could not find default case node for condition", { 83 | defaultCaseEdgeId: defaultCaseEdge.id, 84 | }); 85 | 86 | break; 87 | } 88 | 89 | addNodesToModuleList({ 90 | initialNode: defaultCaseFirstNode, 91 | edges, 92 | nodes, 93 | modules: defaultModules, 94 | }); 95 | } 96 | 97 | const branches: BranchOne["branches"] = []; 98 | 99 | for (const condition of ( 100 | initialNode as FlowNode & { data: { type: "condition" } } 101 | ).data.conditions) { 102 | const branchEdge = edges.find( 103 | (edge) => 104 | edge.source === initialNode.id && edge.sourceHandle === condition.id 105 | ); 106 | if (branchEdge === undefined) { 107 | console.error("Could not find case edge for condition", { 108 | initialNodeId: initialNode.id, 109 | }); 110 | 111 | break; 112 | } 113 | 114 | const branchFirstNode = nodes.find( 115 | (node) => node.id === branchEdge.target 116 | ); 117 | if (branchFirstNode === undefined) { 118 | console.error("Could not find case node for condition", { 119 | branchEdgeId: branchEdge.id, 120 | }); 121 | 122 | break; 123 | } 124 | 125 | const branchModules: FlowModule[] = []; 126 | 127 | addNodesToModuleList({ 128 | initialNode: branchFirstNode, 129 | edges, 130 | nodes, 131 | modules: branchModules, 132 | }); 133 | 134 | branches.push({ 135 | summary: condition.label, 136 | expr: condition.expression, 137 | modules: branchModules, 138 | }); 139 | } 140 | 141 | const conditionModule: FlowModule = { 142 | id: initialNode.id, 143 | summary: "", 144 | value: { 145 | type: "branchone", 146 | default: defaultModules, 147 | branches, 148 | }, 149 | }; 150 | 151 | modules.push(conditionModule); 152 | 153 | break; 154 | } 155 | default: { 156 | break; 157 | } 158 | } 159 | 160 | switch (initialNode.data.type) { 161 | case "condition": { 162 | /** 163 | * No other node can be put after a condition. 164 | * Nodes are necessarily put under either the default branch or a specfic branch. 165 | */ 166 | return modules; 167 | } 168 | default: { 169 | const nextNodeResult = findNextNode({ 170 | currentNode: initialNode, 171 | edges, 172 | nodes, 173 | }); 174 | if (nextNodeResult.ok === false) { 175 | return modules; 176 | } 177 | 178 | return addNodesToModuleList({ 179 | initialNode: nextNodeResult.nextNode, 180 | edges, 181 | nodes, 182 | modules, 183 | }); 184 | } 185 | } 186 | } 187 | 188 | export function buildFlowsFromNodesAndEdges({ 189 | edges, 190 | nodes, 191 | }: { 192 | edges: FlowEdge[]; 193 | nodes: FlowNode[]; 194 | }): 195 | | { 196 | ok: true; 197 | flow: { 198 | summary: string; 199 | description: string; 200 | schema: unknown; 201 | value: FlowValue; 202 | }; 203 | } 204 | | { ok: false; err: string } { 205 | const startNode = nodes.find((n) => n.data.type === "input") as FlowNode & { 206 | data: { type: "input" }; 207 | }; 208 | if (startNode === undefined) { 209 | return { 210 | ok: false, 211 | err: "No start node", 212 | }; 213 | } 214 | 215 | return { 216 | ok: true, 217 | flow: { 218 | summary: "", 219 | description: "", 220 | value: { 221 | modules: addNodesToModuleList({ 222 | initialNode: startNode, 223 | edges, 224 | nodes, 225 | modules: [], 226 | }), 227 | }, 228 | schema: { 229 | $schema: "https://json-schema.org/draft/2020-12/schema", 230 | type: "object", 231 | properties: Object.fromEntries( 232 | startNode.data.properties.map((property) => [ 233 | property.name, 234 | { 235 | description: "", 236 | type: property.type.toLowerCase(), 237 | }, 238 | ]) 239 | ), 240 | required: [], 241 | }, 242 | }, 243 | }; 244 | } 245 | -------------------------------------------------------------------------------- /src/custom-nodes.ts: -------------------------------------------------------------------------------- 1 | import { ActionNode } from "./ActionNode"; 2 | import { ConditionNode } from "./ConditionNode"; 3 | import { InputNode } from "./InputNode"; 4 | 5 | export const customNodes = { 6 | "app-input": InputNode, 7 | "app-action": ActionNode, 8 | "app-condition": ConditionNode, 9 | }; 10 | 11 | export interface InputProperty { 12 | id: string; 13 | name: string; 14 | type: "string" | "number"; 15 | required: boolean; 16 | } 17 | 18 | export interface InputNodeData { 19 | type: "input"; 20 | properties: Array; 21 | } 22 | 23 | export interface ConditionNodeData { 24 | type: "condition"; 25 | conditions: Array<{ id: string; label: string; expression: string }>; 26 | } 27 | 28 | export interface ActionInput { 29 | id: string; 30 | parameter: string; 31 | expression: string; 32 | } 33 | 34 | export interface ActionNodeData { 35 | type: "action"; 36 | actionName?: string; 37 | inputs: Array; 38 | } 39 | 40 | export type CustomNodeData = InputNodeData | ConditionNodeData | ActionNodeData; 41 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/windmill/.gitignore: -------------------------------------------------------------------------------- 1 | gen 2 | -------------------------------------------------------------------------------- /src/windmill/lib.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPI } from "./gen"; 2 | 3 | OpenAPI.BASE = "https://app.windmill.dev/api"; 4 | 5 | OpenAPI.TOKEN = () => { 6 | throw new Error("Must call setupOpenApiToken before using Windmill services."); 7 | }; 8 | 9 | export function setupOpenApiToken(token: string) { 10 | OpenAPI.TOKEN = token; 11 | } 12 | 13 | export * from "./gen"; 14 | -------------------------------------------------------------------------------- /src/windmill/openflow.openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.3" 2 | 3 | info: 4 | version: 1.172.1 5 | title: OpenFlow Spec 6 | contact: 7 | name: Ruben Fiszel 8 | email: ruben@windmill.dev 9 | url: https://windmill.dev 10 | 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | 15 | paths: {} 16 | 17 | externalDocs: 18 | description: documentation portal 19 | url: https://windmill.dev 20 | 21 | components: 22 | schemas: 23 | OpenFlow: 24 | type: object 25 | properties: 26 | summary: 27 | type: string 28 | description: 29 | type: string 30 | value: 31 | $ref: "#/components/schemas/FlowValue" 32 | schema: 33 | type: object 34 | required: 35 | - summary 36 | - value 37 | 38 | FlowValue: 39 | type: object 40 | properties: 41 | modules: 42 | type: array 43 | items: 44 | $ref: "#/components/schemas/FlowModule" 45 | failure_module: 46 | $ref: "#/components/schemas/FlowModule" 47 | same_worker: 48 | type: boolean 49 | concurrent_limit: 50 | type: number 51 | concurrency_time_window_s: 52 | type: number 53 | skip_expr: 54 | type: string 55 | cache_ttl: 56 | type: number 57 | required: 58 | - modules 59 | 60 | Retry: 61 | type: object 62 | properties: 63 | constant: 64 | type: object 65 | properties: 66 | attempts: 67 | type: integer 68 | seconds: 69 | type: integer 70 | exponential: 71 | type: object 72 | properties: 73 | attempts: 74 | type: integer 75 | multiplier: 76 | type: integer 77 | seconds: 78 | type: integer 79 | 80 | FlowModule: 81 | type: object 82 | properties: 83 | id: 84 | type: string 85 | value: 86 | $ref: "#/components/schemas/FlowModuleValue" 87 | stop_after_if: 88 | type: object 89 | properties: 90 | skip_if_stopped: 91 | type: boolean 92 | expr: 93 | type: string 94 | required: 95 | - expr 96 | sleep: 97 | $ref: "#/components/schemas/InputTransform" 98 | cache_ttl: 99 | type: number 100 | timeout: 101 | type: number 102 | summary: 103 | type: string 104 | mock: 105 | type: object 106 | properties: 107 | enabled: 108 | type: boolean 109 | return_value: {} 110 | suspend: 111 | type: object 112 | properties: 113 | required_events: 114 | type: integer 115 | timeout: 116 | type: integer 117 | resume_form: 118 | type: object 119 | properties: 120 | schema: 121 | type: object 122 | retry: 123 | $ref: "#/components/schemas/Retry" 124 | required: 125 | - value 126 | - id 127 | 128 | InputTransform: 129 | oneOf: 130 | - $ref: "#/components/schemas/StaticTransform" 131 | - $ref: "#/components/schemas/JavascriptTransform" 132 | discriminator: 133 | propertyName: type 134 | mapping: 135 | static: "#/components/schemas/StaticTransform" 136 | javascript: "#/components/schemas/JavascriptTransform" 137 | 138 | StaticTransform: 139 | type: object 140 | properties: 141 | value: {} 142 | type: 143 | type: string 144 | enum: 145 | - javascript 146 | required: 147 | - expr 148 | - type 149 | 150 | JavascriptTransform: 151 | type: object 152 | properties: 153 | expr: 154 | type: string 155 | type: 156 | type: string 157 | enum: 158 | - javascript 159 | required: 160 | - expr 161 | - type 162 | 163 | FlowModuleValue: 164 | oneOf: 165 | - $ref: "#/components/schemas/RawScript" 166 | - $ref: "#/components/schemas/PathScript" 167 | - $ref: "#/components/schemas/PathFlow" 168 | - $ref: "#/components/schemas/ForloopFlow" 169 | - $ref: "#/components/schemas/BranchOne" 170 | - $ref: "#/components/schemas/BranchAll" 171 | - $ref: "#/components/schemas/Identity" 172 | - $ref: "#/components/schemas/Graphql" 173 | discriminator: 174 | propertyName: type 175 | mapping: 176 | rawscript: "#/components/schemas/RawScript" 177 | script: "#/components/schemas/PathScript" 178 | flow: "#/components/schemas/PathFlow" 179 | forloopflow: "#/components/schemas/ForloopFlow" 180 | branchone: "#/components/schemas/BranchOne" 181 | branchall: "#/components/schemas/BranchAll" 182 | identity: "#/components/schemas/Identity" 183 | graphql: "#/components/schemas/Graphql" 184 | 185 | RawScript: 186 | type: object 187 | properties: 188 | # to be made required once migration is over 189 | input_transforms: 190 | type: object 191 | additionalProperties: 192 | $ref: "#/components/schemas/InputTransform" 193 | content: 194 | type: string 195 | language: 196 | type: string 197 | enum: 198 | - deno 199 | - bun 200 | - python3 201 | - go 202 | - bash 203 | - powershell 204 | - postgresql 205 | - mysql 206 | - bigquery 207 | - snowflake 208 | - graphql 209 | - nativets 210 | path: 211 | type: string 212 | lock: 213 | type: string 214 | type: 215 | type: string 216 | enum: 217 | - rawscript 218 | tag: 219 | type: string 220 | concurrent_limit: 221 | type: number 222 | concurrency_time_window_s: 223 | type: number 224 | required: 225 | - type 226 | - content 227 | - language 228 | - input_transforms 229 | 230 | PathScript: 231 | type: object 232 | properties: 233 | input_transforms: 234 | type: object 235 | additionalProperties: 236 | $ref: "#/components/schemas/InputTransform" 237 | path: 238 | type: string 239 | hash: 240 | type: string 241 | type: 242 | type: string 243 | enum: 244 | - script 245 | required: 246 | - type 247 | - path 248 | - input_transforms 249 | 250 | PathFlow: 251 | type: object 252 | properties: 253 | input_transforms: 254 | type: object 255 | additionalProperties: 256 | $ref: "#/components/schemas/InputTransform" 257 | path: 258 | type: string 259 | type: 260 | type: string 261 | enum: 262 | - flow 263 | required: 264 | - type 265 | - path 266 | - input_transforms 267 | 268 | ForloopFlow: 269 | type: object 270 | properties: 271 | modules: 272 | type: array 273 | items: 274 | $ref: "#/components/schemas/FlowModule" 275 | iterator: 276 | $ref: "#/components/schemas/InputTransform" 277 | skip_failures: 278 | type: boolean 279 | type: 280 | type: string 281 | enum: 282 | - forloopflow 283 | parallel: 284 | type: boolean 285 | parallelism: 286 | type: integer 287 | required: 288 | - modules 289 | - iterator 290 | - skip_failures 291 | - type 292 | 293 | BranchOne: 294 | type: object 295 | properties: 296 | branches: 297 | type: array 298 | items: 299 | type: object 300 | properties: 301 | summary: 302 | type: string 303 | expr: 304 | type: string 305 | modules: 306 | type: array 307 | items: 308 | $ref: "#/components/schemas/FlowModule" 309 | required: 310 | - modules 311 | - expr 312 | default: 313 | type: array 314 | items: 315 | $ref: "#/components/schemas/FlowModule" 316 | required: [modules] 317 | type: 318 | type: string 319 | enum: 320 | - branchone 321 | required: 322 | - branches 323 | - default 324 | - type 325 | 326 | BranchAll: 327 | type: object 328 | properties: 329 | branches: 330 | type: array 331 | items: 332 | type: object 333 | properties: 334 | summary: 335 | type: string 336 | skip_failure: 337 | type: boolean 338 | modules: 339 | type: array 340 | items: 341 | $ref: "#/components/schemas/FlowModule" 342 | required: 343 | - modules 344 | - expr 345 | type: 346 | type: string 347 | enum: 348 | - branchall 349 | parallel: 350 | type: boolean 351 | required: 352 | - branches 353 | - type 354 | 355 | Identity: 356 | type: object 357 | properties: 358 | type: 359 | type: string 360 | enum: 361 | - identity 362 | flow: 363 | type: boolean 364 | 365 | required: 366 | - type 367 | 368 | Http: 369 | type: object 370 | properties: 371 | type: 372 | type: string 373 | enum: 374 | - http 375 | required: 376 | - type 377 | 378 | Graphql: 379 | type: object 380 | properties: 381 | type: 382 | type: string 383 | enum: 384 | - graphql 385 | required: 386 | - type 387 | 388 | FlowStatus: 389 | type: object 390 | properties: 391 | step: 392 | type: integer 393 | modules: 394 | type: array 395 | items: 396 | $ref: "#/components/schemas/FlowStatusModule" 397 | failure_module: 398 | allOf: 399 | - $ref: "#/components/schemas/FlowStatusModule" 400 | - type: object 401 | properties: 402 | parent_module: 403 | type: string 404 | 405 | retry: 406 | type: object 407 | properties: 408 | fail_count: 409 | type: integer 410 | failed_jobs: 411 | type: array 412 | items: 413 | type: string 414 | format: uuid 415 | required: 416 | - step 417 | - modules 418 | - failure_module 419 | 420 | FlowStatusModule: 421 | type: object 422 | properties: 423 | type: 424 | type: string 425 | enum: 426 | - WaitingForPriorSteps 427 | - WaitingForEvents 428 | - WaitingForExecutor 429 | - InProgress 430 | - Success 431 | - Failure 432 | id: 433 | type: string 434 | job: 435 | type: string 436 | format: uuid 437 | count: 438 | type: integer 439 | iterator: 440 | type: object 441 | properties: 442 | index: 443 | type: integer 444 | itered: 445 | type: array 446 | items: {} 447 | args: {} 448 | flow_jobs: 449 | type: array 450 | items: 451 | type: string 452 | branch_chosen: 453 | type: object 454 | properties: 455 | type: 456 | type: string 457 | enum: [branch, default] 458 | branch: 459 | type: integer 460 | required: 461 | - type 462 | branchall: 463 | type: object 464 | properties: 465 | branch: 466 | type: integer 467 | len: 468 | type: integer 469 | required: 470 | - branch 471 | - len 472 | approvers: 473 | type: array 474 | items: 475 | type: object 476 | properties: 477 | resume_id: 478 | type: integer 479 | approver: 480 | type: string 481 | required: 482 | - resume_id 483 | - approver 484 | 485 | required: [type] 486 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import defaultTheme from 'tailwindcss/defaultConfig' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | "./index.html", 7 | "./src/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: { 11 | fontFamily: { 12 | sans: ['Inter var', ...defaultTheme.theme.fontFamily.sans], 13 | }, 14 | }, 15 | }, 16 | plugins: [], 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | --------------------------------------------------------------------------------