feat(web): StepPickerModal step picker component

This commit is contained in:
2026-07-21 11:37:47 +01:00
parent 67d729b360
commit a4c4a72dbc
@@ -0,0 +1,191 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, WorkflowStep } from "@/lib/api";
import { Modal } from "@/components/ui";
type Tab = "all" | "bash" | "powershell" | "adhoc";
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
const isBash = interpreter === "bash";
return (
<span
className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${
isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"
}`}
>
{isBash ? "bash" : "pwsh"}
</span>
);
}
function DefaultBadge() {
return (
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
default
</span>
);
}
function StepCard({ step, onAdd }: { step: WorkflowStep; onAdd: () => void }) {
return (
<button
onClick={onAdd}
className="group relative rounded-[10px] border border-border bg-surface-2 p-3 text-left transition-colors hover:border-signal/55"
>
<span className="absolute right-3 top-3 text-xs font-semibold text-signal opacity-0 group-hover:opacity-100">
+ Add
</span>
<div className="mb-1.5 flex items-center gap-2">
<ShellBadge interpreter={step.interpreter} />
{step.source === "default" && <DefaultBadge />}
<span className="text-sm font-medium text-text-primary">{step.name}</span>
</div>
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{(step.declared_inputs ?? []).map((p) => (
<span key={p.name} className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
in {p.name}
</span>
))}
{(step.declared_outputs ?? []).map((o) => (
<span key={o} className="rounded border border-signal/35 bg-background px-1.5 py-0.5 font-mono text-[10px] text-signal">
out {o}
</span>
))}
</div>
</button>
);
}
export function StepPickerModal({
open,
onClose,
onSelect,
onAddAdhoc,
onImportAdhoc,
}: {
open: boolean;
onClose: () => void;
onSelect: (stepId: string) => void;
onAddAdhoc: () => void;
onImportAdhoc: (file: File) => void;
}) {
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const [search, setSearch] = useState("");
const [tab, setTab] = useState<Tab>("all");
const fileRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return (library ?? []).filter(
(s) =>
(s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q)) &&
(tab === "all" || tab === "adhoc" ? true : s.interpreter === tab),
);
}, [library, search, tab]);
const group = (source: "default" | "shared", interp: "bash" | "powershell") =>
filtered.filter(
(s) => s.interpreter === interp && (source === "default" ? s.source === "default" : s.source !== "default"),
);
const groups: { label: string; steps: WorkflowStep[] }[] = [
{ label: "Default · Bash", steps: group("default", "bash") },
{ label: "Default · PowerShell", steps: group("default", "powershell") },
{ label: "Shared · Bash", steps: group("shared", "bash") },
{ label: "Shared · PowerShell", steps: group("shared", "powershell") },
];
const showLibrary = tab !== "adhoc";
const showAdhocCards = tab === "all" || tab === "adhoc";
return (
<Modal open={open} onClose={onClose} title="Add a step" wide>
<div className="space-y-4">
<input
autoFocus
className="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"
placeholder="Search steps by name or description…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-1.5">
{(["all", "bash", "powershell", "adhoc"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-full border px-3 py-1 text-xs capitalize ${
tab === t
? "border-signal/50 bg-signal/15 text-signal"
: "border-border bg-surface-2 text-text-secondary hover:text-text-primary"
}`}
>
{t === "all" ? "All" : t === "powershell" ? "PowerShell" : t === "adhoc" ? "Ad-hoc" : "Bash"}
</button>
))}
</div>
{showAdhocCards && (
<div className="grid grid-cols-2 gap-2.5">
<button
onClick={onAddAdhoc}
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
>
+ New ad-hoc step
</button>
<button
onClick={() => fileRef.current?.click()}
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
>
Import ad-hoc from file
</button>
<input
ref={fileRef}
type="file"
accept="application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImportAdhoc(f);
e.target.value = "";
}}
/>
</div>
)}
{showLibrary &&
groups.map(
(g) =>
g.steps.length > 0 && (
<div key={g.label}>
<div className="mb-2.5 flex items-center gap-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
{g.label}
<span className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-2 gap-2.5">
{g.steps.map((s) => (
<StepCard key={s.step_id} step={s} onAdd={() => onSelect(s.step_id)} />
))}
</div>
</div>
),
)}
{showLibrary && filtered.length === 0 && (
<p className="text-sm text-text-secondary">No steps match your search.</p>
)}
<p className="text-xs text-text-secondary">
Click a card to append it to the workflow · manage the library on the{" "}
<a href="/steps" className="text-signal hover:underline">
Steps
</a>{" "}
page.
</p>
</div>
</Modal>
);
}