From 527f9ac869a6662c0dc38dd7a9dee9347954d038 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 05:43:58 +0000 Subject: [PATCH] feat(web): add workflow graph editor and try-mode JSON tree Add a Graph tab (React Flow) as the default workflow editor view, drop the Mermaid YAML preview, auto-assign step-N ids on add, and show test results as a JSON tree. Co-authored-by: Nasyarobby Putra --- package.json | 2 +- packages/web/package.json | 7 +- packages/web/src/components/JsonViewBlock.jsx | 46 +- .../web/src/components/MermaidDiagram.jsx | 75 -- .../web/src/components/workflow/GraphTab.jsx | 400 +++++++++ .../src/components/workflow/ScriptCard.jsx | 63 +- .../src/components/workflow/ScriptsTab.jsx | 17 +- .../src/components/workflow/TriggerCard.jsx | 56 +- .../components/workflow/WorkflowTestPanel.jsx | 50 +- .../workflow/WorkflowVisualEditor.jsx | 57 +- .../web/src/components/workflow/YamlTab.jsx | 16 +- .../components/workflow/graph/GraphNodes.jsx | 58 ++ packages/web/src/index.css | 5 +- packages/web/src/lib/workflow-doc.js | 38 +- packages/web/src/lib/workflow-graph.js | 418 ++++++++++ packages/web/src/lib/workflow-graph.test.js | 89 ++ packages/web/src/lib/workflow-mermaid.js | 161 ---- pnpm-lock.yaml | 775 ++---------------- 18 files changed, 1263 insertions(+), 1070 deletions(-) delete mode 100644 packages/web/src/components/MermaidDiagram.jsx create mode 100644 packages/web/src/components/workflow/GraphTab.jsx create mode 100644 packages/web/src/components/workflow/graph/GraphNodes.jsx create mode 100644 packages/web/src/lib/workflow-graph.js create mode 100644 packages/web/src/lib/workflow-graph.test.js delete mode 100644 packages/web/src/lib/workflow-mermaid.js diff --git a/package.json b/package.json index 76db3d0..27ab9b3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "start:worker": "pnpm --filter @jerapah-flow/server start:worker", "start:control": "pnpm --filter @jerapah-flow/server start:control", "migrate": "pnpm --filter @jerapah-flow/server migrate", - "test": "pnpm --filter @jerapah-flow/shared test", + "test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test", "lint": "pnpm --filter @jerapah-flow/web lint" }, "packageManager": "pnpm@11.22.0" diff --git a/packages/web/package.json b/packages/web/package.json index ac989bf..2e9d639 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -7,19 +7,20 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint src" + "lint": "eslint src", + "test": "node --test src/lib/workflow-graph.test.js" }, "dependencies": { - "@jerapah-flow/shared": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@jerapah-flow/shared": "workspace:*", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.84.0", "@uiw/react-json-view": "2.0.0-alpha.43", + "@xyflow/react": "^12.11.3", "axios": "^1.11.0", "cronstrue": "^3.24.0", - "mermaid": "^11.9.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", diff --git a/packages/web/src/components/JsonViewBlock.jsx b/packages/web/src/components/JsonViewBlock.jsx index 8860e04..8e88ad6 100644 --- a/packages/web/src/components/JsonViewBlock.jsx +++ b/packages/web/src/components/JsonViewBlock.jsx @@ -5,36 +5,42 @@ import { useTheme } from "../theme.jsx"; /** * Collapsible JSON tree using @uiw/react-json-view. - * Docs: value (JSON), keyName (root label), collapsed (depth), - * enableClipboard, displayObjectSize, displayDataTypes, style theme vars. */ -export function JsonViewBlock({ title, value }) { +export function JsonTree({ value, keyName, className = "" }) { const { theme } = useTheme() ?? { theme: "light" }; if (value === undefined) return null; const isTree = value != null && typeof value === "object"; + return ( +
+ +
+ ); +} + +export function JsonViewBlock({ title, value }) { + if (value === undefined) return null; + return (
{title}
-
- -
+
); diff --git a/packages/web/src/components/MermaidDiagram.jsx b/packages/web/src/components/MermaidDiagram.jsx deleted file mode 100644 index 717d79d..0000000 --- a/packages/web/src/components/MermaidDiagram.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useId, useRef } from "react"; -import { useNavigate } from "react-router-dom"; -import mermaid from "mermaid"; -import { useTheme } from "../theme.jsx"; - -function findNode(root, id) { - return ( - root.querySelector(`#${CSS.escape(id)}`) || - root.querySelector(`[id="${CSS.escape(id)}"]`) || - root.querySelector(`[id*="${CSS.escape(id)}"]`) - ); -} - -export function MermaidDiagram({ chart, scriptIds = {} }) { - const { theme } = useTheme(); - const navigate = useNavigate(); - const ref = useRef(null); - const reactId = useId().replace(/:/g, ""); - - const scriptKey = JSON.stringify(scriptIds); - - useEffect(() => { - if (!chart || !ref.current) { - if (ref.current) ref.current.innerHTML = ""; - return; - } - - let cancelled = false; - mermaid.initialize({ - startOnLoad: false, - securityLevel: "loose", - suppressErrorRendering: true, - theme: theme === "dark" ? "dark" : "default", - }); - - const ids = JSON.parse(scriptKey); - const renderId = `mmd-${reactId}-${Date.now()}`; - mermaid - .render(renderId, chart) - .then(({ svg }) => { - if (cancelled || !ref.current) return; - ref.current.innerHTML = svg; - for (const [nodeId, script] of Object.entries(ids)) { - const el = findNode(ref.current, nodeId); - if (!el) continue; - el.style.cursor = "pointer"; - el.setAttribute("title", script); - el.addEventListener("click", () => { - navigate(`/scripts/${encodeURIComponent(script)}/edit`); - }); - } - }) - .catch((err) => { - if (cancelled || !ref.current) return; - ref.current.innerHTML = ""; - const p = document.createElement("p"); - p.className = "text-error text-sm"; - p.textContent = "Could not render diagram"; - ref.current.appendChild(p); - }); - - return () => { - cancelled = true; - }; - }, [chart, scriptKey, theme, navigate, reactId]); - - if (!chart) return null; - - return ( -
- ); -} diff --git a/packages/web/src/components/workflow/GraphTab.jsx b/packages/web/src/components/workflow/GraphTab.jsx new file mode 100644 index 0000000..3503371 --- /dev/null +++ b/packages/web/src/components/workflow/GraphTab.jsx @@ -0,0 +1,400 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Background, + Controls, + MiniMap, + ReactFlow, + ReactFlowProvider, + applyNodeChanges, + useReactFlow, +} from "@xyflow/react"; +import { LuPlus, LuRotateCcw } from "react-icons/lu"; +import { defaultConfigFromMeta } from "../../lib/script.js"; +import { + newCronTrigger, + newHttpTrigger, + newProfileStep, + newScriptStep, + newSetStep, + newWorkflowTrigger, + withAllocatedStepId, +} from "../../lib/workflow-doc.js"; +import { + addStepEdge, + buildGraphElements, + canConnectSteps, + clearGraphLayout, + enteringDagWouldStripWhen, + parseGraphNodeId, + readGraphLayout, + removeStepEdge, + writeGraphLayout, +} from "../../lib/workflow-graph.js"; +import { ConfirmDialog } from "../ConfirmDialog.jsx"; +import { ScriptCard } from "./ScriptCard.jsx"; +import { TriggerCard } from "./TriggerCard.jsx"; +import { AddScriptDialog } from "./AddScriptDialog.jsx"; +import { AddTriggerDialog } from "./AddTriggerDialog.jsx"; +import { StepGraphNode, TriggerGraphNode } from "./graph/GraphNodes.jsx"; + +import "@xyflow/react/dist/style.css"; + +const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode }; + +export function GraphTab(props) { + return ( + + + + ); +} + +function GraphTabInner({ + doc, + onPatch, + disabled, + scripts = [], + profiles = [], + workflows = [], + owner, + file, + excludeFile, + auths = [], + pages = [], +}) { + const { fitView } = useReactFlow(); + const [addStepOpen, setAddStepOpen] = useState(false); + const [addTriggerOpen, setAddTriggerOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [pendingConnect, setPendingConnect] = useState(null); + const [positions, setPositions] = useState(() => readGraphLayout(owner, file)); + const [nodes, setNodes] = useState([]); + + useEffect(() => { + setPositions(readGraphLayout(owner, file)); + setSelectedId(null); + }, [owner, file]); + + const steps = doc?.scripts ?? []; + const triggers = doc?.triggers ?? []; + + const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]); + + useEffect(() => { + const built = buildGraphElements(doc, positions); + setNodes((prev) => { + const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id)); + return built.nodes.map((n) => ({ + ...n, + selected: selected.has(n.id), + deletable: !disabled, + draggable: !disabled, + })); + }); + }, [doc, disabled, positions]); + + useEffect(() => { + const t = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); + return () => cancelAnimationFrame(t); + }, [owner, file, fitView]); + + const selected = parseGraphNodeId(selectedId); + + const scriptsByName = useMemo(() => { + const map = new Map(); + for (const s of scripts) { + const name = typeof s === "string" ? s : s.name; + if (name) map.set(name, s); + } + return map; + }, [scripts]); + + const profilesByName = useMemo(() => { + const map = new Map(); + for (const p of profiles) { + if (p?.name) map.set(p.name, p); + } + return map; + }, [profiles]); + + const displayEdges = useMemo( + () => + edges.map((e) => ({ + ...e, + deletable: Boolean(e.deletable) && !disabled, + })), + [edges, disabled], + ); + + const persistPositions = useCallback( + (next) => { + setPositions(next); + writeGraphLayout(owner, file, next); + }, + [owner, file], + ); + + const onNodesChange = useCallback((changes) => { + setNodes((nds) => applyNodeChanges(changes, nds)); + }, []); + + function patchSteps(next) { + onPatch((d) => { + d.scripts = next; + }); + } + + function patchTriggers(next) { + onPatch((d) => { + d.triggers = next; + }); + } + + function applyConnect(fromUiId, toUiId) { + patchSteps(addStepEdge(doc, fromUiId, toUiId)); + } + + const isValidConnection = useCallback( + (conn) => { + if (disabled) return false; + const from = parseGraphNodeId(conn.source); + const to = parseGraphNodeId(conn.target); + if (from?.kind !== "step" || to?.kind !== "step") return false; + return canConnectSteps(doc, from.uiId, to.uiId); + }, + [doc, disabled], + ); + + function onConnect(conn) { + const from = parseGraphNodeId(conn.source); + const to = parseGraphNodeId(conn.target); + if (from?.kind !== "step" || to?.kind !== "step") return; + if (!canConnectSteps(doc, from.uiId, to.uiId)) return; + if (enteringDagWouldStripWhen(doc)) { + setPendingConnect({ fromUiId: from.uiId, toUiId: to.uiId }); + return; + } + applyConnect(from.uiId, to.uiId); + } + + function onEdgesDelete(deleted) { + if (disabled) return; + for (const edge of deleted) { + const kind = edge.data?.edgeKind; + if (kind !== "list" && kind !== "linear") continue; + const from = parseGraphNodeId(edge.source); + const to = parseGraphNodeId(edge.target); + if (from?.kind !== "step" || to?.kind !== "step") continue; + if (enteringDagWouldStripWhen(doc) && kind === "linear") { + setPendingConnect({ fromUiId: from.uiId, toUiId: to.uiId, remove: true }); + continue; + } + patchSteps(removeStepEdge(doc, from.uiId, to.uiId)); + } + } + + function onNodesDelete(deleted) { + if (disabled) return; + const dropSteps = new Set(); + const dropTrigs = new Set(); + for (const n of deleted) { + const parsed = parseGraphNodeId(n.id); + if (parsed?.kind === "step") dropSteps.add(parsed.uiId); + if (parsed?.kind === "trigger") dropTrigs.add(parsed.uiId); + } + if (dropSteps.size) patchSteps(steps.filter((s) => !dropSteps.has(s.uiId))); + if (dropTrigs.size) patchTriggers(triggers.filter((t) => !dropTrigs.has(t.uiId))); + setSelectedId(null); + } + + function onNodeDragStop(_ev, node) { + const key = node.data?.layoutKey; + if (!key) return; + persistPositions({ ...positions, [key]: { ...node.position } }); + } + + function resetLayout() { + clearGraphLayout(owner, file); + persistPositions({}); + requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); + } + + const selectedStep = selected?.kind === "step" ? steps.find((s) => s.uiId === selected.uiId) : null; + const selectedTrigger = + selected?.kind === "trigger" ? triggers.find((t) => t.uiId === selected.uiId) : null; + const selectedStepIndex = selectedStep ? steps.findIndex((s) => s.uiId === selectedStep.uiId) : -1; + const selectedTriggerIndex = selectedTrigger + ? triggers.findIndex((t) => t.uiId === selectedTrigger.uiId) + : -1; + + return ( +
+
+
+

+ Drag nodes to arrange. Connect steps to set list needs. + Trigger links are automatic. +

+ + + +
+
+ { + const id = selectedNodes[0]?.id ?? null; + setSelectedId((cur) => (cur === id ? cur : id)); + }} + onNodeDragStop={onNodeDragStop} + onNodesDelete={onNodesDelete} + onEdgesDelete={onEdgesDelete} + nodesConnectable={!disabled} + nodesDraggable={!disabled} + elementsSelectable + fitView + proOptions={{ hideAttribution: false }} + > + + + + +
+
+ + setAddStepOpen(false)} + onPick={(picked) => { + let next; + if (picked.kind === "set") next = newSetStep(); + else if (picked.kind === "profile") next = newProfileStep(picked.name, picked.script); + else next = newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)); + next = withAllocatedStepId(next, steps); + patchSteps([...steps, next]); + setSelectedId(`step:${next.uiId}`); + setAddStepOpen(false); + }} + /> + setAddTriggerOpen(false)} + onPick={(kind) => { + const next = + kind === "cron" + ? newCronTrigger() + : kind === "workflow" + ? newWorkflowTrigger() + : newHttpTrigger(); + patchTriggers([...triggers, next]); + setSelectedId(`trig:${next.uiId}`); + setAddTriggerOpen(false); + }} + /> + setPendingConnect(null)} + onConfirm={() => { + const pending = pendingConnect; + setPendingConnect(null); + if (!pending) return; + if (pending.remove) patchSteps(removeStepEdge(doc, pending.fromUiId, pending.toUiId)); + else applyConnect(pending.fromUiId, pending.toUiId); + }} + /> +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/components/workflow/ScriptCard.jsx b/packages/web/src/components/workflow/ScriptCard.jsx index 264d55e..c4e70c9 100644 --- a/packages/web/src/components/workflow/ScriptCard.jsx +++ b/packages/web/src/components/workflow/ScriptCard.jsx @@ -9,12 +9,28 @@ import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx"; import { LogViewer } from "../LogViewer.jsx"; import { StatusBadge } from "../../lib/format"; import { contextFromMeta, prettyJson } from "../../lib/script.js"; +import { needsMode } from "../../lib/workflow-doc.js"; import { ConfigFields } from "./ConfigFields.jsx"; import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx"; import { ScriptIcon } from "../ScriptIcon.jsx"; import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js"; -export function ScriptCard({ +export function ScriptCard(props) { + if (props.sortable === false) { + return ; + } + return ; +} + +function ScriptCardSortable(props) { + const drag = useSortable({ + id: props.step.uiId, + disabled: props.disabled, + }); + return ; +} + +function ScriptCardView({ step, index, otherSteps, @@ -26,18 +42,25 @@ export function ScriptCard({ workflows, owner, excludeFile, + sortable = true, + defaultExpanded = false, + drag, }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: step.uiId, - disabled, - }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? { + attributes: {}, + listeners: {}, + setNodeRef: undefined, + transform: null, + transition: undefined, + isDragging: false, + }; const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, }; const [tryOpen, setTryOpen] = useState(false); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(defaultExpanded); const profile = step.profile && profilesByName?.get(step.profile); const scriptName = profile?.script || step.script; const listed = scriptsByName?.get(scriptName); @@ -68,16 +91,18 @@ export function ScriptCard({ >
- + {sortable !== false ? ( + + ) : null} {step.kind === "script" && scriptName ? ( ) : null} @@ -384,12 +409,6 @@ function NeedsMap({ needs, ids, disabled, onChange }) { ); } -function needsMode(needs) { - if (needs == null) return "none"; - if (Array.isArray(needs)) return "list"; - return "map"; -} - function ScriptTryDialog({ script, config, meta, owner, onClose }) { const existing = useScript(script); const dryRun = useDryRunScript(); diff --git a/packages/web/src/components/workflow/ScriptsTab.jsx b/packages/web/src/components/workflow/ScriptsTab.jsx index 408ee34..126f3b3 100644 --- a/packages/web/src/components/workflow/ScriptsTab.jsx +++ b/packages/web/src/components/workflow/ScriptsTab.jsx @@ -15,7 +15,7 @@ import { } from "@dnd-kit/sortable"; import { LuPlus } from "react-icons/lu"; import { defaultConfigFromMeta } from "../../lib/script.js"; -import { newProfileStep, newScriptStep, newSetStep } from "../../lib/workflow-doc.js"; +import { newProfileStep, newScriptStep, newSetStep, withAllocatedStepId } from "../../lib/workflow-doc.js"; import { AddScriptDialog } from "./AddScriptDialog.jsx"; import { ScriptCard } from "./ScriptCard.jsx"; @@ -115,16 +115,11 @@ export function ScriptsTab({ owner={owner} onClose={() => setAddOpen(false)} onPick={(picked) => { - if (picked.kind === "set") { - patchSteps([...steps, newSetStep()]); - } else if (picked.kind === "profile") { - patchSteps([...steps, newProfileStep(picked.name, picked.script)]); - } else { - patchSteps([ - ...steps, - newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)), - ]); - } + let next; + if (picked.kind === "set") next = newSetStep(); + else if (picked.kind === "profile") next = newProfileStep(picked.name, picked.script); + else next = newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)); + patchSteps([...steps, withAllocatedStepId(next, steps)]); setAddOpen(false); }} /> diff --git a/packages/web/src/components/workflow/TriggerCard.jsx b/packages/web/src/components/workflow/TriggerCard.jsx index 15aa3cb..de6d9fb 100644 --- a/packages/web/src/components/workflow/TriggerCard.jsx +++ b/packages/web/src/components/workflow/TriggerCard.jsx @@ -8,7 +8,22 @@ import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from ". import { FormInput, FormSelect } from "../FormControls.jsx"; import { AuthPicker } from "./AuthPicker.jsx"; -export function TriggerCard({ +export function TriggerCard(props) { + if (props.sortable === false) { + return ; + } + return ; +} + +function TriggerCardSortable(props) { + const drag = useSortable({ + id: props.trigger.uiId, + disabled: props.disabled, + }); + return ; +} + +function TriggerCardView({ trigger, index, owner, @@ -19,18 +34,25 @@ export function TriggerCard({ pages = [], workflows = [], excludeFile, + sortable = true, + defaultExpanded = false, + drag, }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: trigger.uiId, - disabled, - }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? { + attributes: {}, + listeners: {}, + setNodeRef: undefined, + transform: null, + transition: undefined, + isDragging: false, + }; const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, }; const type = trigger.type; - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(defaultExpanded); const alertDestinations = triggerDestinations(workflows, { owner, excludeFile }); return ( @@ -43,16 +65,18 @@ export function TriggerCard({ >
- + {sortable !== false ? ( + + ) : null} + +

