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 <nasyarobby@gmail.com>
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||||
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
||||||
"migrate": "pnpm --filter @jerapah-flow/server migrate",
|
"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"
|
"lint": "pnpm --filter @jerapah-flow/web lint"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.22.0"
|
"packageManager": "pnpm@11.22.0"
|
||||||
|
|||||||
@@ -7,19 +7,20 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src"
|
"lint": "eslint src",
|
||||||
|
"test": "node --test src/lib/workflow-graph.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jerapah-flow/shared": "workspace:*",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@jerapah-flow/shared": "workspace:*",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tanstack/react-query": "^5.84.0",
|
"@tanstack/react-query": "^5.84.0",
|
||||||
"@uiw/react-json-view": "2.0.0-alpha.43",
|
"@uiw/react-json-view": "2.0.0-alpha.43",
|
||||||
|
"@xyflow/react": "^12.11.3",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"cronstrue": "^3.24.0",
|
"cronstrue": "^3.24.0",
|
||||||
"mermaid": "^11.9.0",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
|||||||
@@ -5,36 +5,42 @@ import { useTheme } from "../theme.jsx";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapsible JSON tree using @uiw/react-json-view.
|
* 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" };
|
const { theme } = useTheme() ?? { theme: "light" };
|
||||||
if (value === undefined) return null;
|
if (value === undefined) return null;
|
||||||
|
|
||||||
const isTree = value != null && typeof value === "object";
|
const isTree = value != null && typeof value === "object";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`overflow-auto rounded-box bg-base-200 p-3 text-xs ${className}`}>
|
||||||
|
<JsonView
|
||||||
|
value={isTree ? value : { value }}
|
||||||
|
keyName={keyName}
|
||||||
|
collapsed={1}
|
||||||
|
enableClipboard
|
||||||
|
displayObjectSize
|
||||||
|
displayDataTypes={false}
|
||||||
|
highlightUpdates={false}
|
||||||
|
shortenTextAfterLength={80}
|
||||||
|
style={{
|
||||||
|
...(theme === "dark" ? darkTheme : lightTheme),
|
||||||
|
"--w-rjv-background-color": "transparent",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonViewBlock({ title, value }) {
|
||||||
|
if (value === undefined) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details open className="collapse collapse-arrow border border-base-300 bg-base-100">
|
<details open className="collapse collapse-arrow border border-base-300 bg-base-100">
|
||||||
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">{title}</summary>
|
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">{title}</summary>
|
||||||
<div className="collapse-content">
|
<div className="collapse-content">
|
||||||
<div className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 text-xs">
|
<JsonTree value={value} keyName={title} className="max-h-80" />
|
||||||
<JsonView
|
|
||||||
value={isTree ? value : { value }}
|
|
||||||
keyName={title}
|
|
||||||
collapsed={1}
|
|
||||||
enableClipboard
|
|
||||||
displayObjectSize
|
|
||||||
displayDataTypes={false}
|
|
||||||
highlightUpdates={false}
|
|
||||||
shortenTextAfterLength={80}
|
|
||||||
style={{
|
|
||||||
...(theme === "dark" ? darkTheme : lightTheme),
|
|
||||||
"--w-rjv-background-color": "transparent",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="mermaid-clickable overflow-x-auto rounded-box border border-base-300 bg-base-200 p-3"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<GraphTabInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex min-h-0 flex-1 gap-3">
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm opacity-70 flex-1">
|
||||||
|
Drag nodes to arrange. Connect steps to set list <span className="font-mono">needs</span>.
|
||||||
|
Trigger links are automatic.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={resetLayout}
|
||||||
|
title="Reset layout"
|
||||||
|
>
|
||||||
|
<LuRotateCcw className="size-4" />
|
||||||
|
Reset layout
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setAddTriggerOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
Add trigger
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setAddStepOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
Add step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden rounded-box border border-base-300 bg-base-200">
|
||||||
|
<ReactFlow
|
||||||
|
className="workflow-graph"
|
||||||
|
nodes={nodes}
|
||||||
|
edges={displayEdges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
isValidConnection={isValidConnection}
|
||||||
|
onSelectionChange={({ nodes: selectedNodes }) => {
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<Background />
|
||||||
|
<Controls />
|
||||||
|
<MiniMap pannable zoomable />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="flex w-full max-w-md shrink-0 flex-col gap-2 overflow-auto sm:w-96">
|
||||||
|
{selectedStep && selectedStepIndex >= 0 ? (
|
||||||
|
<ScriptCard
|
||||||
|
key={selectedStep.uiId}
|
||||||
|
step={selectedStep}
|
||||||
|
index={selectedStepIndex}
|
||||||
|
otherSteps={steps}
|
||||||
|
scriptsByName={scriptsByName}
|
||||||
|
profilesByName={profilesByName}
|
||||||
|
disabled={disabled}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
sortable={false}
|
||||||
|
defaultExpanded
|
||||||
|
onChange={(next) => {
|
||||||
|
const copy = [...steps];
|
||||||
|
copy[selectedStepIndex] = next;
|
||||||
|
patchSteps(copy);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
patchSteps(steps.filter((_, i) => i !== selectedStepIndex));
|
||||||
|
setSelectedId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : selectedTrigger && selectedTriggerIndex >= 0 ? (
|
||||||
|
<TriggerCard
|
||||||
|
key={selectedTrigger.uiId}
|
||||||
|
trigger={selectedTrigger}
|
||||||
|
index={selectedTriggerIndex}
|
||||||
|
owner={owner}
|
||||||
|
auths={auths}
|
||||||
|
pages={pages}
|
||||||
|
workflows={workflows}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
disabled={disabled}
|
||||||
|
sortable={false}
|
||||||
|
defaultExpanded
|
||||||
|
onChange={(next) => {
|
||||||
|
const copy = [...triggers];
|
||||||
|
copy[selectedTriggerIndex] = next;
|
||||||
|
patchTriggers(copy);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
patchTriggers(triggers.filter((_, i) => i !== selectedTriggerIndex));
|
||||||
|
setSelectedId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-box border border-dashed border-base-300 p-4 text-sm opacity-70">
|
||||||
|
<p>Select a node to edit it. Add a trigger or step to grow the graph.</p>
|
||||||
|
<p className="mt-2 text-xs">
|
||||||
|
Named <span className="font-mono">needs</span> maps stay in the inspector; the canvas
|
||||||
|
only edits list dependencies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
<AddScriptDialog
|
||||||
|
open={addStepOpen}
|
||||||
|
owner={owner}
|
||||||
|
onClose={() => 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AddTriggerDialog
|
||||||
|
open={addTriggerOpen}
|
||||||
|
onClose={() => setAddTriggerOpen(false)}
|
||||||
|
onPick={(kind) => {
|
||||||
|
const next =
|
||||||
|
kind === "cron"
|
||||||
|
? newCronTrigger()
|
||||||
|
: kind === "workflow"
|
||||||
|
? newWorkflowTrigger()
|
||||||
|
: newHttpTrigger();
|
||||||
|
patchTriggers([...triggers, next]);
|
||||||
|
setSelectedId(`trig:${next.uiId}`);
|
||||||
|
setAddTriggerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(pendingConnect)}
|
||||||
|
title="Switch to DAG mode?"
|
||||||
|
message="Connecting or removing a step edge writes needs and is not compatible with when. Existing when conditions on steps will be cleared."
|
||||||
|
confirmLabel="Continue"
|
||||||
|
confirmClass="btn-primary"
|
||||||
|
onCancel={() => 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,28 @@ import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
|
|||||||
import { LogViewer } from "../LogViewer.jsx";
|
import { LogViewer } from "../LogViewer.jsx";
|
||||||
import { StatusBadge } from "../../lib/format";
|
import { StatusBadge } from "../../lib/format";
|
||||||
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||||
|
import { needsMode } from "../../lib/workflow-doc.js";
|
||||||
import { ConfigFields } from "./ConfigFields.jsx";
|
import { ConfigFields } from "./ConfigFields.jsx";
|
||||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||||
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
|
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
|
||||||
|
|
||||||
export function ScriptCard({
|
export function ScriptCard(props) {
|
||||||
|
if (props.sortable === false) {
|
||||||
|
return <ScriptCardView {...props} drag={null} />;
|
||||||
|
}
|
||||||
|
return <ScriptCardSortable {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScriptCardSortable(props) {
|
||||||
|
const drag = useSortable({
|
||||||
|
id: props.step.uiId,
|
||||||
|
disabled: props.disabled,
|
||||||
|
});
|
||||||
|
return <ScriptCardView {...props} drag={drag} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScriptCardView({
|
||||||
step,
|
step,
|
||||||
index,
|
index,
|
||||||
otherSteps,
|
otherSteps,
|
||||||
@@ -26,18 +42,25 @@ export function ScriptCard({
|
|||||||
workflows,
|
workflows,
|
||||||
owner,
|
owner,
|
||||||
excludeFile,
|
excludeFile,
|
||||||
|
sortable = true,
|
||||||
|
defaultExpanded = false,
|
||||||
|
drag,
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? {
|
||||||
id: step.uiId,
|
attributes: {},
|
||||||
disabled,
|
listeners: {},
|
||||||
});
|
setNodeRef: undefined,
|
||||||
|
transform: null,
|
||||||
|
transition: undefined,
|
||||||
|
isDragging: false,
|
||||||
|
};
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.6 : 1,
|
opacity: isDragging ? 0.6 : 1,
|
||||||
};
|
};
|
||||||
const [tryOpen, setTryOpen] = useState(false);
|
const [tryOpen, setTryOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
const profile = step.profile && profilesByName?.get(step.profile);
|
const profile = step.profile && profilesByName?.get(step.profile);
|
||||||
const scriptName = profile?.script || step.script;
|
const scriptName = profile?.script || step.script;
|
||||||
const listed = scriptsByName?.get(scriptName);
|
const listed = scriptsByName?.get(scriptName);
|
||||||
@@ -68,16 +91,18 @@ export function ScriptCard({
|
|||||||
>
|
>
|
||||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{sortable !== false ? (
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
type="button"
|
||||||
aria-label={`Drag step ${index + 1}`}
|
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
disabled={disabled}
|
aria-label={`Drag step ${index + 1}`}
|
||||||
{...attributes}
|
disabled={disabled}
|
||||||
{...listeners}
|
{...attributes}
|
||||||
>
|
{...listeners}
|
||||||
<LuGripVertical className="size-4 opacity-60" />
|
>
|
||||||
</button>
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{step.kind === "script" && scriptName ? (
|
{step.kind === "script" && scriptName ? (
|
||||||
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||||
) : null}
|
) : 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 }) {
|
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
||||||
const existing = useScript(script);
|
const existing = useScript(script);
|
||||||
const dryRun = useDryRunScript();
|
const dryRun = useDryRunScript();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus } from "react-icons/lu";
|
||||||
import { defaultConfigFromMeta } from "../../lib/script.js";
|
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 { AddScriptDialog } from "./AddScriptDialog.jsx";
|
||||||
import { ScriptCard } from "./ScriptCard.jsx";
|
import { ScriptCard } from "./ScriptCard.jsx";
|
||||||
|
|
||||||
@@ -115,16 +115,11 @@ export function ScriptsTab({
|
|||||||
owner={owner}
|
owner={owner}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onPick={(picked) => {
|
onPick={(picked) => {
|
||||||
if (picked.kind === "set") {
|
let next;
|
||||||
patchSteps([...steps, newSetStep()]);
|
if (picked.kind === "set") next = newSetStep();
|
||||||
} else if (picked.kind === "profile") {
|
else if (picked.kind === "profile") next = newProfileStep(picked.name, picked.script);
|
||||||
patchSteps([...steps, newProfileStep(picked.name, picked.script)]);
|
else next = newScriptStep(picked.name, defaultConfigFromMeta(picked.meta));
|
||||||
} else {
|
patchSteps([...steps, withAllocatedStepId(next, steps)]);
|
||||||
patchSteps([
|
|
||||||
...steps,
|
|
||||||
newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,22 @@ import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from ".
|
|||||||
import { FormInput, FormSelect } from "../FormControls.jsx";
|
import { FormInput, FormSelect } from "../FormControls.jsx";
|
||||||
import { AuthPicker } from "./AuthPicker.jsx";
|
import { AuthPicker } from "./AuthPicker.jsx";
|
||||||
|
|
||||||
export function TriggerCard({
|
export function TriggerCard(props) {
|
||||||
|
if (props.sortable === false) {
|
||||||
|
return <TriggerCardView {...props} drag={null} />;
|
||||||
|
}
|
||||||
|
return <TriggerCardSortable {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriggerCardSortable(props) {
|
||||||
|
const drag = useSortable({
|
||||||
|
id: props.trigger.uiId,
|
||||||
|
disabled: props.disabled,
|
||||||
|
});
|
||||||
|
return <TriggerCardView {...props} drag={drag} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriggerCardView({
|
||||||
trigger,
|
trigger,
|
||||||
index,
|
index,
|
||||||
owner,
|
owner,
|
||||||
@@ -19,18 +34,25 @@ export function TriggerCard({
|
|||||||
pages = [],
|
pages = [],
|
||||||
workflows = [],
|
workflows = [],
|
||||||
excludeFile,
|
excludeFile,
|
||||||
|
sortable = true,
|
||||||
|
defaultExpanded = false,
|
||||||
|
drag,
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? {
|
||||||
id: trigger.uiId,
|
attributes: {},
|
||||||
disabled,
|
listeners: {},
|
||||||
});
|
setNodeRef: undefined,
|
||||||
|
transform: null,
|
||||||
|
transition: undefined,
|
||||||
|
isDragging: false,
|
||||||
|
};
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.6 : 1,
|
opacity: isDragging ? 0.6 : 1,
|
||||||
};
|
};
|
||||||
const type = trigger.type;
|
const type = trigger.type;
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
const alertDestinations = triggerDestinations(workflows, { owner, excludeFile });
|
const alertDestinations = triggerDestinations(workflows, { owner, excludeFile });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,16 +65,18 @@ export function TriggerCard({
|
|||||||
>
|
>
|
||||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{sortable !== false ? (
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
type="button"
|
||||||
aria-label={`Drag trigger ${index + 1}`}
|
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
disabled={disabled}
|
aria-label={`Drag trigger ${index + 1}`}
|
||||||
{...attributes}
|
disabled={disabled}
|
||||||
{...listeners}
|
{...attributes}
|
||||||
>
|
{...listeners}
|
||||||
<LuGripVertical className="size-4 opacity-60" />
|
>
|
||||||
</button>
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 flex-1 text-left"
|
className="min-w-0 flex-1 text-left"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
|||||||
import { api, errorMessage } from "../../api/client.js";
|
import { api, errorMessage } from "../../api/client.js";
|
||||||
import { useRunWorkflow } from "../../api/hooks.js";
|
import { useRunWorkflow } from "../../api/hooks.js";
|
||||||
import { CodeEditor } from "../CodeEditor.jsx";
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
|
import { JsonTree } from "../JsonViewBlock.jsx";
|
||||||
import { StatusBadge } from "../../lib/format";
|
import { StatusBadge } from "../../lib/format";
|
||||||
import { prettyJson } from "../../lib/script.js";
|
import { prettyJson } from "../../lib/script.js";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ export function WorkflowTestPanel({
|
|||||||
const [last, setLast] = useState(null);
|
const [last, setLast] = useState(null);
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [inputView, setInputView] = useState("json");
|
||||||
const saveTimer = useRef(null);
|
const saveTimer = useRef(null);
|
||||||
const pollAbort = useRef(null);
|
const pollAbort = useRef(null);
|
||||||
|
|
||||||
@@ -146,9 +148,15 @@ export function WorkflowTestPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultJson = last
|
const resultValue = last ? (last.error ? { error: last.error } : last.result) : undefined;
|
||||||
? prettyJson(last.error ? { error: last.error } : last.result)
|
|
||||||
: "";
|
let inputTreeValue;
|
||||||
|
let inputTreeError = null;
|
||||||
|
try {
|
||||||
|
inputTreeValue = JSON.parse(dataJson || "null");
|
||||||
|
} catch (err) {
|
||||||
|
inputTreeError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
const busy = run.isPending || polling;
|
const busy = run.isPending || polling;
|
||||||
|
|
||||||
@@ -206,9 +214,31 @@ export function WorkflowTestPanel({
|
|||||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||||
data
|
data
|
||||||
<SchemaTooltip label="Input" fields={inputFields} />
|
<SchemaTooltip label="Input" fields={inputFields} />
|
||||||
|
<span className="join ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`join-item btn btn-xs ${inputView === "json" ? "btn-active" : ""}`}
|
||||||
|
onClick={() => setInputView("json")}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`join-item btn btn-xs ${inputView === "tree" ? "btn-active" : ""}`}
|
||||||
|
onClick={() => setInputView("tree")}
|
||||||
|
>
|
||||||
|
Tree
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||||
<CodeEditor language="json" value={dataJson} onChange={onDataChange} height="100%" />
|
{inputView === "json" ? (
|
||||||
|
<CodeEditor language="json" value={dataJson} onChange={onDataChange} height="100%" />
|
||||||
|
) : inputTreeError ? (
|
||||||
|
<p className="text-error text-sm p-2">Fix JSON to preview the tree: {inputTreeError}</p>
|
||||||
|
) : (
|
||||||
|
<JsonTree value={inputTreeValue} keyName="data" className="h-full max-h-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||||
@@ -222,13 +252,11 @@ export function WorkflowTestPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||||
<CodeEditor
|
{resultValue === undefined ? (
|
||||||
language="json"
|
<p className="text-xs opacity-50 p-2">Run the workflow to see a result tree.</p>
|
||||||
value={resultJson}
|
) : (
|
||||||
onChange={() => {}}
|
<JsonTree value={resultValue} keyName="result" className="h-full max-h-full" />
|
||||||
readOnly
|
)}
|
||||||
height="100%"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { parse as parseYaml } from "yaml";
|
|
||||||
import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js";
|
import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js";
|
||||||
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
|
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
|
||||||
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
|
||||||
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
||||||
import { FormInput, FormTextarea } from "../FormControls.jsx";
|
import { FormInput, FormTextarea } from "../FormControls.jsx";
|
||||||
|
import { GraphTab } from "./GraphTab.jsx";
|
||||||
import { ScriptsTab } from "./ScriptsTab.jsx";
|
import { ScriptsTab } from "./ScriptsTab.jsx";
|
||||||
import { TriggersTab } from "./TriggersTab.jsx";
|
import { TriggersTab } from "./TriggersTab.jsx";
|
||||||
import { YamlTab } from "./YamlTab.jsx";
|
import { YamlTab } from "./YamlTab.jsx";
|
||||||
import { WorkflowTestPanel } from "./WorkflowTestPanel.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({
|
export function WorkflowVisualEditor({
|
||||||
yaml,
|
yaml,
|
||||||
onYamlChange,
|
onYamlChange,
|
||||||
@@ -40,14 +20,14 @@ export function WorkflowVisualEditor({
|
|||||||
testOpen,
|
testOpen,
|
||||||
onTestClose,
|
onTestClose,
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState("scripts");
|
const [tab, setTab] = useState("graph");
|
||||||
const [lastEdited, setLastEdited] = useState("yaml");
|
const [lastEdited, setLastEdited] = useState("yaml");
|
||||||
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
|
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
|
||||||
|
|
||||||
const { parsed, parseError, mermaid } = useYamlPreview(yaml);
|
|
||||||
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
|
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
|
||||||
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
|
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
|
||||||
const visualDisabled = !displayDoc;
|
const visualDisabled = !displayDoc;
|
||||||
|
const parseError = parsedDoc.parseError;
|
||||||
|
|
||||||
const { data: scripts = [] } = useScripts();
|
const { data: scripts = [] } = useScripts();
|
||||||
const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) });
|
const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) });
|
||||||
@@ -116,6 +96,20 @@ export function WorkflowVisualEditor({
|
|||||||
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
|
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
|
||||||
else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML.";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
@@ -147,6 +141,14 @@ export function WorkflowVisualEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
|
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${tab === "graph" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => selectTab("graph")}
|
||||||
|
>
|
||||||
|
Graph
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -179,6 +181,7 @@ export function WorkflowVisualEditor({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{tab === "graph" ? <GraphTab {...graphProps} /> : null}
|
||||||
{tab === "scripts" ? (
|
{tab === "scripts" ? (
|
||||||
<ScriptsTab
|
<ScriptsTab
|
||||||
doc={displayDoc}
|
doc={displayDoc}
|
||||||
@@ -204,13 +207,7 @@ export function WorkflowVisualEditor({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{tab === "yaml" ? (
|
{tab === "yaml" ? (
|
||||||
<YamlTab
|
<YamlTab content={yaml} onChange={onYamlTabChange} parseError={parseError} />
|
||||||
content={yaml}
|
|
||||||
onChange={onYamlTabChange}
|
|
||||||
parseError={parseError}
|
|
||||||
mermaid={mermaid}
|
|
||||||
parsed={parsed}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showTest && owner && file ? (
|
{showTest && owner && file ? (
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { CodeEditor } from "../CodeEditor.jsx";
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
import { MermaidDiagram } from "../MermaidDiagram.jsx";
|
|
||||||
|
|
||||||
export function YamlTab({ content, onChange, parseError, mermaid, parsed }) {
|
export function YamlTab({ content, onChange, parseError }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
<div className="min-h-0">
|
{parseError ? <p className="text-error text-sm shrink-0">{parseError}</p> : null}
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
|
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 overflow-auto">
|
|
||||||
{parseError ? (
|
|
||||||
<p className="text-error text-sm">{parseError}</p>
|
|
||||||
) : (
|
|
||||||
<MermaidDiagram chart={mermaid.chart} scriptIds={mermaid.scriptIds} />
|
|
||||||
)}
|
|
||||||
{parsed?.name ? <p className="text-sm opacity-70 mt-2">{parsed.name}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Handle, Position } from "@xyflow/react";
|
||||||
|
|
||||||
|
export function TriggerGraphNode({ data, selected }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-44 max-w-56 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
|
||||||
|
selected ? "border-primary ring-2 ring-primary/40" : "border-base-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide opacity-60">Trigger</p>
|
||||||
|
<p className="font-semibold text-sm leading-tight">{data.triggerType || "HTTP"}</p>
|
||||||
|
<p className="font-mono text-[11px] opacity-70 truncate" title={data.label}>
|
||||||
|
{data.label}
|
||||||
|
</p>
|
||||||
|
<Handle type="source" position={Position.Right} isConnectable={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepGraphNode({ data, selected }) {
|
||||||
|
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
|
||||||
|
data.kind === "set"
|
||||||
|
? "border-secondary"
|
||||||
|
: data.profile
|
||||||
|
? "border-accent"
|
||||||
|
: "border-primary"
|
||||||
|
} ${selected ? "ring-2 ring-primary/40" : ""}`}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Left} />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
|
||||||
|
{data.stepId ? (
|
||||||
|
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
|
||||||
|
) : null}
|
||||||
|
{data.when ? (
|
||||||
|
<span className="badge badge-warning badge-xs" title={data.when}>
|
||||||
|
when
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{data.needsMode === "map" ? (
|
||||||
|
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
|
||||||
|
map
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-sm leading-tight truncate" title={data.label}>
|
||||||
|
{data.label}
|
||||||
|
</p>
|
||||||
|
{data.missingNeeds?.length ? (
|
||||||
|
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
|
||||||
|
) : null}
|
||||||
|
<Handle type="source" position={Position.Right} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ body,
|
|||||||
transition: transform 0.7s ease-out;
|
transition: transform 0.7s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-clickable [id^="s"] {
|
.workflow-graph {
|
||||||
cursor: pointer;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,37 @@ export function stringifyWorkflowDoc(doc) {
|
|||||||
return stringifyYaml(out, { indent: 2, lineWidth: 0 });
|
return stringifyYaml(out, { indent: 2, lineWidth: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextStepId(existingIds) {
|
||||||
|
const used = existingIds instanceof Set ? existingIds : new Set([...(existingIds ?? [])].filter(Boolean));
|
||||||
|
let n = 1;
|
||||||
|
while (used.has(`step-${n}`)) n += 1;
|
||||||
|
return `step-${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withAllocatedStepId(step, existingSteps) {
|
||||||
|
if (step?.id) return step;
|
||||||
|
const ids = (existingSteps ?? []).map((s) => s.id).filter(Boolean);
|
||||||
|
return { ...step, id: nextStepId(ids) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsMode(needs) {
|
||||||
|
if (needs == null) return "none";
|
||||||
|
if (Array.isArray(needs)) return "list";
|
||||||
|
if (typeof needs === "object") return "map";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyNeeds(needs) {
|
||||||
|
if (needs == null) return true;
|
||||||
|
if (Array.isArray(needs)) return needs.length === 0;
|
||||||
|
if (typeof needs === "object") return Object.keys(needs).length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDagDoc(doc) {
|
||||||
|
return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs));
|
||||||
|
}
|
||||||
|
|
||||||
export function newScriptStep(script, config = {}) {
|
export function newScriptStep(script, config = {}) {
|
||||||
return {
|
return {
|
||||||
uiId: nextUiId("step"),
|
uiId: nextUiId("step"),
|
||||||
@@ -349,10 +380,3 @@ function dumpTrigger(t) {
|
|||||||
}
|
}
|
||||||
return { type: t?.type ?? "HTTP", ...(t.extra ?? {}) };
|
return { type: t?.type ?? "HTTP", ...(t.extra ?? {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmptyNeeds(needs) {
|
|
||||||
if (needs == null) return true;
|
|
||||||
if (Array.isArray(needs)) return needs.length === 0;
|
|
||||||
if (typeof needs === "object") return Object.keys(needs).length === 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId } from "./workflow-doc.js";
|
||||||
|
|
||||||
|
export const STEP_NODE_PREFIX = "step:";
|
||||||
|
export const TRIGGER_NODE_PREFIX = "trig:";
|
||||||
|
|
||||||
|
export function stepNodeId(uiId) {
|
||||||
|
return `${STEP_NODE_PREFIX}${uiId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerNodeId(uiId) {
|
||||||
|
return `${TRIGGER_NODE_PREFIX}${uiId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGraphNodeId(id) {
|
||||||
|
if (typeof id !== "string") return null;
|
||||||
|
if (id.startsWith(STEP_NODE_PREFIX)) {
|
||||||
|
return { kind: "step", uiId: id.slice(STEP_NODE_PREFIX.length) };
|
||||||
|
}
|
||||||
|
if (id.startsWith(TRIGGER_NODE_PREFIX)) {
|
||||||
|
return { kind: "trigger", uiId: id.slice(TRIGGER_NODE_PREFIX.length) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parentIdsFromNeeds(needs) {
|
||||||
|
if (needs == null) return [];
|
||||||
|
if (Array.isArray(needs)) {
|
||||||
|
return needs.filter((x) => typeof x === "string" && x.length > 0);
|
||||||
|
}
|
||||||
|
if (typeof needs === "object") {
|
||||||
|
return Object.values(needs).filter((x) => typeof x === "string" && x.length > 0);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerSummary(trigger, owner) {
|
||||||
|
const type = String(trigger?.type ?? "").toLowerCase();
|
||||||
|
if (type === "cron") return trigger.schedule || "cron";
|
||||||
|
if (type === "workflow") return "callable";
|
||||||
|
const method = trigger?.method || "POST";
|
||||||
|
const path = trigger?.path || "/";
|
||||||
|
if (owner) return `${method} /u/${owner}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
return `${method} ${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepLabel(step) {
|
||||||
|
if (step?.kind === "set") return step.id ? `${step.id}: set` : "set";
|
||||||
|
const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled";
|
||||||
|
return step?.id ? `${step.id}: ${target}` : target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutKeyForTrigger(index) {
|
||||||
|
return `t:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutKeyForStep(step, index) {
|
||||||
|
if (step?.id) return `s-id:${step.id}`;
|
||||||
|
return `s:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docHasWhen(doc) {
|
||||||
|
return (doc?.scripts ?? []).some((s) => typeof s.when === "string" && s.when.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneScripts(doc) {
|
||||||
|
return (doc?.scripts ?? []).map((s) => ({ ...s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedIds(scripts) {
|
||||||
|
return new Set(scripts.map((s) => s.id).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStepId(step, used) {
|
||||||
|
if (step.id) {
|
||||||
|
used.add(step.id);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
const id = nextStepId(used);
|
||||||
|
used.add(id);
|
||||||
|
return { ...step, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripWhenFromScripts(scripts) {
|
||||||
|
return scripts.map((s) => (s.when ? { ...s, when: "" } : s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn implicit linear order into list `needs` (and assign missing ids).
|
||||||
|
* Callers must strip `when` when entering DAG.
|
||||||
|
*/
|
||||||
|
export function materializeLinearNeeds(scripts) {
|
||||||
|
if (scripts.length === 0) return scripts;
|
||||||
|
const used = usedIds(scripts);
|
||||||
|
const next = scripts.map((s) => ensureStepId({ ...s }, used));
|
||||||
|
for (let i = 1; i < next.length; i++) {
|
||||||
|
const parentId = next[i - 1].id;
|
||||||
|
next[i] = { ...next[i], needs: [parentId], when: "" };
|
||||||
|
}
|
||||||
|
next[0] = { ...next[0], when: "" };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjacencyFromScripts(scripts) {
|
||||||
|
const byId = new Map();
|
||||||
|
for (const s of scripts) {
|
||||||
|
if (s.id) byId.set(s.id, s.uiId);
|
||||||
|
}
|
||||||
|
/** @type {Map<string, string[]>} */
|
||||||
|
const children = new Map();
|
||||||
|
for (const s of scripts) children.set(s.uiId, []);
|
||||||
|
const dag = scripts.some((s) => !isEmptyNeeds(s.needs));
|
||||||
|
if (dag) {
|
||||||
|
for (const s of scripts) {
|
||||||
|
for (const pid of parentIdsFromNeeds(s.needs)) {
|
||||||
|
const fromUi = byId.get(pid);
|
||||||
|
if (fromUi) children.get(fromUi)?.push(s.uiId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < scripts.length; i++) {
|
||||||
|
children.get(scripts[i - 1].uiId)?.push(scripts[i].uiId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wouldCreateCycle(scripts, fromUiId, toUiId) {
|
||||||
|
if (fromUiId === toUiId) return true;
|
||||||
|
const children = adjacencyFromScripts(scripts);
|
||||||
|
const stack = [...(children.get(toUiId) ?? [])];
|
||||||
|
const seen = new Set();
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop();
|
||||||
|
if (cur === fromUiId) return true;
|
||||||
|
if (seen.has(cur)) continue;
|
||||||
|
seen.add(cur);
|
||||||
|
for (const n of children.get(cur) ?? []) stack.push(n);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canConnectSteps(doc, fromUiId, toUiId) {
|
||||||
|
const scripts = doc?.scripts ?? [];
|
||||||
|
const from = scripts.find((s) => s.uiId === fromUiId);
|
||||||
|
const to = scripts.find((s) => s.uiId === toUiId);
|
||||||
|
if (!from || !to) return false;
|
||||||
|
if (fromUiId === toUiId) return false;
|
||||||
|
if (needsMode(to.needs) === "map") return false;
|
||||||
|
if (wouldCreateCycle(scripts, fromUiId, toUiId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addStepEdge(doc, fromUiId, toUiId) {
|
||||||
|
if (!canConnectSteps(doc, fromUiId, toUiId)) return doc?.scripts ?? [];
|
||||||
|
let scripts = cloneScripts(doc);
|
||||||
|
if (!isDagDoc({ scripts })) {
|
||||||
|
scripts = materializeLinearNeeds(scripts);
|
||||||
|
}
|
||||||
|
const from = scripts.find((s) => s.uiId === fromUiId);
|
||||||
|
const toIndex = scripts.findIndex((s) => s.uiId === toUiId);
|
||||||
|
if (!from || toIndex < 0) return scripts;
|
||||||
|
const to = scripts[toIndex];
|
||||||
|
if (needsMode(to.needs) === "map") return scripts;
|
||||||
|
const used = usedIds(scripts);
|
||||||
|
const source = ensureStepId(from, used);
|
||||||
|
scripts = scripts.map((s) => (s.uiId === source.uiId ? source : s));
|
||||||
|
const current = Array.isArray(to.needs) ? [...to.needs] : [];
|
||||||
|
if (!current.includes(source.id)) current.push(source.id);
|
||||||
|
scripts[toIndex] = { ...to, needs: current, when: "" };
|
||||||
|
return stripWhenFromScripts(scripts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStepEdge(doc, fromUiId, toUiId) {
|
||||||
|
let scripts = cloneScripts(doc);
|
||||||
|
if (!isDagDoc({ scripts })) {
|
||||||
|
scripts = materializeLinearNeeds(scripts);
|
||||||
|
}
|
||||||
|
const from = scripts.find((s) => s.uiId === fromUiId);
|
||||||
|
const toIndex = scripts.findIndex((s) => s.uiId === toUiId);
|
||||||
|
if (!from?.id || toIndex < 0) return scripts;
|
||||||
|
const to = scripts[toIndex];
|
||||||
|
if (needsMode(to.needs) === "map") return scripts;
|
||||||
|
if (!Array.isArray(to.needs)) return scripts;
|
||||||
|
const nextNeeds = to.needs.filter((id) => id !== from.id);
|
||||||
|
scripts[toIndex] = { ...to, needs: nextNeeds.length ? nextNeeds : null };
|
||||||
|
return scripts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enteringDagWouldStripWhen(doc) {
|
||||||
|
return !isDagDoc(doc) && docHasWhen(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ nodes: object[], edges: object[] }}
|
||||||
|
*/
|
||||||
|
export function buildGraphElements(doc, positions = {}) {
|
||||||
|
const scripts = doc?.scripts ?? [];
|
||||||
|
const triggers = doc?.triggers ?? [];
|
||||||
|
const dag = isDagDoc(doc);
|
||||||
|
const byId = new Map();
|
||||||
|
for (const s of scripts) {
|
||||||
|
if (s.id) byId.set(s.id, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto = autoLayout(doc);
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < triggers.length; i++) {
|
||||||
|
const t = triggers[i];
|
||||||
|
const id = triggerNodeId(t.uiId);
|
||||||
|
const layoutKey = layoutKeyForTrigger(i);
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
type: "trigger",
|
||||||
|
position: positions[layoutKey] ?? auto[layoutKey] ?? { x: 0, y: i * 96 },
|
||||||
|
data: {
|
||||||
|
layoutKey,
|
||||||
|
label: triggerSummary(t),
|
||||||
|
triggerType: t.type,
|
||||||
|
uiId: t.uiId,
|
||||||
|
index: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < scripts.length; i++) {
|
||||||
|
const s = scripts[i];
|
||||||
|
const id = stepNodeId(s.uiId);
|
||||||
|
const layoutKey = layoutKeyForStep(s, i);
|
||||||
|
const mode = needsMode(s.needs);
|
||||||
|
const parents = parentIdsFromNeeds(s.needs);
|
||||||
|
const missing = parents.filter((pid) => !byId.has(pid));
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
type: "step",
|
||||||
|
position: positions[layoutKey] ?? auto[layoutKey] ?? { x: 280, y: i * 96 },
|
||||||
|
data: {
|
||||||
|
layoutKey,
|
||||||
|
label: stepLabel(s),
|
||||||
|
kind: s.kind,
|
||||||
|
stepId: s.id || "",
|
||||||
|
when: s.when || "",
|
||||||
|
needsMode: mode,
|
||||||
|
script: s.script || "",
|
||||||
|
profile: s.profile || "",
|
||||||
|
missingNeeds: missing,
|
||||||
|
uiId: s.uiId,
|
||||||
|
index: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
const roots = [];
|
||||||
|
if (dag) {
|
||||||
|
for (const s of scripts) {
|
||||||
|
const parents = parentIdsFromNeeds(s.needs);
|
||||||
|
if (parents.length === 0) roots.push(s.uiId);
|
||||||
|
const mode = needsMode(s.needs);
|
||||||
|
if (mode === "map" && s.needs && typeof s.needs === "object" && !Array.isArray(s.needs)) {
|
||||||
|
for (const [alias, pid] of Object.entries(s.needs)) {
|
||||||
|
if (typeof pid !== "string" || !pid) continue;
|
||||||
|
const from = byId.get(pid);
|
||||||
|
if (!from) continue;
|
||||||
|
edges.push({
|
||||||
|
id: `e:map:${from.uiId}->${s.uiId}:${alias}`,
|
||||||
|
source: stepNodeId(from.uiId),
|
||||||
|
target: stepNodeId(s.uiId),
|
||||||
|
deletable: false,
|
||||||
|
selectable: false,
|
||||||
|
data: { edgeKind: "map", alias },
|
||||||
|
style: { strokeDasharray: "6 4" },
|
||||||
|
label: alias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const pid of parents) {
|
||||||
|
const from = byId.get(pid);
|
||||||
|
if (!from) continue;
|
||||||
|
edges.push({
|
||||||
|
id: `e:step:${from.uiId}->${s.uiId}`,
|
||||||
|
source: stepNodeId(from.uiId),
|
||||||
|
target: stepNodeId(s.uiId),
|
||||||
|
deletable: true,
|
||||||
|
selectable: true,
|
||||||
|
data: { edgeKind: "list" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (scripts[0]) roots.push(scripts[0].uiId);
|
||||||
|
for (let i = 1; i < scripts.length; i++) {
|
||||||
|
const from = scripts[i - 1];
|
||||||
|
const to = scripts[i];
|
||||||
|
edges.push({
|
||||||
|
id: `e:linear:${from.uiId}->${to.uiId}`,
|
||||||
|
source: stepNodeId(from.uiId),
|
||||||
|
target: stepNodeId(to.uiId),
|
||||||
|
deletable: true,
|
||||||
|
data: { edgeKind: "linear" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of triggers) {
|
||||||
|
for (const rid of roots) {
|
||||||
|
edges.push({
|
||||||
|
id: `e:trig:${t.uiId}->${rid}`,
|
||||||
|
source: triggerNodeId(t.uiId),
|
||||||
|
target: stepNodeId(rid),
|
||||||
|
deletable: false,
|
||||||
|
selectable: false,
|
||||||
|
focusable: false,
|
||||||
|
data: { edgeKind: "trigger" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoLayout(doc) {
|
||||||
|
const scripts = doc?.scripts ?? [];
|
||||||
|
const triggers = doc?.triggers ?? [];
|
||||||
|
const dag = isDagDoc(doc);
|
||||||
|
const colW = 240;
|
||||||
|
const rowH = 96;
|
||||||
|
const leftX = 24;
|
||||||
|
const stepX0 = 280;
|
||||||
|
|
||||||
|
/** @type {Record<string, { x: number, y: number }>} */
|
||||||
|
const pos = {};
|
||||||
|
for (let i = 0; i < triggers.length; i++) {
|
||||||
|
pos[layoutKeyForTrigger(i)] = { x: leftX, y: 24 + i * rowH };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {number[]} */
|
||||||
|
const ranks = scripts.map(() => 0);
|
||||||
|
if (dag) {
|
||||||
|
const byId = new Map();
|
||||||
|
scripts.forEach((s, i) => {
|
||||||
|
if (s.id) byId.set(s.id, i);
|
||||||
|
});
|
||||||
|
const visiting = new Set();
|
||||||
|
const seen = new Set();
|
||||||
|
function rankOf(i) {
|
||||||
|
if (seen.has(i)) return ranks[i];
|
||||||
|
if (visiting.has(i)) return 0;
|
||||||
|
visiting.add(i);
|
||||||
|
let r = 0;
|
||||||
|
for (const pid of parentIdsFromNeeds(scripts[i].needs)) {
|
||||||
|
const pi = byId.get(pid);
|
||||||
|
if (pi == null) continue;
|
||||||
|
r = Math.max(r, rankOf(pi) + 1);
|
||||||
|
}
|
||||||
|
visiting.delete(i);
|
||||||
|
seen.add(i);
|
||||||
|
ranks[i] = r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
scripts.forEach((_, i) => rankOf(i));
|
||||||
|
} else {
|
||||||
|
scripts.forEach((_, i) => {
|
||||||
|
ranks[i] = i;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Map<number, number>} */
|
||||||
|
const rowAtRank = new Map();
|
||||||
|
scripts.forEach((s, i) => {
|
||||||
|
const rank = ranks[i];
|
||||||
|
const row = rowAtRank.get(rank) ?? 0;
|
||||||
|
rowAtRank.set(rank, row + 1);
|
||||||
|
pos[layoutKeyForStep(s, i)] = {
|
||||||
|
x: stepX0 + rank * colW,
|
||||||
|
y: 24 + row * rowH,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_PREFIX = "jerapah-flow.workflowGraphLayout:";
|
||||||
|
|
||||||
|
export function workflowGraphLayoutKey(owner, file) {
|
||||||
|
return `${LAYOUT_PREFIX}${owner}/${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readGraphLayout(owner, file) {
|
||||||
|
if (typeof localStorage === "undefined" || !owner || !file) return {};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(workflowGraphLayoutKey(owner, file));
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeGraphLayout(owner, file, positions) {
|
||||||
|
if (typeof localStorage === "undefined" || !owner || !file) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(workflowGraphLayoutKey(owner, file), JSON.stringify(positions));
|
||||||
|
} catch {
|
||||||
|
// quota / private mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGraphLayout(owner, file) {
|
||||||
|
if (typeof localStorage === "undefined" || !owner || !file) return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(workflowGraphLayoutKey(owner, file));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isDagDoc, nextStepId, withAllocatedStepId } from "./workflow-doc.js";
|
||||||
|
import {
|
||||||
|
addStepEdge,
|
||||||
|
canConnectSteps,
|
||||||
|
enteringDagWouldStripWhen,
|
||||||
|
removeStepEdge,
|
||||||
|
wouldCreateCycle,
|
||||||
|
} from "./workflow-graph.js";
|
||||||
|
|
||||||
|
function step(uiId, extra = {}) {
|
||||||
|
return {
|
||||||
|
uiId,
|
||||||
|
kind: "script",
|
||||||
|
script: uiId,
|
||||||
|
profile: "",
|
||||||
|
config: {},
|
||||||
|
id: extra.id ?? "",
|
||||||
|
when: extra.when ?? "",
|
||||||
|
needs: extra.needs ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("nextStepId skips taken ids", () => {
|
||||||
|
assert.equal(nextStepId([]), "step-1");
|
||||||
|
assert.equal(nextStepId(["step-1", "step-2"]), "step-3");
|
||||||
|
assert.equal(nextStepId(["step-1", "step-3"]), "step-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withAllocatedStepId leaves existing id", () => {
|
||||||
|
const s = withAllocatedStepId(step("a", { id: "weather" }), []);
|
||||||
|
assert.equal(s.id, "weather");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("addStepEdge materializes linear chain then adds the extra edge", () => {
|
||||||
|
const doc = { scripts: [step("a"), step("b"), step("c")] };
|
||||||
|
const scripts = addStepEdge(doc, "a", "c");
|
||||||
|
assert.equal(isDagDoc({ scripts }), true);
|
||||||
|
assert.equal(scripts[0].id, "step-1");
|
||||||
|
assert.equal(scripts[1].id, "step-2");
|
||||||
|
assert.equal(scripts[2].id, "step-3");
|
||||||
|
assert.deepEqual(scripts[1].needs, ["step-1"]);
|
||||||
|
assert.deepEqual(scripts[2].needs, ["step-2", "step-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("addStepEdge strips when when entering DAG", () => {
|
||||||
|
const doc = {
|
||||||
|
scripts: [step("a", { when: "true" }), step("b", { when: "false" })],
|
||||||
|
};
|
||||||
|
assert.equal(enteringDagWouldStripWhen(doc), true);
|
||||||
|
const scripts = addStepEdge(doc, "a", "b");
|
||||||
|
assert.equal(scripts[0].when, "");
|
||||||
|
assert.equal(scripts[1].when, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canConnectSteps blocks cycles and map targets", () => {
|
||||||
|
const linear = { scripts: [step("a"), step("b"), step("c")] };
|
||||||
|
assert.equal(canConnectSteps(linear, "a", "c"), true);
|
||||||
|
assert.equal(canConnectSteps(linear, "c", "a"), false);
|
||||||
|
const dag = {
|
||||||
|
scripts: [
|
||||||
|
step("a", { id: "a" }),
|
||||||
|
step("b", { id: "b", needs: ["a"] }),
|
||||||
|
step("c", { id: "c", needs: ["b"] }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(wouldCreateCycle(dag.scripts, "c", "a"), true);
|
||||||
|
assert.equal(canConnectSteps(dag, "c", "a"), false);
|
||||||
|
const mapped = {
|
||||||
|
scripts: [
|
||||||
|
step("a", { id: "a" }),
|
||||||
|
step("b", { id: "b", needs: { current: "a" } }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(canConnectSteps(mapped, "a", "b"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removeStepEdge drops a list dependency", () => {
|
||||||
|
const doc = {
|
||||||
|
scripts: [
|
||||||
|
step("a", { id: "a" }),
|
||||||
|
step("b", { id: "b", needs: ["a"] }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const scripts = removeStepEdge(doc, "a", "b");
|
||||||
|
assert.equal(scripts[1].needs, null);
|
||||||
|
assert.equal(isDagDoc({ scripts }), false);
|
||||||
|
});
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
function parseStep(step) {
|
|
||||||
if (typeof step === "string") {
|
|
||||||
return { kind: "script", script: step, profile: null, id: null, needs: null, when: null };
|
|
||||||
}
|
|
||||||
if (step?.set) {
|
|
||||||
return {
|
|
||||||
kind: "set",
|
|
||||||
script: null,
|
|
||||||
profile: null,
|
|
||||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
|
||||||
needs: step.needs ?? null,
|
|
||||||
when: typeof step.when === "string" && step.when ? step.when : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (step?.script || step?.profile) {
|
|
||||||
return {
|
|
||||||
kind: "script",
|
|
||||||
script: typeof step.script === "string" && step.script ? step.script : null,
|
|
||||||
profile: typeof step.profile === "string" && step.profile ? step.profile : null,
|
|
||||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
|
||||||
needs: step.needs ?? null,
|
|
||||||
when: typeof step.when === "string" && step.when ? step.when : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentIdsFromNeeds(needs) {
|
|
||||||
if (needs == null) return null;
|
|
||||||
if (Array.isArray(needs)) {
|
|
||||||
return needs.filter((x) => typeof x === "string" && x.length > 0);
|
|
||||||
}
|
|
||||||
if (typeof needs === "object") {
|
|
||||||
return Object.values(needs).filter((x) => typeof x === "string" && x.length > 0);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function mermaidLabel(text) {
|
|
||||||
return String(text ?? "")
|
|
||||||
.replace(/"/g, "#quot;")
|
|
||||||
.replace(/[[\]]/g, " ")
|
|
||||||
.trim() || "node";
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerLabel(t) {
|
|
||||||
const type = String(t?.type ?? "").toLowerCase();
|
|
||||||
if (type === "cron") return `cron ${t.schedule ?? ""}`.trim();
|
|
||||||
if (type === "workflow") return "workflow";
|
|
||||||
const method = t?.method ?? "POST";
|
|
||||||
const path = t?.path ?? "";
|
|
||||||
return `${method} ${path}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepLabel(s) {
|
|
||||||
if (s.kind === "set") {
|
|
||||||
const base = s.id ? `${s.id}: set` : "set";
|
|
||||||
return s.when ? `${base} when: ${s.when}` : base;
|
|
||||||
}
|
|
||||||
const target = s.profile ? `profile ${s.profile}` : s.script;
|
|
||||||
const base = s.id ? `${s.id}: ${target}` : target;
|
|
||||||
return s.when ? `${base} when: ${s.when}` : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workflowToFlowchart(parsed) {
|
|
||||||
/** @type {Record<string, string>} */
|
|
||||||
const scriptIds = {};
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
return { chart: "", scriptIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ["flowchart LR"];
|
|
||||||
|
|
||||||
const triggerIds = [];
|
|
||||||
const triggers = Array.isArray(parsed.triggers) ? parsed.triggers : [];
|
|
||||||
triggers.forEach((t, i) => {
|
|
||||||
const id = `t${i}`;
|
|
||||||
lines.push(` ${id}(["${mermaidLabel(triggerLabel(t))}"])`);
|
|
||||||
let outbound = id;
|
|
||||||
if (t?.auth != null && t.auth !== false) {
|
|
||||||
const authId = `${id}auth`;
|
|
||||||
const label =
|
|
||||||
typeof t.auth === "string"
|
|
||||||
? `auth ${t.auth}`
|
|
||||||
: typeof t.auth === "object" && t.auth?.name
|
|
||||||
? `auth ${t.auth.name}`
|
|
||||||
: typeof t.auth === "object" && t.auth?.type
|
|
||||||
? `auth ${t.auth.type}`
|
|
||||||
: "auth";
|
|
||||||
lines.push(` ${authId}(["${mermaidLabel(label)}"])`);
|
|
||||||
lines.push(` ${id} --> ${authId}`);
|
|
||||||
outbound = authId;
|
|
||||||
}
|
|
||||||
triggerIds.push(outbound);
|
|
||||||
});
|
|
||||||
|
|
||||||
const scripts = Array.isArray(parsed.scripts) ? parsed.scripts : [];
|
|
||||||
const parsedSteps = [];
|
|
||||||
scripts.forEach((step, i) => {
|
|
||||||
const parsedStep = parseStep(step);
|
|
||||||
if (!parsedStep) return;
|
|
||||||
parsedSteps.push({ ...parsedStep, mermaidId: `s${i}` });
|
|
||||||
});
|
|
||||||
|
|
||||||
const dagMode = parsedSteps.some((s) => s.needs != null);
|
|
||||||
|
|
||||||
/** @type {Record<string, string>} */
|
|
||||||
const idToMermaid = {};
|
|
||||||
for (const s of parsedSteps) {
|
|
||||||
if (s.id) idToMermaid[s.id] = s.mermaidId;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const s of parsedSteps) {
|
|
||||||
const label = mermaidLabel(stepLabel(s));
|
|
||||||
if (s.kind === "set") {
|
|
||||||
lines.push(` ${s.mermaidId}(["${label}"])`);
|
|
||||||
} else {
|
|
||||||
scriptIds[s.mermaidId] = s.script || (s.profile ? `profile:${s.profile}` : "");
|
|
||||||
lines.push(` ${s.mermaidId}["${label}"]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dagMode) {
|
|
||||||
const roots = [];
|
|
||||||
for (const s of parsedSteps) {
|
|
||||||
const parents = parentIdsFromNeeds(s.needs);
|
|
||||||
if (!parents || parents.length === 0) {
|
|
||||||
roots.push(s.mermaidId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const pid of parents) {
|
|
||||||
const from = idToMermaid[pid];
|
|
||||||
if (from) lines.push(` ${from} --> ${s.mermaidId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const tid of triggerIds) {
|
|
||||||
for (const rid of roots) {
|
|
||||||
lines.push(` ${tid} --> ${rid}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let prev = null;
|
|
||||||
let firstScript = null;
|
|
||||||
for (const s of parsedSteps) {
|
|
||||||
if (!firstScript) firstScript = s.mermaidId;
|
|
||||||
if (prev) lines.push(` ${prev} --> ${s.mermaidId}`);
|
|
||||||
prev = s.mermaidId;
|
|
||||||
}
|
|
||||||
if (firstScript) {
|
|
||||||
for (const tid of triggerIds) {
|
|
||||||
lines.push(` ${tid} --> ${firstScript}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.length === 1) {
|
|
||||||
return { chart: "", scriptIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { chart: lines.join("\n"), scriptIds };
|
|
||||||
}
|
|
||||||
Generated
+76
-699
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user