feat(unit-4): Dashboard — availability widget with TanStack Query
- Add @tanstack/react-query 5.101.0; wrap app with QueryClientProvider - Add AvailabilityStatus type and AvailabilityResponse to api/types.ts - Implement useAvailabilityStatus (staleTime 30s, stale-on-error preserved) - Add AvailabilityStatusBadge with green/amber/red states and stale indicator - Replace DashboardPage placeholder card with live availability widget - Add MSW availability handler; update test/utils with QueryClientProvider - 55/55 tests pass (FR-05) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+18
@@ -20,6 +20,9 @@ importers:
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.101.0
|
||||
version: 5.101.0(react@19.2.7)
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.170.16
|
||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -916,6 +919,14 @@ packages:
|
||||
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/query-core@5.101.0':
|
||||
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
|
||||
|
||||
'@tanstack/react-query@5.101.0':
|
||||
resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-router@1.170.16':
|
||||
resolution: {integrity: sha512-w6eq1IJklujs1tESazaK/FxH0+H2l8vm/QPuu1cD3oRW/ubgKneQpd7b64ti/8gUyEimzimJQZDmJr6YHfP5+g==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -2903,6 +2914,13 @@ snapshots:
|
||||
|
||||
'@tanstack/history@1.162.0': {}
|
||||
|
||||
'@tanstack/query-core@5.101.0': {}
|
||||
|
||||
'@tanstack/react-query@5.101.0(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.101.0
|
||||
react: 19.2.7
|
||||
|
||||
'@tanstack/react-router@1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.0
|
||||
|
||||
@@ -60,3 +60,11 @@ export interface InviteCompleteRequest {
|
||||
name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable';
|
||||
|
||||
export interface AvailabilityResponse {
|
||||
status: AvailabilityStatus;
|
||||
checkedAt: string; // ISO 8601
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { createElement } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { server } from '@/mocks/server';
|
||||
import { API_BASE } from '@/mocks/auth/fixtures';
|
||||
import { useAvailabilityStatus } from './useAvailability';
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
}
|
||||
|
||||
const AVAILABILITY_URL = `${API_BASE}/api/v1/Availability/status`;
|
||||
|
||||
describe('useAvailabilityStatus', () => {
|
||||
it('starts in loading state', () => {
|
||||
const { result } = renderHook(() => useAvailabilityStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns data on successful fetch', async () => {
|
||||
const { result } = renderHook(() => useAvailabilityStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(result.current.data).toMatchObject({
|
||||
status: 'Available',
|
||||
message: '',
|
||||
});
|
||||
expect(typeof result.current.data?.checkedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('enters error state when the server returns 5xx', async () => {
|
||||
server.use(
|
||||
http.get(AVAILABILITY_URL, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'Internal Server Error', status: 500 },
|
||||
{ status: 500 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAvailabilityStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.isError).toBe(true);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes a refetch function', async () => {
|
||||
const { result } = renderHook(() => useAvailabilityStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(typeof result.current.refetch).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { AvailabilityResponse } from './types';
|
||||
|
||||
export function useAvailabilityStatus() {
|
||||
return useQuery<AvailabilityResponse, Error>({
|
||||
queryKey: ['availability', 'status'],
|
||||
queryFn: () => api.get<AvailabilityResponse>('/api/v1/Availability/status'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderWithProviders } from '@/test/utils';
|
||||
import { AvailabilityStatusBadge } from './AvailabilityStatusBadge';
|
||||
|
||||
describe('AvailabilityStatusBadge', () => {
|
||||
it('renders Available status with green styling', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
|
||||
|
||||
const label = screen.getByTestId('availability-status-label');
|
||||
expect(label).toHaveTextContent('Available');
|
||||
|
||||
const badge = screen.getByTestId('availability-badge');
|
||||
expect(badge.querySelector('.bg-green-100')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders Maintenance status with amber styling', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Maintenance" />);
|
||||
|
||||
const label = screen.getByTestId('availability-status-label');
|
||||
expect(label).toHaveTextContent('Maintenance');
|
||||
|
||||
const badge = screen.getByTestId('availability-badge');
|
||||
expect(badge.querySelector('.bg-amber-100')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders Unavailable status with red styling', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
|
||||
|
||||
const label = screen.getByTestId('availability-status-label');
|
||||
expect(label).toHaveTextContent('Unavailable');
|
||||
|
||||
const badge = screen.getByTestId('availability-badge');
|
||||
expect(badge.querySelector('.bg-red-100')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows message subtitle when message is non-empty', () => {
|
||||
renderWithProviders(
|
||||
<AvailabilityStatusBadge status="Maintenance" message="Scheduled downtime until 18:00" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('availability-message')).toHaveTextContent(
|
||||
'Scheduled downtime until 18:00',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides message element when message is empty string', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Available" message="" />);
|
||||
|
||||
expect(screen.queryByTestId('availability-message')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides message element when message prop is omitted', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
|
||||
|
||||
expect(screen.queryByTestId('availability-message')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows stale indicator when stale={true}', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Available" stale={true} />);
|
||||
|
||||
expect(screen.getByTestId('availability-stale-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides stale indicator when stale is omitted (default false)', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
|
||||
|
||||
expect(screen.queryByTestId('availability-stale-indicator')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AvailabilityStatus } from '@/api/types';
|
||||
|
||||
interface AvailabilityStatusBadgeProps {
|
||||
status: AvailabilityStatus;
|
||||
message?: string;
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
AvailabilityStatus,
|
||||
{ labelKey: string; colorClass: string; Icon: React.ComponentType<{ className?: string }> }
|
||||
> = {
|
||||
Available: {
|
||||
labelKey: 'availability.available',
|
||||
colorClass:
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
Icon: CheckCircle,
|
||||
},
|
||||
Maintenance: {
|
||||
labelKey: 'availability.maintenance',
|
||||
colorClass:
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
Icon: AlertTriangle,
|
||||
},
|
||||
Unavailable: {
|
||||
labelKey: 'availability.unavailable',
|
||||
colorClass:
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
Icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
export function AvailabilityStatusBadge({
|
||||
status,
|
||||
message,
|
||||
stale = false,
|
||||
}: AvailabilityStatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { labelKey, colorClass, Icon } = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="availability-badge"
|
||||
className={stale ? 'rounded-lg border-2 border-dashed border-amber-400 p-3' : undefined}
|
||||
>
|
||||
<div className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${colorClass}`}>
|
||||
<Icon className="size-4" />
|
||||
<span data-testid="availability-status-label">{t(labelKey)}</span>
|
||||
</div>
|
||||
|
||||
{message && message.length > 0 && (
|
||||
<p
|
||||
data-testid="availability-message"
|
||||
className="mt-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stale && (
|
||||
<div
|
||||
data-testid="availability-stale-indicator"
|
||||
className="mt-2 flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
<span>{t('availability.staleLabel')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,8 +59,16 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome back, {{name}}",
|
||||
"placeholder": "Your dashboard widgets will appear here."
|
||||
"welcome": "Welcome back, {{name}}"
|
||||
},
|
||||
"availability": {
|
||||
"title": "System Status",
|
||||
"available": "Available",
|
||||
"maintenance": "Maintenance",
|
||||
"unavailable": "Unavailable",
|
||||
"errorTitle": "Could not load status",
|
||||
"staleLabel": "Last known status",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"userMenu": {
|
||||
"language": "Language",
|
||||
|
||||
@@ -59,8 +59,16 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welkom terug, {{name}}",
|
||||
"placeholder": "Je dashboard-widgets verschijnen hier."
|
||||
"welcome": "Welkom terug, {{name}}"
|
||||
},
|
||||
"availability": {
|
||||
"title": "Systeemstatus",
|
||||
"available": "Beschikbaar",
|
||||
"maintenance": "Onderhoud",
|
||||
"unavailable": "Niet beschikbaar",
|
||||
"errorTitle": "Status kon niet worden geladen",
|
||||
"staleLabel": "Laatste bekende status",
|
||||
"retry": "Opnieuw proberen"
|
||||
},
|
||||
"userMenu": {
|
||||
"language": "Taal",
|
||||
|
||||
+13
-4
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import './index.css';
|
||||
import './i18n/config';
|
||||
import { AuthProvider } from '@/contexts/AuthProvider';
|
||||
@@ -8,6 +9,12 @@ import { useAuth } from '@/contexts/auth-context';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { router } from '@/router';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: true },
|
||||
},
|
||||
});
|
||||
|
||||
function BootstrapSplash() {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center text-muted-foreground">
|
||||
@@ -43,10 +50,12 @@ void enableMocking().then(() => {
|
||||
}
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<InnerApp />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<InnerApp />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const availabilityHandlers = [
|
||||
http.get('*/Availability/status', () =>
|
||||
HttpResponse.json({
|
||||
status: 'Available',
|
||||
checkedAt: new Date().toISOString(),
|
||||
message: '',
|
||||
}),
|
||||
),
|
||||
];
|
||||
@@ -2,12 +2,14 @@ import { authHandlers } from './auth/handlers';
|
||||
import { userHandlers } from './users/handlers';
|
||||
import { setupHandlers } from './setup/handlers';
|
||||
import { invitationHandlers } from './invitation/handlers';
|
||||
import { availabilityHandlers } from './availability/handlers';
|
||||
|
||||
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers];
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers];
|
||||
|
||||
export { authHandlers } from './auth/handlers';
|
||||
export { userHandlers } from './users/handlers';
|
||||
export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers } from './setup/handlers';
|
||||
export { invitationHandlers } from './invitation/handlers';
|
||||
export { availabilityHandlers } from './availability/handlers';
|
||||
export * from './auth/fixtures';
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useAvailabilityStatus } from '@/api/useAvailability';
|
||||
import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusBadge';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, isError, refetch } = useAvailabilityStatus();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -16,12 +20,45 @@ export function DashboardPage() {
|
||||
{t('dashboard.welcome', { name: user?.name ?? '' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.title')}</CardTitle>
|
||||
<CardTitle>{t('availability.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t('dashboard.placeholder')}</p>
|
||||
{isLoading && (
|
||||
<div
|
||||
data-testid="availability-skeleton"
|
||||
className="h-8 w-32 animate-pulse rounded-full bg-muted"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && data && (
|
||||
<AvailabilityStatusBadge
|
||||
status={data.status}
|
||||
message={data.message}
|
||||
stale={isError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div
|
||||
data-testid="availability-error"
|
||||
className="mt-3 flex items-center gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<span>{t('availability.errorTitle')}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="availability-retry"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
{t('availability.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !data && !isError && null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, type ReactElement } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { AuthProvider } from '@/contexts/AuthProvider';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
@@ -11,6 +12,12 @@ import { server } from '@/mocks/server';
|
||||
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
|
||||
import i18n from '@/i18n/config';
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Force the silent-refresh-on-mount to fail, leaving the app in guest state. */
|
||||
export function mockGuest(): void {
|
||||
server.use(
|
||||
@@ -27,12 +34,14 @@ export function mockAuthenticated(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Render an arbitrary element wrapped in the i18n + auth providers. */
|
||||
/** Render an arbitrary element wrapped in i18n + auth + query providers. */
|
||||
export function renderWithProviders(ui: ReactElement) {
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AuthProvider>{ui}</AuthProvider>
|
||||
</I18nextProvider>,
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AuthProvider>{ui}</AuthProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +64,12 @@ function AppHarness({ initialPath }: { initialPath: string }) {
|
||||
/** Render the full application router at a given path, inside all providers. */
|
||||
export function renderApp(initialPath = '/') {
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AuthProvider>
|
||||
<AppHarness initialPath={initialPath} />
|
||||
</AuthProvider>
|
||||
</I18nextProvider>,
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AuthProvider>
|
||||
<AppHarness initialPath={initialPath} />
|
||||
</AuthProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user