feat(frontend): workflows list — create/activate/deactivate/delete

This commit is contained in:
Claude
2026-08-28 16:14:09 +00:00
parent 1f317564f5
commit 942d001c1f
4 changed files with 139 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { PrivateRoute } from "./auth/PrivateRoute";
import { AppShell } from "./layout/AppShell";
import LoginPage from "./pages/LoginPage";
import WorkflowsPage from "./pages/WorkflowsPage";
import EditorPage from "./pages/EditorPage";
import ConnectionsPage from "./pages/ConnectionsPage";
export default function App() {
@@ -14,6 +15,7 @@ export default function App() {
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/workflows" replace />} />
<Route path="/workflows" element={<WorkflowsPage />} />
<Route path="/workflows/:id" element={<EditorPage />} />
<Route path="/connections" element={<ConnectionsPage />} />
</Route>
</Route>

View File

@@ -0,0 +1,3 @@
export default function EditorPage() {
return <div className="p-8">Editor stub — replaced Task 12.</div>;
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "@/testing/renderWithProviders";
import { mswServer, http, HttpResponse } from "@/testing/msw";
import WorkflowsPage from "./WorkflowsPage";
const emptyGraph = { nodes: [], edges: [] };
describe("WorkflowsPage", () => {
it("renders the workflows list from the API", async () => {
mswServer.use(
http.get("/api/workflows", () =>
HttpResponse.json([
{ id: "a", name: "Alpha", active: true, graph: emptyGraph, createdAt: "2026-08-30T00:00:00Z", updatedAt: "2026-08-30T00:00:00Z" },
{ id: "b", name: "Beta", active: false, graph: emptyGraph, createdAt: "2026-08-30T00:00:00Z", updatedAt: "2026-08-30T00:00:00Z" },
])),
);
renderWithProviders(<WorkflowsPage />);
await waitFor(() => expect(screen.getByText("Alpha")).toBeInTheDocument());
expect(screen.getByText("Beta")).toBeInTheDocument();
});
it("creates a new workflow via POST and refetches the list", async () => {
let posted = false;
let workflows: unknown[] = [];
mswServer.use(
http.get("/api/workflows", () => HttpResponse.json(workflows)),
http.post("/api/workflows", async ({ request }) => {
const body = await request.json() as { name: string };
posted = true;
workflows = [{
id: "new", name: body.name, active: false, graph: emptyGraph,
createdAt: "x", updatedAt: "x",
}];
return HttpResponse.json(workflows[0], { status: 201 });
}),
);
renderWithProviders(<WorkflowsPage />);
await userEvent.click(await screen.findByRole("button", { name: /new workflow/i }));
await userEvent.type(screen.getByPlaceholderText(/name/i), "Gamma");
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
await waitFor(() => expect(posted).toBe(true));
await waitFor(() => expect(screen.getByText("Gamma")).toBeInTheDocument());
});
});

View File

@@ -1,3 +1,90 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Play, Pause, Plus, Trash2 } from "lucide-react";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useInvalidate, useWorkflows } from "@/api/hooks";
import type { WorkflowDto } from "@/api/types";
const emptyGraph = { nodes: [], edges: [] };
export default function WorkflowsPage() {
return <div className="p-8">Workflows stub — replaced Task 9.</div>;
const q = useWorkflows();
const invalidate = useInvalidate();
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState("");
const create = useApiMutation(
(n: string) =>
apiFetch<WorkflowDto>("/api/workflows", { method: "POST", body: { name: n, graph: emptyGraph } }),
{ onSuccess: () => { invalidate(qk.workflows); setShowCreate(false); setName(""); } },
);
const remove = useApiMutation(
(id: string) => apiFetch<void>(`/api/workflows/${id}`, { method: "DELETE" }),
{ onSuccess: () => invalidate(qk.workflows) },
);
const activate = useApiMutation(
(id: string) => apiFetch<void>(`/api/workflows/${id}/activate`, { method: "POST" }),
{ onSuccess: () => invalidate(qk.workflows) },
);
const deactivate = useApiMutation(
(id: string) => apiFetch<void>(`/api/workflows/${id}/deactivate`, { method: "POST" }),
{ onSuccess: () => invalidate(qk.workflows) },
);
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Workflows</h1>
<button
onClick={() => setShowCreate(true)}
className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm flex items-center gap-2"
>
<Plus size={14} /> New workflow
</button>
</div>
{showCreate && (
<form
onSubmit={(e) => { e.preventDefault(); if (name.trim()) create.mutate(name.trim()); }}
className="flex gap-2 items-center bg-slate-900 border border-slate-800 rounded p-3"
>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
autoFocus
className="flex-1 rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
/>
<button type="submit" className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm">Create</button>
<button type="button" onClick={() => setShowCreate(false)} className="text-sm text-slate-400">Cancel</button>
</form>
)}
<ul className="divide-y divide-slate-800 border border-slate-800 rounded overflow-hidden">
{q.data?.map((w) => (
<li key={w.id} className="flex items-center gap-3 p-3 hover:bg-slate-900">
<Link to={`/workflows/${w.id}`} className="flex-1 text-slate-200">{w.name}</Link>
<span className={`text-xs px-2 py-1 rounded ${w.active ? "bg-emerald-700/40 text-emerald-300" : "bg-slate-800 text-slate-400"}`}>
{w.active ? "active" : "inactive"}
</span>
<button
onClick={() => (w.active ? deactivate.mutate(w.id) : activate.mutate(w.id))}
className="text-slate-300 hover:text-white"
aria-label={w.active ? "Deactivate" : "Activate"}
>
{w.active ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
onClick={() => confirm(`Delete "${w.name}"?`) && remove.mutate(w.id)}
className="text-slate-400 hover:text-red-400"
aria-label="Delete"
>
<Trash2 size={16} />
</button>
</li>
))}
{q.data && q.data.length === 0 && (
<li className="p-6 text-slate-500 text-sm">No workflows yet — click "New workflow".</li>
)}
</ul>
</div>
);
}