feat(web): replace builder step sidebar with Add-step modal

This commit is contained in:
2026-07-21 11:41:37 +01:00
parent a4c4a72dbc
commit e46d0edbf2
+35 -213
View File
@@ -3,11 +3,11 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api";
import { Button } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
import { StepPickerModal } from "@/components/workflows/StepPickerModal";
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";
@@ -27,14 +27,6 @@ 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>
);
}
function AdhocBadge() {
return (
<span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">
@@ -57,11 +49,9 @@ export default function WorkflowBuilder() {
const params = useParams<{ id: string }>();
const id = params.id;
const router = useRouter();
const queryClient = useQueryClient();
const [wf, setWf] = useState<Workflow | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [, setTick] = useState(0);
@@ -69,16 +59,11 @@ export default function WorkflowBuilder() {
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 [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);
const [editStepOpen, setEditStepOpen] = useState(false);
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const { data: loaded } = useQuery({
queryKey: ["workflow", id],
@@ -212,23 +197,6 @@ export default function WorkflowBuilder() {
});
};
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);
@@ -291,42 +259,6 @@ 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");
const upstreamOutputsFor = (i: number) =>
Array.from(
new Set(
@@ -390,104 +322,19 @@ export default function WorkflowBuilder() {
<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 */}
<aside className="overflow-auto border-r border-border bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-bold uppercase tracking-wide text-text-secondary">Step Library</h2>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingStep(null);
setEditStepOpen(true);
}}
>
+
</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>
<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…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{bashSteps.length > 0 && (
<>
<h3 className="mb-1 mt-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Shared · Bash
</h3>
{bashSteps.map((s) => (
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
setEditingStep(s);
setEditStepOpen(true);
}} />
))}
</>
)}
{pwshSteps.length > 0 && (
<>
<h3 className="mb-1 mt-3 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Shared · PowerShell
</h3>
{pwshSteps.map((s) => (
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
setEditingStep(s);
setEditStepOpen(true);
}} />
))}
</>
)}
{filteredLibrary.length === 0 && <p className="mt-2 text-xs text-text-secondary">No steps found.</p>}
</aside>
<div className="grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]">
{/* CENTER: canvas */}
<main
className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8"
>
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
<button
onClick={() => setPickerOpen(true)}
className="pointer-events-auto inline-flex items-center gap-2 rounded-[9px] bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-[0_6px_20px_rgba(245,165,36,0.28)] hover:bg-signal/90"
>
<span className="text-base leading-none">+</span> Add step
</button>
</div>
<div className="mx-auto flex w-[340px] flex-col items-center">
<DropZone pos={0} />
{sortedSteps.map((ref, i) => {
@@ -548,9 +395,10 @@ export default function WorkflowBuilder() {
<button
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, 0)}
onClick={() => setPickerOpen(true)}
className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
>
+ Drop a step here
+ Add your first step
</button>
)}
</div>
@@ -757,55 +605,29 @@ export default function WorkflowBuilder() {
</div>
<EditWorkflowModal open={editWorkflowOpen} workflow={wf} onSaved={(w) => setWf(w)} onClose={() => setEditWorkflowOpen(false)} />
<EditStepModal
key={editingStep?.step_id ?? "new"}
open={editStepOpen}
step={editingStep}
onClose={() => {
setEditStepOpen(false);
queryClient.invalidateQueries({ queryKey: ["steps"] });
<StepPickerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={(stepId) => insertLibStep(stepId, sortedSteps.length)}
onAddAdhoc={() => {
addAdhocStep();
setPickerOpen(false);
}}
onImportAdhoc={async (file) => {
setPickerOpen(false);
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);
}
}}
/>
</>
);
}
function LibraryCard({ step, onAdd, onEdit }: { step: WorkflowStep; onAdd: () => void; onEdit: () => void }) {
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "lib", stepId: step.step_id }));
}}
onClick={onAdd}
className="group relative mb-2 cursor-grab rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-signal/50"
>
<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="hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Edit step"
>
</button>
</div>
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
</div>
);
}