├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------