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:
@@ -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 (
|
||||
<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 (
|
||||
<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>
|
||||
<div className="collapse-content">
|
||||
<div className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 text-xs">
|
||||
<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>
|
||||
<JsonTree value={value} keyName={title} className="max-h-80" />
|
||||
</div>
|
||||
</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 { 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 <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,
|
||||
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({
|
||||
>
|
||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag step ${index + 1}`}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
{sortable !== false ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag step ${index + 1}`}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
) : null}
|
||||
{step.kind === "script" && scriptName ? (
|
||||
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||
) : 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();
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 <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,
|
||||
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({
|
||||
>
|
||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag trigger ${index + 1}`}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
{sortable !== false ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag trigger ${index + 1}`}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
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 { useRunWorkflow } from "../../api/hooks.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { JsonTree } from "../JsonViewBlock.jsx";
|
||||
import { StatusBadge } from "../../lib/format";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import {
|
||||
@@ -50,6 +51,7 @@ export function WorkflowTestPanel({
|
||||
const [last, setLast] = useState(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [inputView, setInputView] = useState("json");
|
||||
const saveTimer = useRef(null);
|
||||
const pollAbort = useRef(null);
|
||||
|
||||
@@ -146,9 +148,15 @@ export function WorkflowTestPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const resultJson = last
|
||||
? prettyJson(last.error ? { error: last.error } : last.result)
|
||||
: "";
|
||||
const resultValue = last ? (last.error ? { error: last.error } : last.result) : undefined;
|
||||
|
||||
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;
|
||||
|
||||
@@ -206,9 +214,31 @@ export function WorkflowTestPanel({
|
||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||
data
|
||||
<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>
|
||||
<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 className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||
@@ -222,13 +252,11 @@ export function WorkflowTestPanel({
|
||||
) : null}
|
||||
</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={resultJson}
|
||||
onChange={() => {}}
|
||||
readOnly
|
||||
height="100%"
|
||||
/>
|
||||
{resultValue === undefined ? (
|
||||
<p className="text-xs opacity-50 p-2">Run the workflow to see a result tree.</p>
|
||||
) : (
|
||||
<JsonTree value={resultValue} keyName="result" className="h-full max-h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<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 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
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -179,6 +181,7 @@ export function WorkflowVisualEditor({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tab === "graph" ? <GraphTab {...graphProps} /> : null}
|
||||
{tab === "scripts" ? (
|
||||
<ScriptsTab
|
||||
doc={displayDoc}
|
||||
@@ -204,13 +207,7 @@ export function WorkflowVisualEditor({
|
||||
/>
|
||||
) : null}
|
||||
{tab === "yaml" ? (
|
||||
<YamlTab
|
||||
content={yaml}
|
||||
onChange={onYamlTabChange}
|
||||
parseError={parseError}
|
||||
mermaid={mermaid}
|
||||
parsed={parsed}
|
||||
/>
|
||||
<YamlTab content={yaml} onChange={onYamlTabChange} parseError={parseError} />
|
||||
) : null}
|
||||
|
||||
{showTest && owner && file ? (
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
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 (
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
||||
<div className="min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
{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%" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user