feat(web): step export/import, sync defaults, auto outputs, default badge

This commit is contained in:
2026-07-21 10:31:28 +01:00
parent b543cd1b3d
commit 90ce7af769
2 changed files with 87 additions and 12 deletions
+79 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -27,6 +27,14 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
);
}
function DefaultBadge() {
return (
<span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-surface-2 text-text-secondary">
default
</span>
);
}
export default function WorkflowBuilder() {
const params = useParams<{ id: string }>();
const id = params.id;
@@ -39,6 +47,10 @@ export default function WorkflowBuilder() {
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
@@ -178,6 +190,38 @@ export default function WorkflowBuilder() {
updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } });
};
const onImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
setError(null);
try {
const doc = JSON.parse(await file.text());
await api.importStep(doc);
await queryClient.invalidateQueries({ queryKey: ["steps"] });
setNotice("Step imported.");
} catch (err) {
setError((err as Error).message);
} finally {
setImporting(false);
e.target.value = "";
}
};
const onSyncDefaults = async () => {
setSyncing(true);
setError(null);
try {
const { created, updated } = await api.seedDefaults();
await queryClient.invalidateQueries({ queryKey: ["steps"] });
setNotice(`${created} created, ${updated} updated`);
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
};
const filteredLibrary = (library ?? []).filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
const bashSteps = filteredLibrary.filter((s) => s.interpreter === "bash");
const pwshSteps = filteredLibrary.filter((s) => s.interpreter === "powershell");
@@ -236,6 +280,9 @@ export default function WorkflowBuilder() {
{error && (
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>
)}
{notice && (
<div className="border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm text-signal">{notice}</div>
)}
<div className="grid h-[calc(100vh-53px)] grid-cols-[264px_1fr_320px]">
{/* LEFT: library */}
@@ -253,6 +300,26 @@ export default function WorkflowBuilder() {
+
</Button>
</div>
<div className="mb-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={onImportFile}
/>
<Button
variant="ghost"
size="sm"
loading={importing}
onClick={() => fileInputRef.current?.click()}
>
Import
</Button>
<Button variant="ghost" size="sm" loading={syncing} onClick={onSyncDefaults}>
Sync defaults
</Button>
</div>
<input
className={`${inputClass} mb-3`}
placeholder="Search steps…"
@@ -542,13 +609,23 @@ function LibraryCard({ step, onAdd, onEdit }: { step: WorkflowStep; onAdd: () =>
<div className="mb-1 flex items-center gap-2">
<span className="text-text-secondary"></span>
<ShellBadge interpreter={step.interpreter} />
{step.source === "default" && <DefaultBadge />}
<span className="text-sm font-medium text-text-primary">{step.name}</span>
<a
href={api.exportStepUrl(step.step_id)}
download
onClick={(e) => e.stopPropagation()}
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Export step"
>
</a>
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
className="hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Edit step"
>
+8 -10
View File
@@ -13,9 +13,7 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
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);
@@ -27,7 +25,7 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
try {
const payload: Partial<WorkflowStep> = {
name: name.trim(), description: step?.description ?? "", interpreter, script,
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
secret_refs: step?.secret_refs ?? [],
};
if (step) await api.updateStep(step.step_id, payload);
@@ -71,17 +69,17 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
</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) => (
<div className="mb-1 flex flex-wrap gap-1">
{(step?.declared_outputs ?? []).length === 0 && (
<p className="text-xs text-text-secondary">No declared outputs.</p>
)}
{(step?.declared_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>
{o}
</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>
<p className="text-xs text-text-secondary">Outputs are detected automatically from lines writing to $WORKFLOW_ENV.</p>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>