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:
@@ -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" ? (
|
||||
|
||||
Reference in New Issue
Block a user