fix(events): honour ?tab=, show a load error, and stop lying about uploads

Three warnings on the event-details surface.

?tab= deep links were ignored -- activeTab was hardcoded to 'overview' and
nothing read or wrote the search param, unlike Settings. Mirrors SettingsPage's
pattern exactly (module-level key list + type guard, seed useState from the
param, write-back and reflect-back effects), plus a snap-back for the `guests`
tab, which only renders when identity_mode is 'guest' -- a deep link to it on
any other event would otherwise show a tab bar with no content. The snap-back
is guarded on the query's isLoading so it cannot fire against undefined
settings and kill a legitimate deep link.

Worth recording: the two effects ping-pong infinitely if activeTab and a valid
URL tab disagree at mount, which is exactly the pre-fix state. The seeding is
what makes them agree, so the fix is also what makes the pair safe.

Offline Photos tab rendered the "no media uploaded yet" empty state on a
failed fetch, because `data: photos = []` makes a rejected query
indistinguishable from an empty one -- a user could reasonably think their
photos were gone. Threaded isError through and added a third branch, reusing
TaxReportPage's existing error-with-retry shape. Needed no new keys.

The spurious "Upload completed successfully" toast was in the host, not the
uploader: PhotosTab hung toast.success off PhotoUpload's onUploadComplete,
which is documented as a grid-refresh signal and fires as soon as the transfer
loop exits -- including when the request 400'd on the photo cap or every file
was rejected by magic-byte validation. PhotoUpload's own toasts were already
correct. Removed it, and added a real partial-success branch reporting the
actual split instead of a plain "Upload complete!".

The guest uploader had a variant of the same bug in a different place: its
toast is gated on successCount, but successCount++ fired on any resolved
request -- and the upload route answers 202 with count: 0 and an errors[]
entry when the file is refused. So a refused guest photo produced "Upload
completed successfully (1 photos)" and pushed a useless upload_id into the
processing poll. Now gated on count.

