fix(events): render a not-found state instead of hanging on a 404

EventDetailsPage gated on `if (eventLoading || !event)`. The backend returns
a clean 404 for a nonexistent id, but once isLoading settled false `event`
stayed undefined forever, so /admin/events/999999 sat on the loading spinner
permanently with no error state.

Destructure isError and split the gate: spinner while loading, then a
not-found Card. Reuses the existing `events.notFound` key (already used by
EventFeedbackPage for the same entity) and the Card padding="lg" not-found
shape from contracts/ContractDetailPage. No new i18n keys.

Refs testplan REPORT.md #5 (Part 7.02).
This commit is contained in:
Paul Nothaft
2026-09-01 16:28:21 +02:00
parent 3489610cb8
commit c2428aa23a
2 changed files with 100 additions and 3 deletions
+16 -3
View File
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Loading } from '../../components/common'; import { Button, Card, Loading } from '../../components/common';
import { PasswordResetModal, PublishGalleryDialog, SendGalleryEmailDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin'; import { PasswordResetModal, PublishGalleryDialog, SendGalleryEmailDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
@@ -101,7 +101,7 @@ export const EventDetailsPage: React.FC = () => {
}); });
// Fetch event details // Fetch event details
const { data: event, isLoading: eventLoading, refetch: refetchEvent } = useQuery({ const { data: event, isLoading: eventLoading, isError: eventError, refetch: refetchEvent } = useQuery({
queryKey: ['admin-event', id], queryKey: ['admin-event', id],
queryFn: () => eventsService.getEvent(parseInt(id!)), queryFn: () => eventsService.getEvent(parseInt(id!)),
enabled: !!id, enabled: !!id,
@@ -324,7 +324,7 @@ export const EventDetailsPage: React.FC = () => {
}, },
}); });
if (eventLoading || !event) { if (eventLoading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('events.loadingEventDetails')} /> <Loading size="lg" text={t('events.loadingEventDetails')} />
@@ -332,6 +332,19 @@ export const EventDetailsPage: React.FC = () => {
); );
} }
// A 404 (or any settled failure) leaves `event` undefined forever — without
// this branch the spinner above never resolved (QA 7.02).
if (eventError || !event) {
return (
<Card padding="lg">
<p className="text-neutral-900 dark:text-neutral-100">{t('events.notFound', 'Event not found')}</p>
<Button variant="outline" className="mt-4" onClick={() => navigate('/admin/events')}>
{t('events.backToEvents')}
</Button>
</Card>
);
}
const expiresAtDate = safeParseDate(event.expires_at); const expiresAtDate = safeParseDate(event.expires_at);
// Timestamp comparison, not truncated whole days (#909): the old // Timestamp comparison, not truncated whole days (#909): the old
// differenceInDays <= 0 marked events "expired" up to 24h early. // differenceInDays <= 0 marked events "expired" up to 24h early.
@@ -0,0 +1,84 @@
/**
* /admin/events/:id hung on the spinner forever for a nonexistent id
* (QA 7.02). The backend returns a clean 404, but the page gated on
* `eventLoading || !event`, so once the query settled `event` stayed
* undefined and the condition never went false.
*/
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
const getEvent = vi.fn();
vi.mock('../../../services/events.service', () => ({
eventsService: {
getEvent: (...args: unknown[]) => getEvent(...args),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
extendExpiration: vi.fn(),
duplicateEvent: vi.fn(),
resetPassword: vi.fn(),
publishEvent: vi.fn(),
renameEvent: vi.fn(),
},
}));
vi.mock('../../../hooks/usePublicSettings', () => ({
PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'],
usePublicSettings: () => ({ data: {} }),
}));
vi.mock('../../../contexts/FeatureFlagsContext', () => ({
useFeatureFlags: () => ({ flags: {}, isLoading: false }),
useFeatureEnabled: () => false,
}));
vi.mock('../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({ hasAnyPermission: () => true, hasPermission: () => true, isLoading: false }),
}));
import { EventDetailsPage } from '../EventDetailsPage';
function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/events/999999']}>
<Routes>
<Route path="/admin/events/:id" element={<EventDetailsPage />} />
<Route path="/admin/events" element={<div>events list</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('EventDetailsPage 404 handling (QA 7.02)', () => {
it('renders a not-found state instead of spinning forever when the event 404s', async () => {
getEvent.mockRejectedValue({ response: { status: 404, data: { error: 'Event not found' } } });
renderPage();
expect(screen.getByText('events.loadingEventDetails')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Event not found')).toBeInTheDocument();
});
expect(screen.queryByText('events.loadingEventDetails')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'events.backToEvents' })).toBeInTheDocument();
});
});