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 { 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 { prettyJson } from "../../lib/script.js"; import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js"; import { needsMode, stepCustomName, stepDisplayName } 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 { ScriptIcon } from "../ScriptIcon.jsx"; import { configHasOverlay, mergeProfileConfig, overlayFromMerged, } from "../../lib/profile.js"; export function ScriptCard(props) { if (props.sortable === false) { return ; } return ; } function ScriptCardSortable(props) { const drag = useSortable({ id: props.step.uiId, disabled: props.disabled, }); return ; } function ScriptCardView({ step, index, otherSteps, scriptsByName, profilesByName, onChange, onRemove, disabled, workflows, owner, excludeFile, sortable = true, defaultExpanded = false, trySession, onTrySuccess, tryOpen: tryOpenProp, onTryOpenChange, onNavigateTry, drag, }) { 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 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); const meta = listed?.meta ?? null; const inheritedConfig = profile?.config ?? null; const mergedConfig = profile ? mergeProfileConfig(profile.config, step.config) : step.config; const overridden = Boolean(step.profile && configHasOverlay(step.config)); const duplicateId = step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId); const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey); const previewFull = configValueText(mergedConfig, meta?.previewConfigKey); const scriptFallback = step.kind === "set" ? "set" : scriptName || "untitled"; const customName = stepCustomName(step); const baseName = stepDisplayName(step, scriptFallback); const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName; const setConfig = 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 (
{sortable !== false ? ( ) : null} {step.kind === "script" && scriptName ? ( ) : null} {step.kind === "script" ? ( ) : null} {!expanded ? ( ) : null}
{step.kind === "set" || (step.kind === "script" && scriptName) ? ( ) : null}
{expanded ? ( <> {step.kind === "set" ? (
onChange({ ...step, expression: e.target.value }) } />
) : (
{step.profile ? ( JSON is the step overlay, not the merged runtime config ) : null}
{configMode === "form" ? ( onChange({ ...step, config })} /> ) : ( onChange({ ...step, config })} /> )}
)}
Advanced
) : null}
{tryOpen ? ( onChange({ ...step, expression }) : (config) => onChange({ ...step, config }) } onClose={() => setTryOpen(false)} /> ) : null}
); } 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 (
{parseError ?

{parseError}

: null}
{} : commit} readOnly={disabled} height="100%" />
); } function NeedsEditor({ step, otherSteps, disabled, onChange }) { const mode = needsMode(step.needs); const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId); const ids = others.map((s) => s.id); const nameById = new Map( others.map((s) => [s.id, stepCustomName(s) || null]), ); function setMode(next) { if (next === "none") onChange({ ...step, needs: null }); else if (next === "list") onChange({ ...step, needs: [], when: "" }); else onChange({ ...step, needs: {}, when: "" }); } function idLabel(id) { const named = nameById.get(id); return named ? `${id} · ${named}` : id; } return (
needs setMode(e.target.value)} > {mode === "list" ? (
{ids.length === 0 ? ( Give other steps an id first ) : ( ids.map((id) => { const checked = Array.isArray(step.needs) && step.needs.includes(id); return ( ); }) )}
) : null} {mode === "map" ? ( onChange({ ...step, needs })} /> ) : null}
); } function NeedsMap({ needs, ids, idLabel, disabled, onChange }) { const entries = Object.entries(needs ?? {}); return (
{entries.map(([alias, from]) => (
{ const next = {}; for (const [k, v] of Object.entries(needs)) { next[k === alias ? e.target.value : k] = v; } onChange(next); }} /> onChange({ ...needs, [alias]: e.target.value })} > {from && !ids.includes(from) ? ( ) : null} {ids.map((id) => ( ))}
))}
); } 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 ( ); } const selected = neighbors.some((n) => n.uiId === pick) ? pick : neighbors[0].uiId; return (
onPick(e.target.value)} aria-label={isPrev ? "Previous step" : "Next step"} > {neighbors.map((n) => ( ))}
); } 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 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 ?? "", ); 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]); 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)) ) { 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: 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 (
{!isSet && script ? ( ) : null}

{stepDisplayName(step, isSet ? "set" : script)}

{stepCustomName(step) && script ? (

{isSet ? "set" : script}

) : null}

{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."}

{seedHint ? (

Seed: {seedHint} {isSet ? " · sets do not mutate context" : ""}

) : null}
{parseError ? (

{parseError}

) : null} {!isSet && !configParse.ok ? (

{configParse.error}

) : null} {dryRun.isError ? (

{errorMessage(dryRun.error)}

) : null}

data

context

{isSet ? ( "expression" ) : ( <> config {inheritedConfig ? ( {" "} (merged; Apply writes overlay) ) : null} )}

{isSet ? ( ) : ( )}

result (output + context) {dryRun.data?.status ? ( ) : sessionResult && !dryRun.data ? ( session ) : null}

{resultValue === undefined ? ( ) : ( )}
{dryRun.data?.logs?.length ? ( ) : null}
onNavigateTry?.(uiId)} disabled={!onNavigateTry} /> onNavigateTry?.(uiId)} disabled={!onNavigateTry} />
); }