feat(web): add groups page with RouteManager and route handler proxies

Implements the Groups & Routes admin page with a client-side RouteManager
component (add/delete sync routes via fetch), server-side groups page that
pre-fetches groups/routes from the API, and Next.js Route Handler proxies
for /api/routes (GET, POST) and /api/routes/[id] (DELETE). Adds a custom
jest environment that polyfills Node 18+ native fetch into the jsdom sandbox
so tests can use jest.spyOn(global, 'fetch').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 01:37:13 +05:30
parent 71e2b0681c
commit 1389a65e18
8 changed files with 300 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const res = await fetch(`${API_URL}/routes/${id}`, { method: 'DELETE' });
return new Response(null, { status: res.status });
}
+19
View File
@@ -0,0 +1,19 @@
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const url = new URL(`${API_URL}/routes`);
searchParams.forEach((v, k) => url.searchParams.set(k, v));
const res = await fetch(url, { cache: 'no-store' });
return Response.json(await res.json(), { status: res.status });
}
export async function POST(req: Request) {
const body = await req.json();
const res = await fetch(`${API_URL}/routes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return Response.json(await res.json(), { status: res.status });
}
+77
View File
@@ -0,0 +1,77 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RouteManager } from './RouteManager';
const groups = [
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp' },
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp' },
];
const routes = [
{
id: 'rt_1',
sourceGroupId: 'grp_1',
targetGroupId: 'grp_2',
sourceGroup: { name: 'Alpha' },
targetGroup: { name: 'Beta' },
},
];
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('RouteManager', () => {
it('renders existing routes with source → target names', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Beta')).toBeInTheDocument();
});
it('renders two group select dropdowns for adding a route', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
expect(screen.getAllByRole('combobox')).toHaveLength(2);
});
it('calls POST /api/routes when Add route is submitted', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<RouteManager groups={groups} initialRoutes={[]} />);
const [sourceSelect, targetSelect] = screen.getAllByRole('combobox');
fireEvent.change(sourceSelect, { target: { value: 'grp_1' } });
fireEvent.change(targetSelect, { target: { value: 'grp_2' } });
fireEvent.click(screen.getByRole('button', { name: /add route/i }));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
'/api/routes',
expect.objectContaining({ method: 'POST' }),
);
});
});
it('shows a delete button for each existing route', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('calls DELETE /api/routes/:id when a route is deleted', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
render(<RouteManager groups={groups} initialRoutes={routes} />);
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
'/api/routes/rt_1',
expect.objectContaining({ method: 'DELETE' }),
);
});
});
});
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
interface Group {
id: string;
name: string;
platform: string;
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
}
export function RouteManager({
groups,
initialRoutes,
}: {
groups: Group[];
initialRoutes: Route[];
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetId, setTargetId] = useState('');
const [busy, setBusy] = useState(false);
async function addRoute() {
if (!sourceId || !targetId) return;
setBusy(true);
try {
const res = await fetch('/api/routes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }),
});
if (!res.ok) return;
const created: Route = await res.json();
setRoutes((prev) => [created, ...prev]);
setSourceId('');
setTargetId('');
} finally {
setBusy(false);
}
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<ul className="flex flex-col gap-2">
{routes.map((route) => (
<li
key={route.id}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3"
>
<span className="text-sm">
<span className="font-medium">{route.sourceGroup.name}</span>
<span className="mx-2 text-gray-400"></span>
<span className="font-medium">{route.targetGroup.name}</span>
</span>
<button
onClick={() => deleteRoute(route.id)}
className="text-xs text-red-500 hover:underline"
aria-label="Delete route"
>
Delete
</button>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add route</h2>
<div className="flex gap-2 items-center flex-wrap">
<select
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Source group"
>
<option value="">Source group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400"></span>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Target group"
>
<option value="">Target group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<button
onClick={addRoute}
disabled={!sourceId || !targetId || busy}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Add route
</button>
</div>
</section>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { RouteManager } from './RouteManager';
interface Group {
id: string;
name: string;
platform: string;
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export default async function GroupsPage() {
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
const [groups, routes] = await Promise.all([
fetchJson<Group[]>(`${apiUrl}/groups`),
fetchJson<Route[]>(`${apiUrl}/routes`),
]);
return (
<div className="max-w-2xl">
<h1 className="text-xl font-semibold mb-6">Groups &amp; Routes</h1>
<RouteManager groups={groups ?? []} initialRoutes={routes ?? []} />
</div>
);
}
+3 -1
View File
@@ -4,6 +4,8 @@ const createJestConfig = nextJest({ dir: './' });
module.exports = createJestConfig({
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jest-environment-jsdom',
// Custom environment that extends jest-environment-jsdom and polyfills the
// native Node 18+ Fetch API so tests can use jest.spyOn(global, 'fetch').
testEnvironment: '<rootDir>/jest.environment.js',
testMatch: ['**/*.test.tsx', '**/*.test.ts'],
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Custom Jest environment that extends jest-environment-jsdom and polyfills
* the Fetch API (fetch, Request, Response, Headers) from Node 18+'s native
* implementation. This lets tests spy on `global.fetch` with jest.spyOn.
*/
const JSDOMEnvironment = require('jest-environment-jsdom').default;
class CustomJSDOMEnvironment extends JSDOMEnvironment {
async setup() {
await super.setup();
// Node 18+ exposes fetch on globalThis; copy it into the jsdom sandbox.
if (typeof globalThis.fetch === 'function') {
this.global.fetch = globalThis.fetch.bind(globalThis);
this.global.Request = globalThis.Request;
this.global.Response = globalThis.Response;
this.global.Headers = globalThis.Headers;
}
}
}
module.exports = CustomJSDOMEnvironment;
+6
View File
@@ -1 +1,7 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';
// Exclude <script>, <style>, and <option> elements from getByText queries.
// <option> elements within <select> dropdowns would otherwise conflict with
// route-name text in the route list when both contain the same group name.
configure({ defaultIgnore: 'script, style, option' });