good forst commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props {
|
||||
current: 'mine' | 'shared' | 'all';
|
||||
counts: { mine: number; shared: number };
|
||||
}
|
||||
|
||||
export function GroupsTabs({ current, counts }: Props) {
|
||||
const tabs: { key: Props['current']; label: string; href: string }[] = [
|
||||
{ key: 'mine', label: `My Groups (${counts.mine})`, href: '/groups' },
|
||||
{ key: 'shared', label: `Shared with me (${counts.shared})`, href: '/groups?tab=shared' },
|
||||
];
|
||||
return (
|
||||
<div className="flex border-b border-gray-200">
|
||||
{tabs.map((t) => (
|
||||
<Link key={t.key} href={t.href}
|
||||
className={`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
current === t.key
|
||||
? 'border-blue-600 text-blue-700 font-medium'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@ 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' },
|
||||
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', isActive: true },
|
||||
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', isActive: true },
|
||||
{ id: 'grp_3', name: 'Gamma', platform: 'whatsapp', isActive: true },
|
||||
];
|
||||
|
||||
const routes = [
|
||||
@@ -27,46 +28,77 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('RouteManager', () => {
|
||||
it('renders existing routes with source → target names', () => {
|
||||
it('renders routes grouped by source group', () => {
|
||||
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', () => {
|
||||
it('shows source dropdown and target checkboxes', () => {
|
||||
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls POST /api/routes when Add route is submitted', async () => {
|
||||
it('shows target checkboxes when a source is selected', () => {
|
||||
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } });
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls POST /api/routes/batch with selected targetIds', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }),
|
||||
JSON.stringify([
|
||||
{ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } },
|
||||
{ id: 'rt_new2', sourceGroupId: 'grp_1', targetGroupId: 'grp_3', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Gamma' } },
|
||||
]),
|
||||
{ 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 }));
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } });
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: /create 2 routes/i }));
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/routes',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'/api/routes/batch',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceGroupId: 'grp_1', targetGroupIds: ['grp_2', 'grp_3'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on 409 conflict', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ message: 'Routes already exist for: Beta' }),
|
||||
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } });
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0]);
|
||||
fireEvent.click(screen.getByRole('button', { name: /create 1 route/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Routes already exist for: Beta')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a delete button for each existing route', () => {
|
||||
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete route to beta/i });
|
||||
expect(deleteBtn).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 }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /delete route to beta/i }));
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/routes/rt_1',
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
@@ -25,23 +26,51 @@ export function RouteManager({
|
||||
}) {
|
||||
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [targetId, setTargetId] = useState('');
|
||||
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function addRoute() {
|
||||
if (!sourceId || !targetId) return;
|
||||
// Group routes by source group name
|
||||
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
|
||||
for (const r of routes) {
|
||||
const key = r.sourceGroup.name;
|
||||
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
|
||||
grouped.get(key)!.targets.push(r);
|
||||
}
|
||||
|
||||
function toggleTarget(id: string) {
|
||||
setTargetIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function addRoutes() {
|
||||
if (!sourceId || targetIds.size === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/routes', {
|
||||
const res = await fetch('/api/routes/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }),
|
||||
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const created: Route = await res.json();
|
||||
setRoutes((prev) => [created, ...prev]);
|
||||
if (res.status === 409) {
|
||||
const errBody = await res.json();
|
||||
setError(errBody.message ?? 'Some routes already exist');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError('Failed to create routes');
|
||||
return;
|
||||
}
|
||||
const created: Route[] = await res.json();
|
||||
setRoutes((prev) => [...created, ...prev]);
|
||||
setSourceId('');
|
||||
setTargetId('');
|
||||
setTargetIds(new Set());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -52,6 +81,8 @@ export function RouteManager({
|
||||
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
|
||||
}
|
||||
|
||||
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section>
|
||||
@@ -59,24 +90,26 @@ export function RouteManager({
|
||||
{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>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => (
|
||||
<li key={sId} className="rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-sm font-semibold text-gray-700 whitespace-nowrap mt-1 min-w-[120px]">{sourceName}</span>
|
||||
<div className="flex flex-col gap-1.5 flex-1">
|
||||
{targets.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between group">
|
||||
<span className="text-sm text-gray-600">{r.targetGroup.name}</span>
|
||||
<button
|
||||
onClick={() => deleteRoute(r.id)}
|
||||
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Delete route to ${r.targetGroup.name}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -84,37 +117,60 @@ export function RouteManager({
|
||||
</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>
|
||||
<h2 className="text-base font-semibold mb-3">Add routes</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
aria-label="Source group"
|
||||
>
|
||||
<option value="">Source group…</option>
|
||||
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sourceId && eligibleTargets.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
|
||||
{eligibleTargets.map((g) => {
|
||||
const checked = targetIds.has(g.id);
|
||||
return (
|
||||
<label
|
||||
key={g.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
|
||||
checked ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTarget(g.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{g.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
<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"
|
||||
onClick={addRoutes}
|
||||
disabled={!sourceId || targetIds.size === 0 || busy}
|
||||
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add route
|
||||
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { RouteManager } from './RouteManager';
|
||||
import { GroupsTabs } from './GroupsTabs';
|
||||
import { apiFetch } from '../_lib/api';
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
platformId: string;
|
||||
isActive: boolean;
|
||||
accountId: string | null;
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
interface SharedGroup extends Group {
|
||||
sharedByTenantName: string;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
@@ -14,27 +24,82 @@ interface Route {
|
||||
targetGroup: { name: string };
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<FetchResult<T>> {
|
||||
let res: Response;
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
res = await apiFetch(path);
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText };
|
||||
}
|
||||
return { ok: true, data: (await res.json()) as T };
|
||||
}
|
||||
|
||||
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`),
|
||||
export default async function GroupsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: 'mine' | 'shared' }>;
|
||||
}) {
|
||||
const { tab: rawTab } = await searchParams;
|
||||
const tab: 'mine' | 'shared' = rawTab === 'shared' ? 'shared' : 'mine';
|
||||
const [groupsR, sharedR, routesR] = await Promise.all([
|
||||
fetchJson<Group[]>('/groups'),
|
||||
tab === 'shared' ? fetchJson<SharedGroup[]>('/groups/shared') : Promise.resolve(null),
|
||||
fetchJson<Route[]>('/routes'),
|
||||
]);
|
||||
|
||||
const groups = groupsR?.ok ? groupsR.data : [];
|
||||
const shared = sharedR && sharedR.ok ? sharedR.data : [];
|
||||
const routes = routesR?.ok ? routesR.data : [];
|
||||
const errors = [groupsR, sharedR, routesR]
|
||||
.filter((r): r is { ok: false; status: number; error: string } => !!r && !r.ok && r.status !== 401)
|
||||
.map((e) => `${e.status === 0 ? 'API unreachable' : `API ${e.status}`}: ${e.error}`);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-xl font-semibold mb-6">Groups & Routes</h1>
|
||||
<RouteManager groups={groups ?? []} initialRoutes={routes ?? []} />
|
||||
{errors.length > 0 && (
|
||||
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<div className="font-medium mb-1">Failed to load some data</div>
|
||||
<ul className="list-disc pl-5 space-y-0.5">
|
||||
{errors.map((e) => <li key={e}>{e}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<GroupsTabs current={tab} counts={{ mine: groups.length, shared: shared.length }} />
|
||||
{tab === 'shared' && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-sm font-medium mb-2">Shared with me</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Groups other tenants have shared with you. You can use them as TARGET groups in your routes.
|
||||
</p>
|
||||
{shared.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">No groups shared with you yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{shared.map((g) => (
|
||||
<li key={g.id} className="border border-gray-200 rounded bg-white px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{g.name}</span>
|
||||
{!g.isActive && <span className="ml-2 text-xs text-red-500 font-medium">(Bot removed)</span>}
|
||||
<span className="text-xs text-gray-400 ml-2">shared by {g.sharedByTenantName}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'mine' && (
|
||||
<div className="mt-4">
|
||||
<RouteManager groups={groups} initialRoutes={routes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user