Refs testplan REPORT.md, ?tab= / offline-empty-state / spurious-toast warnings.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent 15fdd70a08
commit 504a8b6fae
9 changed files with 533 additions and 12 deletions
@@ -453,6 +453,17 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.processingFailed', { count: processingAggregate.failed }) ||
`${processingAggregate.failed} photo(s) failed to process`
);
} else if (transferFailures.length > 0) {
// Processing was clean, but files were rejected or lost before they got
// there. A plain "Upload complete!" here would contradict the failure
// report right below it (QA P4-B.05 / 7.05) — report the real split.
toast.warning(
t('upload.partialComplete', '{{uploaded}} of {{total}} files uploaded — {{failed}} could not be uploaded.', {
uploaded: processingAggregate.complete,
total: processingAggregate.complete + transferFailures.length,
failed: transferFailures.length,
})
);
} else {
toast.success(
t('upload.uploadComplete') || `Successfully uploaded ${processingAggregate.complete} photo(s)`
@@ -0,0 +1,189 @@
/**
* The completion toast must describe what actually happened.
*
* QA P4-B.05 / 7.05: uploading past a photo cap (whole request 400s) and
* uploading a `.txt` renamed to `.jpg` (magic-byte rejection) both produced a
* generic "Upload completed successfully" toast *alongside* the rejection
* toast, with 0 of N files in the gallery. The success toast came from the
* host's `onUploadComplete` handler, which PhotoUpload fires purely as a
* "refresh the grid" signal — including on runs where nothing landed.
*
* These pin the outcome contract:
* - nothing landed -> no success toast (and the refresh still fires)
* - some landed -> an accurate partial message, not "complete!"
* - all landed -> success
* plus the host-side rule that the refresh callback never announces success.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import fs from 'fs';
import path from 'path';
import type { ReactElement } from 'react';
import { PhotoUpload } from '../PhotoUpload';
// Interpolating t() — the partial message is only meaningful with its numbers
// substituted, so the mock has to do what i18next would.
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (key: string, second?: any, third?: any) => {
const fallback = typeof second === 'string' ? second : undefined;
const vars = (typeof second === 'object' ? second : third) || {};
let out = fallback ?? key;
for (const [k, v] of Object.entries(vars)) {
out = out.split(`{{${k}}}`).join(String(v));
}
return out;
},
}),
};
});
const toastMock = vi.hoisted(() => ({
warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn(),
}));
vi.mock('react-toastify', () => ({ toast: toastMock }));
const postMock = vi.fn();
vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a), get: vi.fn() } }));
const hoisted = vi.hoisted(() => ({ aggregate: null as any }));
const idle = {
total: 0, pending: 0, processing: 0, complete: 0, failed: 0,
failedPhotos: [] as { id: number; filename: string; error: string | null }[],
isComplete: false, isReady: true,
};
vi.mock('../../../hooks/useUploadProgress', () => ({
useUploadProgress: (ids: string[]) => ({
snapshots: {},
error: null,
aggregate: ids && ids.length > 0 ? hoisted.aggregate : idle,
}),
}));
vi.mock('../../../services/categories.service', () => ({
categoriesService: { getEventCategories: vi.fn().mockResolvedValue([]) },
}));
vi.mock('../../../services/settings.service', () => ({
settingsService: { getAllSettings: vi.fn().mockResolvedValue({}) },
}));
const renderWithClient = (ui: ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const makeFile = (name: string) =>
new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' });
async function uploadFiles(container: HTMLElement, user: ReturnType<typeof userEvent.setup>, names: string[]) {
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, names.map(makeFile));
await user.click(screen.getByRole('button', { name: /common\.upload/ }));
}
describe('PhotoUpload completion toast', () => {
beforeEach(() => {
postMock.mockReset();
hoisted.aggregate = { ...idle };
});
afterEach(() => vi.clearAllMocks());
it('stays silent on success when the whole request was refused (photo cap)', async () => {
// Backend 400s the entire upload — every file is a transfer failure.
postMock.mockRejectedValue({
response: { data: { error: 'Photo cap exceeded. This event allows a maximum of 3 photos.' } },
});
const onUploadComplete = vi.fn();
const user = userEvent.setup();
const { container } = renderWithClient(
<PhotoUpload eventId={1} onUploadComplete={onUploadComplete} />
);
await uploadFiles(container, user, ['a.png', 'b.png']);
await screen.findByTestId('upload-failure-report');
expect(toastMock.success).not.toHaveBeenCalled();
// The grid refresh still has to happen — it is a refresh signal, which is
// exactly why the host must not hang a success toast off it.
expect(onUploadComplete).toHaveBeenCalled();
});
it('stays silent on success when every file was rejected per-file', async () => {
// 202, but count 0: nothing was queued (renamed .txt / magic-byte check).
postMock.mockResolvedValue({
data: {
count: 0,
upload_id: 'u1',
errors: [{ filename: 'fake.jpg', error: 'File content does not match declared type' }],
},
});
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFiles(container, user, ['fake.jpg']);
await screen.findByTestId('upload-failure-report');
expect(toastMock.success).not.toHaveBeenCalled();
});
it('reports the real split when some files land and others do not', async () => {
postMock.mockResolvedValue({
data: {
count: 1,
upload_id: 'u1',
errors: [{ filename: 'fake.jpg', error: 'File content does not match declared type' }],
},
});
hoisted.aggregate = {
total: 1, pending: 0, processing: 0, complete: 1, failed: 0,
failedPhotos: [], isComplete: true, isReady: true,
};
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFiles(container, user, ['good.png', 'fake.jpg']);
await waitFor(() =>
expect(toastMock.warning).toHaveBeenCalledWith(
'1 of 2 files uploaded — 1 could not be uploaded.'
)
);
expect(toastMock.success).not.toHaveBeenCalled();
});
it('still congratulates a clean upload', async () => {
postMock.mockResolvedValue({ data: { count: 1, upload_id: 'u1', errors: [] } });
hoisted.aggregate = {
total: 1, pending: 0, processing: 0, complete: 1, failed: 0,
failedPhotos: [], isComplete: true, isReady: true,
};
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFiles(container, user, ['good.png']);
await waitFor(() => expect(toastMock.success).toHaveBeenCalled());
expect(toastMock.warning).not.toHaveBeenCalled();
});
});
describe('host refresh callback', () => {
it('does not announce success from the Photos tab refresh handler', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', '..', '..', 'pages', 'admin', 'event-details', 'PhotosTab.tsx'),
'utf8'
);
const handler = source.slice(
source.indexOf('onUploadComplete={'),
source.indexOf('{/* Photo Filters */}')
);
expect(handler).toContain('refetchPhotos()');
expect(handler).not.toContain('toast.success');
});
});
@@ -157,7 +157,11 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
}
try {
const response = await api.post<{ upload_id?: string }>(`/gallery/${eventId}/upload`, formData, {
const response = await api.post<{
upload_id?: string;
count?: number;
errors?: Array<{ filename?: string; error?: string }>;
}>(`/gallery/${eventId}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -174,16 +178,30 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
}
},
});
// Request resolved → the bytes are stored. Processing continues in the
// background worker; `upload_id` is how the gallery follows it.
if (response.data?.upload_id) {
uploadIds.push(response.data.upload_id);
}
setProcessingFiles(prev => {
const next = { ...prev };
delete next[file.name];
return next;
});
// A 202 does NOT mean the file landed: the route still answers 202
// with `count: 0` and an `errors[]` entry when the queue refuses it
// (content/type mismatch, cap hit). Counting that as a success fired
// "Upload completed successfully" for a photo that never existed —
// the guest-side twin of QA P4-B.05 / 7.05.
const queuedCount = response.data?.count;
if (typeof queuedCount === 'number' && queuedCount === 0) {
failedCount++;
const reason = response.data?.errors?.[0]?.error || t('upload.someFilesFailed');
toast.error(`${file.name}: ${reason}`);
continue;
}
// Bytes are stored and queued. Processing continues in the background
// worker; `upload_id` is how the gallery follows it.
if (response.data?.upload_id) {
uploadIds.push(response.data.upload_id);
}
successCount++;
} catch (error: any) {
// Upload error handled - user notified via UI
@@ -0,0 +1,93 @@
/**
* A guest must not be told their photo uploaded when it was refused.
*
* `POST /gallery/:id/upload` answers 202 even when the queue rejects the file
* (content/type mismatch, photo cap) the refusal comes back as `count: 0`
* plus an `errors[]` entry. The uploader treated "the request resolved" as
* success, so the guest got "Upload completed successfully (1 photos)" for a
* photo that never existed: the guest-side twin of QA P4-B.05 / 7.05.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UserPhotoUpload } from '../UserPhotoUpload';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (key: string, second?: any) => (typeof second === 'string' ? second : key),
}),
};
});
const toastMock = vi.hoisted(() => ({
warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn(),
}));
vi.mock('react-toastify', () => ({ toast: toastMock }));
const postMock = vi.fn();
vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a) } }));
vi.mock('../../../hooks/usePublicSettings', () => ({
usePublicSettings: () => ({ data: {} }),
}));
const renderUploader = (onUploadComplete = vi.fn()) => {
const utils = render(
<UserPhotoUpload
eventId={7}
categoryId={null}
onUploadComplete={onUploadComplete}
onClose={vi.fn()}
/>
);
return { ...utils, onUploadComplete };
};
async function pickAndUpload(container: HTMLElement, user: ReturnType<typeof userEvent.setup>, name: string) {
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' }));
await user.click(screen.getByRole('button', { name: /common\.upload/ }));
}
describe('UserPhotoUpload rejected file', () => {
beforeEach(() => postMock.mockReset());
afterEach(() => vi.clearAllMocks());
it('does not report success for a 202 that queued nothing', async () => {
postMock.mockResolvedValue({
data: {
upload_id: 'u1',
count: 0,
errors: [{ filename: 'fake.png', error: 'File content does not match declared type' }],
},
});
const user = userEvent.setup();
const { container, onUploadComplete } = renderUploader();
await pickAndUpload(container, user, 'fake.png');
await waitFor(() =>
expect(toastMock.error).toHaveBeenCalledWith(
'fake.png: File content does not match declared type'
)
);
expect(toastMock.success).not.toHaveBeenCalled();
// Nothing was queued, so there is no upload group worth polling.
expect(onUploadComplete).not.toHaveBeenCalled();
});
it('still reports success and hands over the upload id when the file lands', async () => {
postMock.mockResolvedValue({ data: { upload_id: 'u1', count: 1 } });
const user = userEvent.setup();
const { container, onUploadComplete } = renderUploader();
await pickAndUpload(container, user, 'good.png');
await waitFor(() => expect(toastMock.success).toHaveBeenCalled());
expect(onUploadComplete).toHaveBeenCalledWith(['u1']);
});
});
+1
View File
@@ -201,6 +201,7 @@
"processingStillRunning": "Ihr Upload wird noch verarbeitet — er erscheint in Kürze in der Galerie.",
"retryFailed": "Fehlgeschlagene erneut versuchen",
"uploadComplete": "Upload abgeschlossen!",
"partialComplete": "{{uploaded}} von {{total}} Dateien hochgeladen — {{failed}} konnten nicht hochgeladen werden.",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"failures": {
"title": "{{count}} Datei(en) konnten nicht hochgeladen werden",
+1
View File
@@ -201,6 +201,7 @@
"processingStillRunning": "Your upload is still being processed — it will appear in the gallery shortly.",
"retryFailed": "Retry failed",
"uploadComplete": "Upload complete!",
"partialComplete": "{{uploaded}} of {{total}} files uploaded — {{failed}} could not be uploaded.",
"someFilesFailed": "Some files failed to upload",
"failures": {
"title": "{{count}} file(s) could not be uploaded",
+46 -4
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useExpiryRefresh } from '../../hooks/useExpiryRefresh';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -23,9 +23,16 @@ import { OverviewTab } from './event-details/OverviewTab';
import { PhotosTab } from './event-details/PhotosTab';
import { CategoriesTab } from './event-details/CategoriesTab';
const ALL_TAB_KEYS: EventDetailsTab[] = ['overview', 'photos', 'categories', 'guests'];
function isValidTab(value: string | null): value is EventDetailsTab {
return value !== null && (ALL_TAB_KEYS as string[]).includes(value);
}
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { format } = useLocalizedDate();
@@ -55,7 +62,31 @@ export const EventDetailsPage: React.FC = () => {
rate_limit_window_minutes: 15,
rate_limit_max_requests: 10,
});
const [activeTab, setActiveTab] = useState<EventDetailsTab>('overview');
// Read ?tab=… on mount, same shape as SettingsPage so both surfaces answer
// deep links identically; an unknown value falls back to the default tab and
// the sync effect below rewrites the URL to match (QA follow-up).
const [activeTab, setActiveTab] = useState<EventDetailsTab>(
isValidTab(searchParams.get('tab')) ? (searchParams.get('tab') as EventDetailsTab) : 'overview'
);
// Keep the URL in sync when the user clicks tabs, so copy-pasting the address
// lands the recipient on the same tab.
useEffect(() => {
if (searchParams.get('tab') === activeTab) return;
const next = new URLSearchParams(searchParams);
next.set('tab', activeTab);
setSearchParams(next, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
// Reflect external URL changes (back/forward) back into local state.
useEffect(() => {
const urlTab = searchParams.get('tab');
if (isValidTab(urlTab) && urlTab !== activeTab) {
setActiveTab(urlTab);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
@@ -115,12 +146,22 @@ export const EventDetailsPage: React.FC = () => {
useExpiryRefresh([event?.expires_at], bumpExpiryTick);
// Fetch feedback settings
const { data: eventFeedbackSettings } = useQuery({
const { data: eventFeedbackSettings, isLoading: feedbackSettingsLoading } = useQuery({
queryKey: ['admin-event-feedback-settings', id],
queryFn: () => feedbackService.getEventFeedbackSettings(id!),
enabled: !!id,
});
// Guests is only rendered in guest identity mode, so a ?tab=guests deep link
// on any other event would show an empty content area. Snap back once the
// settings have actually loaded — not while they're still undefined.
useEffect(() => {
if (feedbackSettingsLoading) return;
if (activeTab === 'guests' && eventFeedbackSettings?.identity_mode !== 'guest') {
setActiveTab('overview');
}
}, [feedbackSettingsLoading, eventFeedbackSettings?.identity_mode, activeTab]);
// Update local feedback settings when fetched from server
useEffect(() => {
if (eventFeedbackSettings) {
@@ -147,7 +188,7 @@ export const EventDetailsPage: React.FC = () => {
// While any photo is still in pending/processing state we poll every
// 2s so the admin grid auto-updates as the background worker drains
// the queue. Once everything is complete/failed the polling stops.
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
const { data: photos = [], isLoading: photosLoading, isError: photosError, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, combinedPhotoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters),
enabled: !!id && (activeTab === 'photos' || isEditing),
@@ -692,6 +733,7 @@ export const EventDetailsPage: React.FC = () => {
id={id}
photos={photos}
photosLoading={photosLoading}
photosError={photosError}
refetchPhotos={refetchPhotos}
categories={categories}
photoFilters={photoFilters}
@@ -0,0 +1,145 @@
/**
* Two QA follow-ups on /admin/events/:id, both observable on the Photos tab:
*
* - `?tab=photos` was ignored the page always mounted on Overview, unlike
* Settings which seeds its tab state from the same param.
* - With the network down, the photos query failed and the grid fell through
* to its "no media uploaded yet" empty state, so an admin could reasonably
* conclude the photos were gone.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } 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),
getEventCategories: vi.fn().mockResolvedValue([]),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
extendExpiration: vi.fn(),
duplicateEvent: vi.fn(),
resetPassword: vi.fn(),
publishEvent: vi.fn(),
renameEvent: vi.fn(),
revealNow: vi.fn(),
archiveEvent: vi.fn(),
sendGalleryEmail: vi.fn(),
},
}));
const getEventPhotos = vi.fn();
vi.mock('../../../services/photos.service', () => ({
photosService: {
getEventPhotos: (...args: unknown[]) => getEventPhotos(...args),
getFilterSummary: vi.fn().mockResolvedValue({}),
getExportFormats: vi.fn().mockResolvedValue([]),
},
}));
vi.mock('../../../services/feedback.service', () => ({
feedbackService: {
getEventFeedbackSettings: vi.fn().mockResolvedValue({ identity_mode: 'simple' }),
},
}));
vi.mock('../../../services/cssTemplates.service', () => ({
cssTemplatesService: { getEnabledTemplates: vi.fn().mockResolvedValue([]) },
}));
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';
const EVENT = {
id: 1,
event_name: 'ZZTEST',
slug: 'zztest',
event_type: 'wedding',
event_date: '2026-09-01T00:00:00.000Z',
expires_at: '2027-09-01T00:00:00.000Z',
is_active: true,
is_archived: false,
photo_count: 3,
source_mode: 'managed',
};
function renderPage(entry: string) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/admin/events/:id" element={<EventDetailsPage />} />
<Route path="/admin/events" element={<div>events list</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('EventDetailsPage photos tab', () => {
beforeEach(() => {
vi.clearAllMocks();
getEvent.mockResolvedValue(EVENT);
getEventPhotos.mockResolvedValue([]);
});
it('honours a ?tab=photos deep link instead of landing on Overview', async () => {
renderPage('/admin/events/1?tab=photos');
await waitFor(() => {
expect(screen.getByText('events.uploadPhotos')).toBeInTheDocument();
});
// Overview-only control must not be on screen.
expect(screen.queryByText('events.eventInformation')).not.toBeInTheDocument();
});
it('falls back to Overview for an unknown ?tab= value', async () => {
renderPage('/admin/events/1?tab=nonsense');
await waitFor(() => {
expect(screen.queryByText('events.uploadPhotos')).not.toBeInTheDocument();
});
});
it('renders an error with retry, not the empty state, when the photos query fails', async () => {
getEventPhotos.mockRejectedValue(new Error('Network Error'));
renderPage('/admin/events/1?tab=photos');
await waitFor(() => {
expect(screen.getByText('gallery.failedToLoad')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'common.retry' })).toBeInTheDocument();
expect(screen.queryByText('No media uploaded yet')).not.toBeInTheDocument();
});
});
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Upload, X } from 'lucide-react';
import { AlertCircle, Upload, X } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card, Loading } from '../../../components/common';
import { AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PhotoUploadModal, PhotoFilterPanel, PhotoExportMenu } from '../../../components/admin';
@@ -16,6 +16,7 @@ interface PhotosTabProps {
id: string | undefined;
photos: AdminPhoto[];
photosLoading: boolean;
photosError: boolean;
refetchPhotos: () => void;
categories: Array<{ id: number; name: string; slug: string; is_folder?: boolean }>;
photoFilters: PhotoFilterParams;
@@ -31,6 +32,7 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
id,
photos,
photosLoading,
photosError,
refetchPhotos,
categories,
photoFilters,
@@ -58,9 +60,13 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
onClose={() => setShowPhotoUpload(false)}
eventId={parseInt(id!)}
onUploadComplete={() => {
// Refresh-only. PhotoUpload fires this as bytes land AND again when
// processing finishes — including runs where every file was rejected
// — so a success toast here claimed "Upload completed successfully"
// over the top of the rejection warning (QA P4-B.05 / 7.05). The
// outcome toast belongs to PhotoUpload, which knows the counts.
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
toast.success(t('toast.uploadSuccess'));
refetchPhotos();
}}
/>
@@ -130,6 +136,21 @@ export const PhotosTab: React.FC<PhotosTabProps> = ({
<div className="flex items-center justify-center py-12">
<Loading size="lg" text={t('events.loadingPhotos')} />
</div>
) : photosError ? (
// Without this branch a failed fetch (offline, 5xx) fell through to the
// grid's "no media uploaded yet" empty state, which reads as "your
// photos are gone" rather than "we couldn't load them" (QA follow-up).
<Card padding="lg">
<div className="flex items-start gap-3 text-amber-700 dark:text-amber-400">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">{t('gallery.failedToLoad')}</p>
<Button variant="outline" size="sm" onClick={() => refetchPhotos()} className="mt-3">
{t('common.retry')}
</Button>
</div>
</div>
</Card>
) : (
<AdminPhotoGrid
photos={photos}