test(admin): Activities + app/nav — fill gaps + lifecycle test
Fill coverage gaps and lock in behavior for the Activities screen and app/nav (Sidebar) domain (plan task B-D6). All four domain src files (screens/Activities.tsx, api/activities.ts, App.tsx, components/Sidebar.tsx) now at 100% stmts/branch/funcs/lines. - Activities.test.tsx: insole-type toggle select/deselect (both branches), reduced-subset create, add disabled when no types, edit insole types, cancel-without-save, empty-types-defaults-all editor, empty state, error-falls-back-to-empty, and the create->edit types->reorder->delete lifecycle test. - Sidebar.test.tsx (new): brand + nav links + email, active-route highlight, end-match keeps Live inactive off-root, nav click switches screen + moves highlight, Uitloggen calls signOut, email hidden without useMe data. - Activities.tsx: three /* c8 ignore next */ annotations on the defensive early-return guards in handleAdd/handleSave/handleMove (each mirrored by a disabled button, so unreachable via the UI). Comment-only, no behavior change. No bugs found — the domain code was already correct; the tests lock it in.
This commit is contained in:
95
apps/admin/src/components/Sidebar.test.tsx
Normal file
95
apps/admin/src/components/Sidebar.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
|
const signOut = vi.hoisted(() => vi.fn());
|
||||||
|
const meState = vi.hoisted(() => ({ email: 'admin@solelog.local' as string | undefined }));
|
||||||
|
|
||||||
|
vi.mock('../auth/AuthContext', () => ({
|
||||||
|
useAuth: () => ({ isAuthed: true, signIn: vi.fn(), signOut }),
|
||||||
|
}));
|
||||||
|
vi.mock('../api/me', () => ({
|
||||||
|
useMe: () => ({ data: meState.email ? { user: { email: meState.email } } : undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Render the Sidebar next to a stub route table so navigation can be observed.
|
||||||
|
function renderSidebar(initial = '/') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initial]}>
|
||||||
|
<Sidebar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<div>SCREEN Live</div>} />
|
||||||
|
<Route path="/handelingen" element={<div>SCREEN Handelingen</div>} />
|
||||||
|
<Route path="/sessies" element={<div>SCREEN Sessies</div>} />
|
||||||
|
<Route path="/rapporten" element={<div>SCREEN Rapporten</div>} />
|
||||||
|
<Route path="/gebruikers" element={<div>SCREEN Gebruikers</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
signOut.mockReset();
|
||||||
|
meState.email = 'admin@solelog.local';
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the brand, every nav link, and the signed-in email', () => {
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.getByText('SoleLog Admin')).toBeInTheDocument();
|
||||||
|
const nav = within(screen.getByRole('navigation'));
|
||||||
|
for (const label of ['Live', 'Handelingen', 'Sessies', 'Rapporten', 'Gebruikers']) {
|
||||||
|
expect(nav.getByRole('link', { name: label })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
expect(screen.getByText('admin@solelog.local')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the active route and only that route', () => {
|
||||||
|
renderSidebar('/');
|
||||||
|
expect(screen.getByText('SCREEN Live')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Live' }).className).toContain('nav-link-active');
|
||||||
|
expect(screen.getByRole('link', { name: 'Handelingen' }).className).not.toContain(
|
||||||
|
'nav-link-active',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Live inactive when a non-root route is active (end match on /)', () => {
|
||||||
|
renderSidebar('/handelingen');
|
||||||
|
expect(screen.getByText('SCREEN Handelingen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Handelingen' }).className).toContain(
|
||||||
|
'nav-link-active',
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a nav link switches the rendered screen and moves the highlight', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSidebar('/');
|
||||||
|
expect(screen.getByText('SCREEN Live')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('link', { name: 'Rapporten' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('SCREEN Rapporten')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('SCREEN Live')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Rapporten' }).className).toContain('nav-link-active');
|
||||||
|
expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Uitloggen calls signOut', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSidebar();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Uitloggen' }));
|
||||||
|
expect(signOut).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the email span when useMe has no data', () => {
|
||||||
|
meState.email = undefined;
|
||||||
|
renderSidebar();
|
||||||
|
expect(screen.queryByText('admin@solelog.local')).not.toBeInTheDocument();
|
||||||
|
// The logout control is still present.
|
||||||
|
expect(screen.getByRole('button', { name: 'Uitloggen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
@@ -163,4 +163,232 @@ describe('Activities', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('type toggles deselect and reselect insole types (add form)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
const kurk = screen.getByRole('button', { name: 'Kurk' });
|
||||||
|
expect(kurk).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// Remove branch of toggle().
|
||||||
|
await user.click(kurk);
|
||||||
|
expect(kurk).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
// Re-select — add branch of toggle().
|
||||||
|
await user.click(kurk);
|
||||||
|
expect(kurk).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a handeling with a reduced insole-type subset', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes');
|
||||||
|
// Default is all three; drop Berk so the POST carries only the remaining subset.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Berk' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: 'Randjes', insole_types: ['Kurk', '3D'] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Stap toevoegen when every insole type is deselected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes');
|
||||||
|
const add = screen.getByRole('button', { name: 'Stap toevoegen' });
|
||||||
|
expect(add).toBeEnabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Kurk' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Berk' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '3D' }));
|
||||||
|
expect(add).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editing insole types PUTs the updated subset', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen'); // Frezen has ['Kurk', 'Berk']
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
|
||||||
|
const li = screen.getByDisplayValue('Frezen').closest('li')!;
|
||||||
|
// Add 3D via the edit-form toggles.
|
||||||
|
await user.click(within(li).getByRole('button', { name: '3D' }));
|
||||||
|
await user.click(within(li).getByRole('button', { name: 'Opslaan' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk', 'Berk', '3D'] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel exits edit mode without saving', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Frezen');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
|
||||||
|
expect(screen.getByDisplayValue('Frezen')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Annuleren' }));
|
||||||
|
|
||||||
|
expect(screen.queryByDisplayValue('Frezen')).not.toBeInTheDocument();
|
||||||
|
expect(mockApiFetch).not.toHaveBeenCalledWith(
|
||||||
|
'/api/activities/1',
|
||||||
|
expect.objectContaining({ method: 'PUT' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editing an activity with no insole types pre-selects all three', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApiFetch.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: 'Blanco',
|
||||||
|
insole_types: [],
|
||||||
|
created_at: '2026-06-17T00:00:00.000Z',
|
||||||
|
sort_order: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderActivities();
|
||||||
|
await screen.findByText('Blanco');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Bewerk Blanco' }));
|
||||||
|
const li = screen.getByDisplayValue('Blanco').closest('li')!;
|
||||||
|
for (const type of ['Kurk', 'Berk', '3D']) {
|
||||||
|
expect(within(li).getByRole('button', { name: type })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when there are no activities', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue([]);
|
||||||
|
renderActivities();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Huidige stappen (0)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the empty state when the query errors (no dedicated error UI)', async () => {
|
||||||
|
mockApiFetch.mockRejectedValue(new Error('boom'));
|
||||||
|
renderActivities();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifecycle: create → edit insole types → reorder → delete, each step reflected in the list.
|
||||||
|
it('lifecycle: create → edit insole types → reorder → delete', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
let list: Activity[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Lijmen',
|
||||||
|
insole_types: ['3D'],
|
||||||
|
created_at: '2026-06-17T00:00:00.000Z',
|
||||||
|
sort_order: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let nextId = 2;
|
||||||
|
mockApiFetch.mockImplementation((path: string, init?: RequestInit) => {
|
||||||
|
const method = init?.method ?? 'GET';
|
||||||
|
if (path === '/api/activities' && method === 'GET') return Promise.resolve(list);
|
||||||
|
if (path === '/api/activities' && method === 'POST') {
|
||||||
|
const body = JSON.parse(init!.body as string);
|
||||||
|
const created: Activity = {
|
||||||
|
id: nextId++,
|
||||||
|
name: body.name,
|
||||||
|
insole_types: body.insole_types,
|
||||||
|
created_at: '2026-07-23T00:00:00.000Z',
|
||||||
|
sort_order: list.length,
|
||||||
|
};
|
||||||
|
list = [...list, created];
|
||||||
|
return Promise.resolve(created);
|
||||||
|
}
|
||||||
|
if (path === '/api/activities/reorder' && method === 'PUT') {
|
||||||
|
const { ids } = JSON.parse(init!.body as string) as { ids: number[] };
|
||||||
|
list = ids.map((id, i) => ({ ...list.find((a) => a.id === id)!, sort_order: i }));
|
||||||
|
return Promise.resolve(list);
|
||||||
|
}
|
||||||
|
const idMatch = path.match(/^\/api\/activities\/(\d+)$/);
|
||||||
|
if (idMatch && method === 'PUT') {
|
||||||
|
const id = Number(idMatch[1]);
|
||||||
|
const body = JSON.parse(init!.body as string);
|
||||||
|
list = list.map((a) =>
|
||||||
|
a.id === id ? { ...a, name: body.name, insole_types: body.insole_types } : a,
|
||||||
|
);
|
||||||
|
return Promise.resolve(list.find((a) => a.id === id)!);
|
||||||
|
}
|
||||||
|
if (idMatch && method === 'DELETE') {
|
||||||
|
const id = Number(idMatch[1]);
|
||||||
|
list = list.filter((a) => a.id !== id);
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`unexpected ${method} ${path}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = renderActivities();
|
||||||
|
|
||||||
|
// 1. Create Frezen (defaults to all three types).
|
||||||
|
await screen.findByText('Lijmen');
|
||||||
|
await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Frezen');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
|
||||||
|
expect(await screen.findByText('Frezen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Huidige stappen (2)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 2. Edit its insole types down to just Kurk.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
|
||||||
|
const editLi = screen.getByDisplayValue('Frezen').closest('li')!;
|
||||||
|
await user.click(within(editLi).getByRole('button', { name: 'Berk' }));
|
||||||
|
await user.click(within(editLi).getByRole('button', { name: '3D' }));
|
||||||
|
await user.click(within(editLi).getByRole('button', { name: 'Opslaan' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Reorder: move Frezen above Lijmen.
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Verplaats Frezen omhoog' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/reorder', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ids: [2, 1] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
const names = Array.from(container.querySelectorAll('.activity-name')).map(
|
||||||
|
(n) => n.textContent,
|
||||||
|
);
|
||||||
|
expect(names).toEqual(['Frezen', 'Lijmen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Delete Frezen.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', { method: 'DELETE' });
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Frezen')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Huidige stappen (1)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export default function Activities() {
|
|||||||
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
|
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
|
/* c8 ignore next -- defensive: the Stap toevoegen button is disabled when !addEnabled */
|
||||||
if (!addEnabled) return;
|
if (!addEnabled) return;
|
||||||
createActivity.mutate(
|
createActivity.mutate(
|
||||||
{ name: newName.trim(), insole_types: newTypes },
|
{ name: newName.trim(), insole_types: newTypes },
|
||||||
@@ -115,6 +116,7 @@ export default function Activities() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSave(id: number) {
|
function handleSave(id: number) {
|
||||||
|
/* c8 ignore next -- defensive: the Opslaan button is disabled when name/types are empty */
|
||||||
if (editName.trim().length === 0 || editTypes.length === 0) return;
|
if (editName.trim().length === 0 || editTypes.length === 0) return;
|
||||||
updateActivity.mutate(
|
updateActivity.mutate(
|
||||||
{ id, input: { name: editName.trim(), insole_types: editTypes } },
|
{ id, input: { name: editName.trim(), insole_types: editTypes } },
|
||||||
@@ -131,6 +133,7 @@ export default function Activities() {
|
|||||||
|
|
||||||
function handleMove(index: number, direction: -1 | 1) {
|
function handleMove(index: number, direction: -1 | 1) {
|
||||||
const target = index + direction;
|
const target = index + direction;
|
||||||
|
/* c8 ignore next -- defensive: up/down buttons are disabled at the list bounds */
|
||||||
if (target < 0 || target >= activities.length) return;
|
if (target < 0 || target >= activities.length) return;
|
||||||
const ids = activities.map((a) => a.id);
|
const ids = activities.map((a) => a.id);
|
||||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||||
|
|||||||
@@ -516,3 +516,45 @@ Guaranteed behaviors:
|
|||||||
- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed
|
- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed
|
||||||
list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch →
|
list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch →
|
||||||
reset its password inline (`Opslaan`) → the POST fires and the inline field closes.
|
reset its password inline (`Opslaan`) → the POST fires and the inline field closes.
|
||||||
|
|
||||||
|
## Activities + app/nav — admin client
|
||||||
|
|
||||||
|
Src covered (admin `apps/admin`): `src/screens/Activities.tsx`, `src/api/activities.ts`,
|
||||||
|
`src/App.tsx`, and `src/components/Sidebar.tsx` — all at 100% stmts/branch/funcs/lines. Three
|
||||||
|
`c8 ignore`s with reasons in `Activities.tsx`: the early-return guards in `handleAdd`, `handleSave`,
|
||||||
|
and `handleMove` are unreachable via the UI because the button that triggers each is disabled in
|
||||||
|
exactly that state (`Stap toevoegen` when `!addEnabled`, `Opslaan` when the name/types are empty,
|
||||||
|
the ↑/↓ move buttons at the list bounds).
|
||||||
|
|
||||||
|
Guaranteed behaviors:
|
||||||
|
|
||||||
|
- Activities renders the Dutch header (`Handelingen` / `Beheer handelingen per zooltype`), the
|
||||||
|
add-handeling card, and the current-steps list with a live count (`Huidige stappen (<n>)`).
|
||||||
|
- The insole-type toggles select/deselect a type (both the remove and re-add branches), reflected via
|
||||||
|
`aria-pressed`; a new handeling defaults to all three types (`Kurk`, `Berk`, `3D`).
|
||||||
|
- Creating a handeling POSTs `/api/activities` with the trimmed name and the selected `insole_types`
|
||||||
|
subset (a deselected type is dropped from the payload); `Stap toevoegen` is disabled while the name
|
||||||
|
is blank or every type is deselected.
|
||||||
|
- Editing a handeling opens an inline editor prefilled from the row and PUTs `/api/activities/:id` with
|
||||||
|
the updated name + `insole_types`; an activity stored with no insole types pre-selects all three in the
|
||||||
|
editor; `Annuleren` exits edit mode without issuing a PUT.
|
||||||
|
- Deleting a handeling confirms first (`window.confirm`); DELETE `/api/activities/:id` fires only when
|
||||||
|
confirmed, and is skipped when cancelled.
|
||||||
|
- Reorder: ↑/↓ move a row and PUT `/api/activities/reorder` with the swapped id order; ↑ is disabled on
|
||||||
|
the first row and ↓ on the last.
|
||||||
|
- The list shows the loading placeholder (`Laden...`) while the query is pending and the empty state
|
||||||
|
(`Nog geen stappen. Voeg er een toe hierboven.`) when there are none; a query error falls back to the
|
||||||
|
same empty state (there is no dedicated error UI).
|
||||||
|
- `api/activities.ts` hooks hit the right path/method — `useActivities` → GET `/api/activities`;
|
||||||
|
`useCreateActivity` → POST; `useUpdateActivity` → PUT `/:id`; `useDeleteActivity` → DELETE `/:id`;
|
||||||
|
`useReorderActivities` → PUT `/reorder` — and every mutation invalidates the `['activities']` query on
|
||||||
|
success.
|
||||||
|
- App/nav: `App` gates on auth (no token → `Login`; a token → the authed shell with `Sidebar` + routed
|
||||||
|
screens). `Sidebar` renders the brand, the five nav links, and the signed-in email (hidden when
|
||||||
|
`useMe` has no data), and `Uitloggen` calls `signOut`.
|
||||||
|
- `Sidebar` highlights the active route via `nav-link-active` and, with `end` matching on `/`, keeps the
|
||||||
|
Live link inactive on any non-root route; clicking a nav link switches the rendered screen and moves
|
||||||
|
the highlight.
|
||||||
|
- Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a
|
||||||
|
single type → reorder it above another → delete it, with each step reflected in the refetched list;
|
||||||
|
the Sidebar test (`src/components/Sidebar.test.tsx`) covers navigation switching screens.
|
||||||
|
|||||||
Reference in New Issue
Block a user