feat(web): ad-hoc inline steps + import-to-inline in workflow editor
This commit is contained in:
+140
-21
@@ -35,6 +35,14 @@ function DefaultBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
function AdhocBadge() {
|
||||
return (
|
||||
<span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">
|
||||
ad-hoc
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowBuilder() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params.id;
|
||||
@@ -49,8 +57,10 @@ export default function WorkflowBuilder() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importingInline, setImportingInline] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const inlineFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
|
||||
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
|
||||
@@ -140,6 +150,45 @@ export default function WorkflowBuilder() {
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
};
|
||||
|
||||
const appendRef = (ref: WorkflowStepRef) => {
|
||||
setWf({ ...wf, steps: resequence([...sortedSteps, ref]) });
|
||||
};
|
||||
|
||||
const addAdhocStep = () => {
|
||||
appendRef({
|
||||
inline: {
|
||||
step_id: "",
|
||||
name: "New ad-hoc step",
|
||||
description: "",
|
||||
interpreter: "bash",
|
||||
script: "",
|
||||
declared_outputs: [],
|
||||
declared_inputs: [],
|
||||
secret_refs: [],
|
||||
},
|
||||
order: wf.steps.length,
|
||||
on_failure: "stop",
|
||||
max_retries: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const onImportInline = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImportingInline(true);
|
||||
setError(null);
|
||||
try {
|
||||
const doc = JSON.parse(await file.text());
|
||||
const step = await api.parseStep(doc);
|
||||
appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setImportingInline(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const moveStep = (from: number, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
const [item] = next.splice(from, 1);
|
||||
@@ -177,6 +226,12 @@ export default function WorkflowBuilder() {
|
||||
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
|
||||
});
|
||||
|
||||
const updateInline = (idx: number, patch: Partial<WorkflowStep>) =>
|
||||
setWf({
|
||||
...wf,
|
||||
steps: wf.steps.map((r, i) => (i === idx && r.inline ? { ...r, inline: { ...r.inline, ...patch } } : r)),
|
||||
});
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
const remaining = resequence(wf.steps.filter((_, i) => i !== idx));
|
||||
setWf({ ...wf, steps: remaining });
|
||||
@@ -320,6 +375,26 @@ export default function WorkflowBuilder() {
|
||||
Sync defaults
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<input
|
||||
ref={inlineFileInputRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={onImportInline}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={addAdhocStep}>
|
||||
+ Add ad-hoc step
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
loading={importingInline}
|
||||
onClick={() => inlineFileInputRef.current?.click()}
|
||||
>
|
||||
Import ad-hoc
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
className={`${inputClass} mb-3`}
|
||||
placeholder="Search steps…"
|
||||
@@ -367,7 +442,7 @@ export default function WorkflowBuilder() {
|
||||
{sortedSteps.map((ref, i) => {
|
||||
const lib = libById(ref.step_id);
|
||||
const outs = upstreamOutputsFor(i);
|
||||
const script = ref.overrides?.script ?? lib?.script ?? "";
|
||||
const script = ref.inline?.script ?? ref.overrides?.script ?? lib?.script ?? "";
|
||||
const wfIdx = wf.steps.indexOf(ref);
|
||||
const isSelected = selected === i;
|
||||
return (
|
||||
@@ -405,8 +480,10 @@ export default function WorkflowBuilder() {
|
||||
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{lib?.name ?? ref.step_id}</span>
|
||||
{lib && <ShellBadge interpreter={lib.interpreter} />}
|
||||
<span className="text-sm font-medium text-text-primary">{ref.inline?.name ?? lib?.name ?? ref.step_id}</span>
|
||||
{ref.inline && <ShellBadge interpreter={ref.inline.interpreter} />}
|
||||
{lib && !ref.inline && <ShellBadge interpreter={lib.interpreter} />}
|
||||
{ref.inline && <AdhocBadge />}
|
||||
</div>
|
||||
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap rounded border border-border bg-surface-2 p-2 font-mono text-xs text-text-secondary">
|
||||
{script.slice(0, 200)}
|
||||
@@ -439,27 +516,69 @@ export default function WorkflowBuilder() {
|
||||
Step {selected + 1} · Inspector
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLib && <ShellBadge interpreter={selectedLib.interpreter} />}
|
||||
<h2 className="text-sm font-bold text-text-primary">{selectedLib?.name ?? selectedRef.step_id}</h2>
|
||||
{selectedRef.inline && <ShellBadge interpreter={selectedRef.inline.interpreter} />}
|
||||
{selectedLib && !selectedRef.inline && <ShellBadge interpreter={selectedLib.interpreter} />}
|
||||
{selectedRef.inline && <AdhocBadge />}
|
||||
<h2 className="text-sm font-bold text-text-primary">{selectedRef.inline?.name ?? selectedLib?.name ?? selectedRef.step_id}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
overrides: { ...selectedRef.overrides, script: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose
|
||||
it to later steps.
|
||||
</p>
|
||||
</div>
|
||||
{selectedRef.inline ? (
|
||||
<div className="space-y-4 border-b border-border pb-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={selectedRef.inline.name}
|
||||
onChange={(e) => updateInline(selectedIdxInWf, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={selectedRef.inline.interpreter}
|
||||
onChange={(e) =>
|
||||
updateInline(selectedIdxInWf, {
|
||||
interpreter: e.target.value as WorkflowStep["interpreter"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="bash">bash</option>
|
||||
<option value="powershell">powershell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.inline.script}
|
||||
onChange={(e) => updateInline(selectedIdxInWf, { script: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to
|
||||
expose it to later steps. Outputs are derived automatically on save.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
overrides: { ...selectedRef.overrides, script: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose
|
||||
it to later steps.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedLib?.declared_inputs ?? []).length > 0 && (
|
||||
<div className="border-b border-border pb-4">
|
||||
|
||||
Reference in New Issue
Block a user