feat: restyle the steps table and add 22 default steps
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 2m46s

This commit is contained in:
2026-08-04 17:34:18 +01:00
parent 50a9ac5fdc
commit 85a8865892
23 changed files with 663 additions and 191 deletions
+211 -191
View File
@@ -3,214 +3,234 @@
import { useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep } from "@/lib/api";
import { Button } from "@/components/ui";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
type Tab = "all" | "bash" | "powershell" | "default" | "shared";
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";
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-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
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>
);
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>;
}
export default function StepsPage() {
const qc = useQueryClient();
const { data: steps } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage });
const qc = useQueryClient();
const { data: steps, isLoading, error: loadError } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage });
const [search, setSearch] = useState("");
const [tab, setTab] = useState<Tab>("all");
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<WorkflowStep | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [tab, setTab] = useState<Tab>("all");
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<WorkflowStep | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const rows = useMemo(() => {
const q = search.toLowerCase();
return (steps ?? []).filter((s) => {
const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
const matchesTab =
tab === "all" ||
(tab === "bash" && s.interpreter === "bash") ||
(tab === "powershell" && s.interpreter === "powershell") ||
(tab === "default" && s.source === "default") ||
(tab === "shared" && s.source !== "default");
return matchesText && matchesTab;
});
}, [steps, search, tab]);
const rows = useMemo(() => {
const q = search.toLowerCase();
return (steps ?? []).filter((s) => {
const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
const matchesTab =
tab === "all" ||
(tab === "bash" && s.interpreter === "bash") ||
(tab === "powershell" && s.interpreter === "powershell") ||
(tab === "default" && s.source === "default") ||
(tab === "shared" && s.source !== "default");
return matchesText && matchesTab;
});
}, [steps, search, tab]);
const openNew = () => {
setEditing(null);
setEditOpen(true);
};
const openEdit = (s: WorkflowStep) => {
setEditing(s);
setEditOpen(true);
};
const openNew = () => {
setEditing(null);
setEditOpen(true);
};
const openEdit = (s: WorkflowStep) => {
setEditing(s);
setEditOpen(true);
};
const onImport = 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);
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice("Step imported.");
} catch (err) {
setError((err as Error).message);
} finally {
setImporting(false);
e.target.value = "";
}
};
const onImport = 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);
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice("Step imported.");
} catch (err) {
setError((err as Error).message);
} finally {
setImporting(false);
e.target.value = "";
}
};
const onSync = async () => {
setSyncing(true);
setError(null);
try {
const { created, updated } = await api.seedDefaults();
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice(`${created} created, ${updated} updated`);
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
};
const onSync = async () => {
setSyncing(true);
setError(null);
try {
const { created, updated } = await api.seedDefaults();
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice(`${created} created, ${updated} updated`);
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
};
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex items-center gap-3">
<div>
<h1 className="text-xl font-semibold text-text-primary">Steps</h1>
<p className="text-sm text-text-secondary">Reusable steps shared across all workflows.</p>
</div>
<div className="ml-auto flex items-center gap-2">
<input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={onImport} />
<Button variant="secondary" size="sm" loading={syncing} onClick={onSync}>
Sync defaults
</Button>
<Button variant="secondary" size="sm" loading={importing} onClick={() => fileRef.current?.click()}>
Import
</Button>
<Button size="sm" onClick={openNew}>
+ New step
</Button>
</div>
</div>
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Steps</h1>
<p className="mt-1 text-sm text-text-secondary">
{steps?.length ?? 0} step{steps?.length !== 1 ? "s" : ""} · reusable across all workflows
</p>
</div>
<div className="flex items-center gap-2">
<input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={onImport} />
<Button variant="ghost" size="sm" loading={syncing} onClick={onSync}>
Sync defaults
</Button>
<Button variant="ghost" size="sm" loading={importing} onClick={() => fileRef.current?.click()}>
Import
</Button>
<Button variant="primary" size="sm" onClick={openNew}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New step
</Button>
</div>
</div>
{error && <div className="mb-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="mb-4 rounded border border-signal/30 bg-signal/10 px-3 py-2 text-sm text-signal">{notice}</div>}
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="mb-4 rounded-lg border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent">{notice}</div>}
<div className="mb-4 flex items-center gap-2">
<input className={`${inputClass} max-w-sm`} placeholder="Search steps…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-1.5">
{(["all", "bash", "powershell", "default", "shared"] 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 === "powershell" ? "PowerShell" : t}
</button>
))}
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-text-secondary">
<th className="px-4 py-2.5 font-bold">Name</th>
<th className="px-4 py-2.5 font-bold">Shell</th>
<th className="px-4 py-2.5 font-bold">Source</th>
<th className="px-4 py-2.5 font-bold">Outputs</th>
<th className="px-4 py-2.5 font-bold">Used by</th>
<th className="px-4 py-2.5 text-right font-bold">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const count = usage?.[s.step_id] ?? 0;
return (
<tr key={s.step_id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-text-primary">{s.name}</div>
{s.description && <div className="text-xs text-text-secondary">{s.description}</div>}
</td>
<td className="px-4 py-3">
<ShellBadge interpreter={s.interpreter} />
</td>
<td className="px-4 py-3">
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
{s.source === "default" ? "default" : "shared"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(s.declared_outputs ?? []).map((o) => (
<span key={o} className="rounded border border-signal/35 px-1.5 py-0.5 font-mono text-[10px] text-signal">
{o}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-text-secondary">
{count === 0 ? "0" : `${count} workflow${count === 1 ? "" : "s"}`}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-3 text-text-secondary">
<button onClick={() => openEdit(s)} className="hover:text-text-primary">
{s.source === "default" ? "View" : "Edit"}
</button>
<a href={api.exportStepUrl(s.step_id)} download className="hover:text-text-primary">
Export
</a>
{s.source !== "default" && (
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input className={`${inputClass} sm:max-w-sm`} placeholder="Search steps…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex flex-wrap gap-1.5">
{(["all", "bash", "powershell", "default", "shared"] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`rounded-full border px-3 py-1 text-xs capitalize transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
tab === t ? "border-accent/50 bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t === "powershell" ? "PowerShell" : t}
</button>
)}
</div>
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-text-secondary">
No steps found.
</td>
</tr>
)}
</tbody>
</table>
</div>
))}
</div>
</div>
<EditStepModal
key={editing?.step_id ?? "new"}
open={editOpen}
step={editing}
onClose={() => {
setEditOpen(false);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["step-usage"] });
}}
/>
</div>
);
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : loadError ? (
<div className="py-20 text-center text-danger">Failed to load steps. Is the backend running?</div>
) : rows.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Shell</Th>
<Th>Source</Th>
<Th>Outputs</Th>
<Th>Used by</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{rows.map((s) => {
const count = usage?.[s.step_id] ?? 0;
return (
<Tr key={s.step_id}>
<Td label="Name">
<span>
<span className="block font-medium text-text-primary">{s.name}</span>
{s.description && <span className="mt-0.5 block text-xs text-text-secondary">{s.description}</span>}
</span>
</Td>
<Td label="Shell">
<ShellBadge interpreter={s.interpreter} />
</Td>
<Td label="Source">
<span className="rounded-sm border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
{s.source === "default" ? "default" : "shared"}
</span>
</Td>
<Td label="Outputs">
<span className="flex flex-wrap gap-1">
{(s.declared_outputs ?? []).length === 0 ? (
<span className="text-text-tertiary"></span>
) : (
(s.declared_outputs ?? []).map((o) => (
<span key={o} className="rounded-sm border border-signal/35 px-1.5 py-0.5 font-mono text-[10px] text-signal">
{o}
</span>
))
)}
</span>
</Td>
<Td label="Used by">
<span className={count === 0 ? "text-text-tertiary" : "text-text-secondary"}>{count === 0 ? "unused" : `${count} workflow${count === 1 ? "" : "s"}`}</span>
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(s)}>
{s.source === "default" ? "View" : "Edit"}
</Button>
<a href={api.exportStepUrl(s.step_id)} download>
<Button variant="ghost" size="sm">
Export
</Button>
</a>
</div>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">{steps && steps.length > 0 ? "No steps match that filter." : "No steps yet."}</p>
{(!steps || steps.length === 0) && (
<Button variant="primary" size="sm" className="mt-4" onClick={openNew}>
Create your first step
</Button>
)}
</div>
)}
</Card>
<EditStepModal
key={editing?.step_id ?? "new"}
open={editOpen}
step={editing}
onClose={() => {
setEditOpen(false);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["step-usage"] });
}}
/>
</div>
);
}