feat(frontend): workflows list — create/activate/deactivate/delete
This commit is contained in:
@@ -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>
|
||||
|
||||
3
frontend/src/pages/EditorPage.tsx
Normal file
3
frontend/src/pages/EditorPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function EditorPage() {
|
||||
return <div className="p-8">Editor stub — replaced Task 12.</div>;
|
||||
}
|
||||
46
frontend/src/pages/WorkflowsPage.test.tsx
Normal file
46
frontend/src/pages/WorkflowsPage.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user