feat(frontend): connections page — schema-driven create + list + delete

This commit is contained in:
Claude
2026-08-28 16:17:56 +00:00
parent 03cb34d009
commit c9193dbf36
2 changed files with 164 additions and 1 deletions

View File

@@ -0,0 +1,52 @@
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 ConnectionsPage from "./ConnectionsPage";
const providers = [{
id: "nats", displayName: "NATS",
configSchema: {
type: "object",
required: ["url"],
properties: {
url: { type: "string", title: "Server URL" },
pass: { type: "string", format: "password", title: "Password" },
},
},
}];
describe("ConnectionsPage", () => {
it("lists existing connections", async () => {
mswServer.use(
http.get("/api/connection-providers", () => HttpResponse.json(providers)),
http.get("/api/connections", () =>
HttpResponse.json([{ id: "c1", providerId: "nats", name: "local", config: { url: "nats://x" } }])),
);
renderWithProviders(<ConnectionsPage />);
await waitFor(() => expect(screen.getByText("local")).toBeInTheDocument());
expect(screen.getByText(/nats/i)).toBeInTheDocument();
});
it("creates a new connection using the provider's schema", async () => {
let created: unknown = null;
mswServer.use(
http.get("/api/connection-providers", () => HttpResponse.json(providers)),
http.get("/api/connections", () =>
HttpResponse.json(created ? [created] : [])),
http.post("/api/connections", async ({ request }) => {
created = { id: "c1", ...(await request.json() as object) };
return HttpResponse.json(created, { status: 201 });
}),
);
renderWithProviders(<ConnectionsPage />);
await userEvent.click(await screen.findByRole("button", { name: /new connection/i }));
await userEvent.selectOptions(screen.getByLabelText(/provider/i), "nats");
await userEvent.type(screen.getByLabelText(/^name$/i), "prod");
await userEvent.type(screen.getByLabelText(/server url/i), "nats://prod");
await userEvent.type(screen.getByLabelText(/password/i), "s3cret");
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
await waitFor(() => expect(created).toMatchObject({ providerId: "nats", name: "prod" }));
});
});

View File

@@ -1,3 +1,114 @@
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useConnections, useInvalidate, useProviders } from "@/api/hooks";
import { SchemaForm } from "@/forms/SchemaForm";
import type { ConnectionDto } from "@/api/types";
export default function ConnectionsPage() {
return <div className="p-8">Connections stub — replaced Task 11.</div>;
const providers = useProviders();
const conns = useConnections();
const invalidate = useInvalidate();
const [creating, setCreating] = useState(false);
const create = useApiMutation(
(body: { providerId: string; name: string; config: Record<string, unknown> }) =>
apiFetch<ConnectionDto>("/api/connections", { method: "POST", body }),
{ onSuccess: () => { invalidate(qk.connections); setCreating(false); } },
);
const remove = useApiMutation(
(id: string) => apiFetch<void>(`/api/connections/${id}`, { method: "DELETE" }),
{ onSuccess: () => invalidate(qk.connections) },
);
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Connections</h1>
<button
onClick={() => setCreating(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 connection
</button>
</div>
{creating && providers.data && (
<CreateForm
providers={providers.data}
onCreate={(body) => create.mutate(body)}
onCancel={() => setCreating(false)}
submitting={create.isPending}
/>
)}
<ul className="divide-y divide-slate-800 border border-slate-800 rounded overflow-hidden">
{conns.data?.map((c) => (
<li key={c.id} className="flex items-center gap-3 p-3 hover:bg-slate-900">
<span className="flex-1">{c.name}</span>
<span className="text-xs text-slate-400">{c.providerId}</span>
<button
onClick={() => confirm(`Delete "${c.name}"?`) && remove.mutate(c.id)}
className="text-slate-400 hover:text-red-400"
aria-label="Delete"
>
<Trash2 size={16} />
</button>
</li>
))}
{conns.data && conns.data.length === 0 && (
<li className="p-6 text-slate-500 text-sm">No connections yet.</li>
)}
</ul>
</div>
);
}
function CreateForm({
providers, onCreate, onCancel, submitting,
}: {
providers: { id: string; displayName: string; configSchema: import("@/api/types").JsonSchemaObject }[];
onCreate: (b: { providerId: string; name: string; config: Record<string, unknown> }) => void;
onCancel: () => void;
submitting: boolean;
}) {
const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
const [name, setName] = useState("");
const [config, setConfig] = useState<Record<string, unknown>>({});
const provider = providers.find((p) => p.id === providerId);
return (
<form
className="bg-slate-900 border border-slate-800 rounded p-4 space-y-3"
onSubmit={(e) => { e.preventDefault(); if (providerId && name.trim()) onCreate({ providerId, name: name.trim(), config }); }}
>
<label className="block text-sm space-y-1">
<span className="text-slate-300">Provider</span>
<select
value={providerId}
onChange={(e) => { setProviderId(e.target.value); setConfig({}); }}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
>
{providers.map((p) => (<option key={p.id} value={p.id}>{p.displayName}</option>))}
</select>
</label>
<label className="block text-sm space-y-1">
<span className="text-slate-300">Name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
/>
</label>
{provider && <SchemaForm schema={provider.configSchema} value={config} onChange={setConfig} />}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={submitting}
className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm"
>
Create
</button>
<button type="button" onClick={onCancel} className="text-sm text-slate-400">Cancel</button>
</div>
</form>
);
}