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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user