feat(web): edit-base-step modal with inputs/outputs editor

This commit is contained in:
2026-07-20 14:42:13 +01:00
parent 78194daf5f
commit f22f0a4729
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep, InputParam } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState(step?.name ?? "");
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
const [script, setScript] = useState(step?.script ?? "");
const [outputs, setOutputs] = useState<string[]>(step?.declared_outputs ?? []);
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
const [newOut, setNewOut] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// NOTE: because state is seeded from props, render the modal conditionally
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
const save = async () => {
setBusy(true); setError(null);
try {
const payload: Partial<WorkflowStep> = {
name: name.trim(), description: step?.description ?? "", interpreter, script,
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
secret_refs: step?.secret_refs ?? [],
};
if (step) await api.updateStep(step.step_id, payload);
else await api.createStep(payload);
qc.invalidateQueries({ queryKey: ["steps"] });
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
const del = async () => {
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
setBusy(true); setError(null);
try {
await api.deleteStep(step.step_id);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["workflow"] });
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
return (
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(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>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
<div className="mb-2 flex flex-wrap gap-1">
{outputs.map((o) => (
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
{o}<button onClick={() => setOutputs(outputs.filter((x) => x !== o))}></button>
</span>
))}
</div>
<div className="flex gap-2">
<input className={inputClass} placeholder="OUTPUT_NAME" value={newOut} onChange={(e) => setNewOut(e.target.value)} />
<Button variant="ghost" size="sm" onClick={() => { if (newOut.trim()) { setOutputs([...outputs, newOut.trim()]); setNewOut(""); } }}>Add</Button>
</div>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
<div className="space-y-2">
{inputs.map((inp, i) => (
<div key={i} className="flex gap-2">
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}></Button>
</div>
))}
</div>
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
</div>
<div className="flex items-center justify-between pt-2">
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
</div>
</div>
</div>
</Modal>
);
}