feat(server): enhance workflow and script handling with new features

- Added `workflowLastModifiedAt` function to retrieve the last modified timestamp of workflow YAML files.
- Introduced `encodeBinaryForWire` and `reviveBinaryFromWire` functions for better handling of binary data in JSON.
- Implemented `useDeleteKv` hook for deleting key-value pairs in the web API.
- Enhanced `scriptsPluginFactory` to support dry-run evaluations with JSONata expressions.
- Added `set-dry-run-smoke.js` test to validate dry-run functionality.
- Updated profile configuration to include `overlayFromMerged` for better profile management.
- Introduced try-session management in the web components to handle step execution states.
- Improved various components to support new try-session features and maintain UI consistency.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
2026-08-22 17:52:17 +07:00
parent 72feb39d56
commit beb7e22652
28 changed files with 1753 additions and 143 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export function CodeEditor({ language, value, onChange, height = "50vh", readOnl
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
return (
<div className="overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<div className="nokey overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<Editor
height={height}
language={language}
@@ -28,6 +28,7 @@ import {
parseGraphNodeId,
readGraphLayout,
removeStepEdge,
stepNodeId,
writeGraphLayout,
} from "../../lib/workflow-graph.js";
import { ConfirmDialog } from "../ConfirmDialog.jsx";
@@ -41,6 +42,10 @@ import "@xyflow/react/dist/style.css";
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
/** Two Control zoom-out steps from React Flow's default zoom (1). */
const INITIAL_ZOOM = 1 / 1.2 ** 2;
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
export function GraphTab(props) {
return (
<ReactFlowProvider>
@@ -61,6 +66,10 @@ function GraphTabInner({
excludeFile,
auths = [],
pages = [],
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) {
const { fitView } = useReactFlow();
const [addStepOpen, setAddStepOpen] = useState(false);
@@ -94,7 +103,7 @@ function GraphTabInner({
}, [doc, disabled, positions]);
useEffect(() => {
const t = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 }));
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
return () => cancelAnimationFrame(t);
}, [owner, file, fitView]);
@@ -218,7 +227,7 @@ function GraphTabInner({
function resetLayout() {
clearGraphLayout(owner, file);
persistPositions({});
requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 }));
requestAnimationFrame(() => fitView(FIT_VIEW));
}
const selectedStep = selected?.kind === "step" ? steps.find((s) => s.uiId === selected.uiId) : null;
@@ -285,6 +294,7 @@ function GraphTabInner({
nodesConnectable={!disabled}
nodesDraggable={!disabled}
connectionRadius={80}
defaultViewport={{ x: 0, y: 0, zoom: INITIAL_ZOOM }}
defaultEdgeOptions={{ type: "smoothstep" }}
proOptions={{ hideAttribution: false }}
>
@@ -309,6 +319,20 @@ function GraphTabInner({
excludeFile={excludeFile}
sortable={false}
defaultExpanded
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === selectedStep.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? selectedStep.uiId : null);
}}
onNavigateTry={(targetUiId) => {
const id = stepNodeId(targetUiId);
setSelectedId(id);
setNodes((prev) =>
prev.map((n) => ({ ...n, selected: n.id === id })),
);
onTryFocus?.(targetUiId);
}}
onChange={(next) => {
const copy = [...steps];
copy[selectedStepIndex] = next;
@@ -317,6 +341,7 @@ function GraphTabInner({
onRemove={() => {
patchSteps(steps.filter((_, i) => i !== selectedStepIndex));
setSelectedId(null);
onTryFocus?.(null);
}}
/>
) : selectedTrigger && selectedTriggerIndex >= 0 ? (
@@ -1,19 +1,42 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { LuChevronDown, LuGripVertical, LuMaximize2, LuMinimize2, LuPlay, LuTrash2 } from "react-icons/lu";
import {
LuChevronDown,
LuGripVertical,
LuMaximize2,
LuMinimize2,
LuPlay,
LuTrash2,
} from "react-icons/lu";
import { useDryRunScript, useScript } from "../../api/hooks.js";
import { errorMessage } from "../../api/client.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { JsonTree } from "../JsonViewBlock.jsx";
import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format";
import { contextFromMeta, prettyJson } from "../../lib/script.js";
import { prettyJson } from "../../lib/script.js";
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
import { needsMode } from "../../lib/workflow-doc.js";
import {
stepPredecessors,
stepSuccessors,
} from "../../lib/workflow-graph.js";
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 { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
import {
configHasOverlay,
mergeProfileConfig,
overlayFromMerged,
} from "../../lib/profile.js";
export function ScriptCard(props) {
if (props.sortable === false) {
@@ -44,9 +67,21 @@ function ScriptCardView({
excludeFile,
sortable = true,
defaultExpanded = false,
trySession,
onTrySuccess,
tryOpen: tryOpenProp,
onTryOpenChange,
onNavigateTry,
drag,
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = drag ?? {
attributes: {},
listeners: {},
setNodeRef: undefined,
@@ -59,8 +94,15 @@ function ScriptCardView({
transition,
opacity: isDragging ? 0.6 : 1,
};
const [tryOpen, setTryOpen] = useState(false);
const tryControlled = typeof onTryOpenChange === "function";
const [localTryOpen, setLocalTryOpen] = useState(false);
const tryOpen = tryControlled ? Boolean(tryOpenProp) : localTryOpen;
function setTryOpen(open) {
if (tryControlled) onTryOpenChange(open);
else setLocalTryOpen(open);
}
const [expanded, setExpanded] = useState(defaultExpanded);
const [configMode, setConfigMode] = useState("form");
const profile = step.profile && profilesByName?.get(step.profile);
const scriptName = profile?.script || step.script;
const listed = scriptsByName?.get(scriptName);
@@ -81,6 +123,27 @@ function ScriptCardView({
step.kind === "set" ? { expression: step.expression ?? "" } : null;
const missingProfile = Boolean(step.profile) && !profile;
const neighborOpts = useMemo(() => {
const steps = otherSteps ?? [];
const byUi = new Map(steps.map((s, i) => [s.uiId, { step: s, index: i }]));
function labeled(uiIds) {
return uiIds
.map((uiId) => {
const hit = byUi.get(uiId);
if (!hit) return null;
return {
uiId,
label: stepTryLabel(hit.step, hit.index),
};
})
.filter(Boolean);
}
return {
predecessors: labeled(stepPredecessors(steps, step.uiId)),
successors: labeled(stepSuccessors(steps, step.uiId)),
};
}, [otherSteps, step.uiId]);
return (
<article
ref={setNodeRef}
@@ -104,7 +167,11 @@ function ScriptCardView({
</button>
) : null}
{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}
<button
type="button"
@@ -112,7 +179,10 @@ function ScriptCardView({
onClick={() => setExpanded((v) => !v)}
>
<div className="flex min-w-0 items-center gap-2">
<h3 className="card-title min-w-0 flex-1 text-base font-mono" title={titleFull}>
<h3
className="card-title min-w-0 flex-1 text-base font-mono"
title={titleFull}
>
<span className="min-w-0 truncate">{baseName}</span>
{!expanded && preview ? (
<span
@@ -134,11 +204,15 @@ function ScriptCardView({
: `profile ${step.profile}`
}
>
{overridden ? `profile ${step.profile} + overridden` : `profile ${step.profile}`}
{overridden
? `profile ${step.profile} + overridden`
: `profile ${step.profile}`}
</span>
) : null}
{step.id ? (
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
<span className="badge badge-ghost badge-sm font-mono shrink-0">
{step.id}
</span>
) : null}
</div>
{expanded && step.kind === "script" && missingProfile ? (
@@ -159,10 +233,12 @@ function ScriptCardView({
<SchemaTooltip label="Input" fields={meta?.input} />
) : null}
{!expanded ? (
<ConfigTooltip config={step.kind === "set" ? setConfig : (mergedConfig ?? {})} />
<ConfigTooltip
config={step.kind === "set" ? setConfig : (mergedConfig ?? {})}
/>
) : null}
<div className="card-actions shrink-0">
{step.kind === "script" && scriptName ? (
{step.kind === "set" || (step.kind === "script" && scriptName) ? (
<button
type="button"
className="btn btn-ghost btn-xs"
@@ -190,7 +266,9 @@ function ScriptCardView({
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
>
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} />
<LuChevronDown
className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
</div>
@@ -208,25 +286,64 @@ function ScriptCardView({
className="w-full min-h-24 font-mono text-xs"
value={step.expression ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...step, expression: e.target.value })}
onChange={(e) =>
onChange({ ...step, expression: e.target.value })
}
/>
</div>
) : (
<ConfigFields
script={scriptName}
config={step.config}
inheritedConfig={inheritedConfig}
meta={meta}
disabled={disabled}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
onChange={(config) => onChange({ ...step, config })}
/>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="join">
<button
type="button"
className={`btn btn-xs join-item ${configMode === "form" ? "btn-active" : ""}`}
disabled={disabled}
onClick={() => setConfigMode("form")}
>
Form
</button>
<button
type="button"
className={`btn btn-xs join-item ${configMode === "json" ? "btn-active" : ""}`}
disabled={disabled}
onClick={() => setConfigMode("json")}
>
JSON
</button>
</div>
{step.profile ? (
<span className="text-xs opacity-60">
JSON is the step overlay, not the merged runtime config
</span>
) : null}
</div>
{configMode === "form" ? (
<ConfigFields
script={scriptName}
config={step.config}
inheritedConfig={inheritedConfig}
meta={meta}
disabled={disabled}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
onChange={(config) => onChange({ ...step, config })}
/>
) : (
<ConfigJsonEditor
config={step.config}
disabled={disabled}
onChange={(config) => onChange({ ...step, config })}
/>
)}
</div>
)}
<details className="collapse collapse-arrow bg-base-200">
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">Advanced</summary>
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
Advanced
</summary>
<div className="collapse-content space-y-2">
<label className="form-control">
<span className="label py-0 text-sm">id</span>
@@ -238,7 +355,9 @@ function ScriptCardView({
placeholder="optional step id"
/>
{duplicateId ? (
<span className="text-error text-xs">Duplicate step id</span>
<span className="text-error text-xs">
Duplicate step id
</span>
) : null}
</label>
<NeedsEditor
@@ -253,11 +372,15 @@ function ScriptCardView({
className="w-full font-mono"
value={step.when ?? ""}
disabled={disabled || Boolean(step.needs)}
onChange={(e) => onChange({ ...step, when: e.target.value })}
onChange={(e) =>
onChange({ ...step, when: e.target.value })
}
placeholder="JSONata; skip if false (linear only)"
/>
{step.needs ? (
<span className="text-xs opacity-60">when is not allowed when needs is set</span>
<span className="text-xs opacity-60">
when is not allowed when needs is set
</span>
) : null}
</label>
</div>
@@ -267,10 +390,27 @@ function ScriptCardView({
</div>
{tryOpen ? (
<ScriptTryDialog
script={scriptName}
kind={step.kind === "set" ? "set" : "script"}
script={step.kind === "set" ? "set" : scriptName}
expression={step.expression ?? ""}
config={mergedConfig}
inheritedConfig={inheritedConfig}
meta={meta}
owner={owner}
disabled={disabled}
step={step}
index={index}
otherSteps={otherSteps}
trySession={trySession}
onTrySuccess={onTrySuccess}
predecessors={neighborOpts.predecessors}
successors={neighborOpts.successors}
onNavigateTry={onNavigateTry}
onApplyConfig={
step.kind === "set"
? (expression) => onChange({ ...step, expression })
: (config) => onChange({ ...step, config })
}
onClose={() => setTryOpen(false)}
/>
) : null}
@@ -278,9 +418,60 @@ function ScriptCardView({
);
}
function parseConfigObject(text) {
const parsed = JSON.parse(text || "{}");
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("config must be a JSON object");
}
return parsed;
}
function configsEqual(a, b) {
try {
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
} catch {
return false;
}
}
function ConfigJsonEditor({ config, disabled, onChange }) {
const [draft, setDraft] = useState(() => prettyJson(config ?? {}) || "{}");
const [parseError, setParseError] = useState(null);
function commit(nextText) {
setDraft(nextText);
try {
const parsed = parseConfigObject(nextText);
setParseError(null);
if (!configsEqual(parsed, config ?? {})) {
onChange(parsed);
}
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
}
}
return (
<div className="space-y-1">
{parseError ? <p className="text-error text-xs">{parseError}</p> : null}
<div className="h-48">
<CodeEditor
language="json"
value={draft}
onChange={disabled ? () => {} : commit}
readOnly={disabled}
height="100%"
/>
</div>
</div>
);
}
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
const mode = needsMode(step.needs);
const ids = otherSteps.filter((s) => s.id && s.uiId !== step.uiId).map((s) => s.id);
const ids = otherSteps
.filter((s) => s.id && s.uiId !== step.uiId)
.map((s) => s.id);
function setMode(next) {
if (next === "none") onChange({ ...step, needs: null });
@@ -303,10 +494,13 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
{mode === "list" ? (
<div className="flex flex-wrap gap-2 pt-1">
{ids.length === 0 ? (
<span className="text-xs opacity-60">Give other steps an id first</span>
<span className="text-xs opacity-60">
Give other steps an id first
</span>
) : (
ids.map((id) => {
const checked = Array.isArray(step.needs) && step.needs.includes(id);
const checked =
Array.isArray(step.needs) && step.needs.includes(id);
return (
<label key={id} className="label cursor-pointer gap-1 py-0">
<input
@@ -315,7 +509,9 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
checked={checked}
disabled={disabled}
onChange={(e) => {
const current = Array.isArray(step.needs) ? [...step.needs] : [];
const current = Array.isArray(step.needs)
? [...step.needs]
: [];
onChange({
...step,
needs: e.target.checked
@@ -367,7 +563,9 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
disabled={disabled}
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
>
{from && !ids.includes(from) ? <option value={from}>{from}</option> : null}
{from && !ids.includes(from) ? (
<option value={from}>{from}</option>
) : null}
{ids.map((id) => (
<option key={id} value={id}>
{id}
@@ -409,63 +607,263 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
);
}
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
const existing = useScript(script);
function TryNavCluster({ direction, neighbors, pick, onPick, onGo, disabled }) {
if (!neighbors?.length) return null;
const isPrev = direction === "prev";
if (neighbors.length === 1) {
const only = neighbors[0];
return (
<button
type="button"
className="btn btn-ghost btn-sm max-w-xs truncate"
disabled={disabled}
title={only.label}
onClick={() => onGo(only.uiId)}
>
{isPrev ? `← Prev: ${only.label}` : `Try next: ${only.label}`}
</button>
);
}
const selected =
neighbors.some((n) => n.uiId === pick) ? pick : neighbors[0].uiId;
return (
<div className="join max-w-md">
<FormSelect
className="join-item select-sm max-w-[14rem] font-mono text-xs"
value={selected}
disabled={disabled}
onChange={(e) => onPick(e.target.value)}
aria-label={isPrev ? "Previous step" : "Next step"}
>
{neighbors.map((n) => (
<option key={n.uiId} value={n.uiId}>
{n.label}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-sm join-item"
disabled={disabled || !selected}
onClick={() => onGo(selected)}
>
{isPrev ? "← Go" : "Go →"}
</button>
</div>
);
}
function ScriptTryDialog({
kind = "script",
script,
expression: initialExpression = "",
config,
inheritedConfig,
meta,
owner,
disabled,
step,
index,
otherSteps,
trySession,
onTrySuccess,
predecessors = [],
successors = [],
onNavigateTry,
onApplyConfig,
onClose,
}) {
const isSet = kind === "set";
const existing = useScript(script, !isSet);
const dryRun = useDryRunScript();
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
const defaultData = useMemo(() => prettyJson(defaults.data ?? {}), [defaults]);
const defaultContext = useMemo(() => prettyJson(defaults.context ?? {}), [defaults]);
const [dataJson, setDataJson] = useState(defaultData);
const [contextJson, setContextJson] = useState(defaultContext);
const seed = useMemo(
() =>
seedTryDialog({
step: step ?? { uiId: script },
index: index ?? 0,
steps: otherSteps ?? [],
session: trySession ?? { byStep: {}, lastTriedUiId: null },
meta: isSet ? null : meta,
}),
[step, index, otherSteps, trySession, meta, isSet, script],
);
const [dataJson, setDataJson] = useState(() => prettyJson(seed.data ?? {}) || "{}");
const [contextJson, setContextJson] = useState(
() => prettyJson(seed.context ?? {}) || "{}",
);
const [configJson, setConfigJson] = useState(
() => prettyJson(config ?? {}) || "{}",
);
const [expression, setExpression] = useState(() => initialExpression ?? "");
const [baselineMerged, setBaselineMerged] = useState(() => ({
...(config ?? {}),
}));
const [baselineExpression, setBaselineExpression] = useState(
() => initialExpression ?? "",
);
const [parseError, setParseError] = useState(null);
const [expanded, setExpanded] = useState(false);
const [seedHint] = useState(() => seed.source);
const [sessionResult, setSessionResult] = useState(() => seed.lastResult ?? null);
const [prevPick, setPrevPick] = useState(
() => predecessors[0]?.uiId ?? "",
);
const [nextPick, setNextPick] = useState(
() => successors[0]?.uiId ?? "",
);
useEffect(() => {
setDataJson(defaultData);
setContextJson(defaultContext);
}, [defaultData, defaultContext]);
const resultValue = useMemo(() => {
if (dryRun.data) {
if (dryRun.data.status === "success") {
return {
output: dryRun.data.output,
context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
};
}
return { error: dryRun.data.error };
}
if (sessionResult) {
return {
output: sessionResult.output,
context: sessionResult.context,
};
}
return undefined;
}, [dryRun.data, sessionResult]);
const resultJson = dryRun.data
? prettyJson(
dryRun.data.status === "success"
? {
output: dryRun.data.output,
context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
}
: { error: dryRun.data.error },
)
: "";
function parseTryConfig() {
return parseConfigObject(configJson);
}
const configParse = useMemo(() => {
if (isSet) return { ok: true, value: { expression } };
try {
return { ok: true, value: parseConfigObject(configJson) };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}, [configJson, isSet, expression]);
const configDirty = isSet
? expression !== baselineExpression
: configParse.ok && !configsEqual(configParse.value, baselineMerged);
const canApply =
Boolean(onApplyConfig) && !disabled && configParse.ok && configDirty;
function onRun() {
setParseError(null);
let data;
let context;
let runConfig;
try {
data = JSON.parse(dataJson || "null");
context = JSON.parse(contextJson || "{}");
if (!isSet) runConfig = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
if (context != null && (typeof context !== "object" || Array.isArray(context))) {
if (
context != null &&
(typeof context !== "object" || Array.isArray(context))
) {
setParseError("context must be a JSON object");
return;
}
if (isSet) {
if (!expression.trim()) {
setParseError("expression is required");
return;
}
dryRun.mutate(
{
name: "set",
expression,
data,
context: context ?? {},
config: { expression },
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
return;
}
if (!existing.data?.content) {
setParseError("could not load script source");
return;
}
dryRun.mutate({
name: script,
content: existing.data.content,
data,
context: context ?? {},
config: config ?? {},
owner,
});
dryRun.mutate(
{
name: script,
content: existing.data.content,
data,
context: context ?? {},
config: runConfig,
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
}
function onApply() {
setParseError(null);
if (isSet) {
onApplyConfig?.(expression);
setBaselineExpression(expression);
return;
}
let parsed;
try {
parsed = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
const overlay = overlayFromMerged(inheritedConfig, parsed);
onApplyConfig?.(overlay);
setBaselineMerged(parsed);
}
const runDisabled =
dryRun.isPending ||
(!isSet && (existing.isLoading || !configParse.ok)) ||
(isSet && !expression.trim());
return (
<dialog className="modal modal-open">
<div
@@ -477,10 +875,18 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
>
<div className="flex shrink-0 items-start gap-2">
<div className="min-w-0 flex-1">
<h3 className="font-bold font-mono">{script}</h3>
<h3 className="font-bold font-mono">{isSet ? "set" : script}</h3>
<p className="text-sm opacity-70">
Dry-run this script with the card config. Does not create an event.
{isSet
? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step."
: "Dry-run this script with editable data, context, and config. Does not create an event. Use Apply to card to write config back to the step."}
</p>
{seedHint ? (
<p className="text-xs opacity-60 mt-1">
Seed: {seedHint}
{isSet ? " · sets do not mutate context" : ""}
</p>
) : null}
</div>
<button
type="button"
@@ -489,12 +895,25 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
aria-label={expanded ? "Exit full screen" : "Full screen"}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <LuMinimize2 className="size-4" /> : <LuMaximize2 className="size-4" />}
{expanded ? (
<LuMinimize2 className="size-4" />
) : (
<LuMaximize2 className="size-4" />
)}
</button>
</div>
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</p> : null}
{parseError ? (
<p className="text-error text-sm mt-2 shrink-0">{parseError}</p>
) : null}
{!isSet && !configParse.ok ? (
<p className="text-error text-sm mt-2 shrink-0">
{configParse.error}
</p>
) : null}
{dryRun.isError ? (
<p className="text-error text-sm mt-2 shrink-0">{errorMessage(dryRun.error)}</p>
<p className="text-error text-sm mt-2 shrink-0">
{errorMessage(dryRun.error)}
</p>
) : null}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
@@ -502,47 +921,148 @@ function ScriptTryDialog({ script, config, meta, owner, onClose }) {
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1">data</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
<CodeEditor
language="json"
value={dataJson}
onChange={setDataJson}
height="100%"
/>
</div>
</div>
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1">context</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={contextJson} onChange={setContextJson} height="100%" />
<CodeEditor
language="json"
value={contextJson}
onChange={setContextJson}
height="100%"
/>
</div>
</div>
<div className={`md:col-span-2 ${expanded ? "min-h-0 flex-1 h-full" : "h-48"}`}>
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
result (output + context)
{dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null}
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1">
{isSet ? (
"expression"
) : (
<>
config
{inheritedConfig ? (
<span className="opacity-50">
{" "}
(merged; Apply writes overlay)
</span>
) : null}
</>
)}
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
<CodeEditor language="json" value={resultJson} onChange={() => {}} readOnly height="100%" />
{isSet ? (
<CodeEditor
language="javascript"
value={expression}
onChange={setExpression}
height="100%"
/>
) : (
<CodeEditor
language="json"
value={configJson}
onChange={setConfigJson}
height="100%"
/>
)}
</div>
</div>
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
result (output + context)
{dryRun.data?.status ? (
<StatusBadge status={dryRun.data.status} />
) : sessionResult && !dryRun.data ? (
<span className="badge badge-ghost badge-xs">session</span>
) : null}
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
{resultValue === undefined ? (
<JsonTree
value={{}}
keyName="result"
className="h-full max-h-full"
/>
) : (
<JsonTree
value={resultValue}
keyName="result"
className="h-full max-h-full"
shortenTextAfterLength={undefined}
/>
)}
</div>
</div>
</div>
<details className="collapse collapse-arrow mt-2 border border-base-300 shrink-0">
<summary className="collapse-title min-h-0 py-2 text-sm">config (from card)</summary>
<div className="collapse-content">
<pre className="text-xs overflow-auto">{prettyJson(config ?? {})}</pre>
</div>
</details>
{dryRun.data?.logs?.length ? (
<LogViewer logs={dryRun.data.logs} className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"} />
<LogViewer
logs={dryRun.data.logs}
className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"}
/>
) : null}
<div className="modal-action shrink-0">
<button type="button" className="btn" onClick={onClose}>
Close
</button>
<button
type="button"
className="btn btn-primary"
disabled={dryRun.isPending || existing.isLoading}
onClick={onRun}
>
{dryRun.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
Run
</button>
<div className="modal-action shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<TryNavCluster
direction="prev"
neighbors={predecessors}
pick={prevPick}
onPick={setPrevPick}
onGo={(uiId) => onNavigateTry?.(uiId)}
disabled={!onNavigateTry}
/>
<TryNavCluster
direction="next"
neighbors={successors}
pick={nextPick}
onPick={setNextPick}
onGo={(uiId) => onNavigateTry?.(uiId)}
disabled={!onNavigateTry}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn" onClick={onClose}>
Close
</button>
<button
type="button"
className="btn"
disabled={!canApply}
onClick={onApply}
title={
disabled
? "Card is read-only"
: isSet
? !configDirty
? "No expression changes to apply"
: "Write expression back to the workflow step"
: !configParse.ok
? "Fix config JSON first"
: !configDirty
? "No config changes to apply"
: "Write overlay back to the workflow step"
}
>
Apply to card
</button>
<button
type="button"
className="btn btn-primary"
disabled={runDisabled}
onClick={onRun}
>
{dryRun.isPending ? (
<span className="loading loading-spinner loading-xs" />
) : null}
Run
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
@@ -28,6 +28,10 @@ export function ScriptsTab({
workflows = [],
owner,
excludeFile,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) {
const [addOpen, setAddOpen] = useState(false);
const sensors = useSensors(
@@ -98,12 +102,24 @@ export function ScriptsTab({
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === step.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? step.uiId : null);
}}
onNavigateTry={(targetUiId) => {
onTryFocus?.(targetUiId);
}}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
patchSteps(copy);
}}
onRemove={() => patchSteps(steps.filter((_, i) => i !== index))}
onRemove={() => {
if (tryFocusUiId === step.uiId) onTryFocus?.(null);
patchSteps(steps.filter((_, i) => i !== index));
}}
/>
))}
</div>
@@ -1,6 +1,11 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js";
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
import {
emptyTrySession,
pruneTrySession,
recordTrySuccess,
} from "../../lib/try-session.js";
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
import { FormInput, FormTextarea } from "../FormControls.jsx";
import { GraphTab } from "./GraphTab.jsx";
@@ -23,6 +28,8 @@ export function WorkflowVisualEditor({
const [tab, setTab] = useState("graph");
const [lastEdited, setLastEdited] = useState("yaml");
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
const [trySession, setTrySession] = useState(emptyTrySession);
const [tryFocusUiId, setTryFocusUiId] = useState(/** @type {string | null} */ (null));
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -39,6 +46,38 @@ export function WorkflowVisualEditor({
const scriptCount = displayDoc?.scripts?.length ?? 0;
const triggerCount = displayDoc?.triggers?.length ?? 0;
const unsaved = savedYaml != null && yaml !== savedYaml;
const trySessionCount = Object.keys(trySession.byStep ?? {}).length;
useEffect(() => {
setTrySession((prev) => {
const next = pruneTrySession(prev, displayDoc?.scripts ?? []);
const prevKeys = Object.keys(prev.byStep ?? {});
const nextKeys = Object.keys(next.byStep ?? {});
if (
prevKeys.length === nextKeys.length &&
prevKeys.every((k) => next.byStep[k]) &&
prev.lastTriedUiId === next.lastTriedUiId
) {
return prev;
}
return next;
});
const ids = new Set((displayDoc?.scripts ?? []).map((s) => s.uiId));
setTryFocusUiId((cur) => (cur && !ids.has(cur) ? null : cur));
}, [displayDoc?.scripts]);
function onTrySuccess(uiId, result) {
if (!uiId) return;
setTrySession((prev) => recordTrySuccess(prev, uiId, result));
}
function onTryFocus(uiId) {
setTryFocusUiId(uiId ?? null);
}
function clearTrySession() {
setTrySession(emptyTrySession());
}
function patchDoc(mutator) {
const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -108,6 +147,10 @@ export function WorkflowVisualEditor({
excludeFile: file,
auths,
pages,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
};
return (
@@ -138,6 +181,16 @@ export function WorkflowVisualEditor({
}
/>
{unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null}
{trySessionCount > 0 ? (
<button
type="button"
className="btn btn-ghost btn-xs"
title="Clear Try session (cached step outputs for seeding)"
onClick={clearTrySession}
>
Clear try session ({trySessionCount})
</button>
) : null}
</div>
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
@@ -192,6 +245,10 @@ export function WorkflowVisualEditor({
workflows={workflows}
owner={owner}
excludeFile={file}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryFocusUiId={tryFocusUiId}
onTryFocus={onTryFocus}
/>
) : null}
{tab === "triggers" ? (