- + {inputView === "json" ? ( + + ) : inputTreeError ? ( +

Fix JSON to preview the tree: {inputTreeError}

+ ) : ( + + )}
@@ -222,13 +252,11 @@ export function WorkflowTestPanel({ ) : null}

- {}} - readOnly - height="100%" - /> + {resultValue === undefined ? ( +

Run the workflow to see a result tree.

+ ) : ( + + )}
diff --git a/packages/web/src/components/workflow/WorkflowVisualEditor.jsx b/packages/web/src/components/workflow/WorkflowVisualEditor.jsx index 28aaf7a..39e11a0 100644 --- a/packages/web/src/components/workflow/WorkflowVisualEditor.jsx +++ b/packages/web/src/components/workflow/WorkflowVisualEditor.jsx @@ -1,34 +1,14 @@ import { useMemo, useState } from "react"; -import { parse as parseYaml } from "yaml"; import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js"; import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js"; -import { workflowToFlowchart } from "../../lib/workflow-mermaid.js"; import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js"; import { FormInput, FormTextarea } from "../FormControls.jsx"; +import { GraphTab } from "./GraphTab.jsx"; import { ScriptsTab } from "./ScriptsTab.jsx"; import { TriggersTab } from "./TriggersTab.jsx"; import { YamlTab } from "./YamlTab.jsx"; import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx"; -function useYamlPreview(content) { - return useMemo(() => { - try { - const parsedYaml = parseYaml(content); - return { - parsed: parsedYaml, - parseError: null, - mermaid: workflowToFlowchart(parsedYaml), - }; - } catch (err) { - return { - parsed: null, - parseError: err instanceof Error ? err.message : String(err), - mermaid: { chart: "", scriptIds: {} }, - }; - } - }, [content]); -} - export function WorkflowVisualEditor({ yaml, onYamlChange, @@ -40,14 +20,14 @@ export function WorkflowVisualEditor({ testOpen, onTestClose, }) { - const [tab, setTab] = useState("scripts"); + const [tab, setTab] = useState("graph"); const [lastEdited, setLastEdited] = useState("yaml"); const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc); - const { parsed, parseError, mermaid } = useYamlPreview(yaml); const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]); const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc; const visualDisabled = !displayDoc; + const parseError = parsedDoc.parseError; const { data: scripts = [] } = useScripts(); const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) }); @@ -116,6 +96,20 @@ export function WorkflowVisualEditor({ if (visualDisabled) testDisabledReason = "Fix YAML before running."; else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML."; + const graphProps = { + doc: displayDoc, + onPatch: patchDoc, + disabled: visualDisabled, + scripts, + profiles, + workflows, + owner, + file, + excludeFile: file, + auths, + pages, + }; + return ( <>
@@ -147,6 +141,14 @@ export function WorkflowVisualEditor({
+