fix(photos): emit visibility and processing_status from the list mapper

The "hidden photo has no indicator on the admin grid" warning was not a
missing badge. The badge markup has existed since #172; the defect was in
GET /:eventId/photos, which hand-builds its response literal field by field
and never emitted `visibility` -- so the value was always undefined and
neither the grid tile nor the list row badge could render. Same omission class
as the view_count/download_count bug already commented in that file.

(The `visibility` line itself was swept into 4721bd83, whose message does not
mention it -- recording that here.)

Fixes the adjacent instance too: `processing_status` is missing from the same
mapper, so the grid's "Processing…" and "Failed"/Retry placeholders could
never render either.

On the card, reuses the existing EyeOff badge pattern from the list-view rows,
adds a tooltip on both layouts, and drops the category badge to top-9 so the
hidden badge can own the top-left corner.

Also fixes the Photo Limit spinbutton's aria-valuemax, which read 0 even with
a real cap set. Root cause: min={0} with no max -- for input[type=number]
Blink's MaxValueForRange returns DBL_MAX, fails isfinite and supplies no max,
so a11y tooling prints the default 0. Set to 2147483647, the events.photo_cap
column's real signed-32-bit ceiling (migration 074), which also stops an
out-of-range value failing only at INSERT. The sibling expires_in_days input
already had proper bounds.

Known: EventInformationCard carries the identical Photo Limit input with the
same defect; it is held by another concurrent change and follows next.

Refs testplan REPORT.md, hidden-photo and aria-valuemax warnings.
This commit is contained in:
Paul Nothaft
2026-09-01 18:55:17 +02:00
parent 3acb452090
commit fe5ac9162d
5 changed files with 186 additions and 6 deletions
@@ -0,0 +1,36 @@
/**
* `GET /api/admin/photos/:eventId/photos` hand-rolls its response object
* field by field instead of spreading the row, so any column the admin UI
* reads has to be listed explicitly. `visibility` (#172) was missing, which
* meant the grid/list "Hidden" badge could never render and a photo hidden
* from clients looked identical to a visible one (QA warning) — the same
* class of omission that previously hid view_count / download_count.
*
* Source inspection rather than an HTTP round-trip: the defect is purely
* "the key isn't in the literal", and this needs no database.
*/
const fs = require('fs');
const path = require('path');
const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'adminPhotos.js'), 'utf8');
// The `res.json({ photos: photos.map(photo => ({ ... })) })` literal.
const LIST_MAPPER = /photos: photos\.map\(photo => \(\{([\s\S]*?)\n {6}\}\)\)/.exec(SOURCE);
describe('admin photo list mapper', () => {
it('has a recognisable photos.map() response literal', () => {
expect(LIST_MAPPER).not.toBeNull();
});
it.each([
'visibility',
'view_count',
'download_count',
])('exposes %s so the admin grid can render it', (field) => {
expect(LIST_MAPPER[1]).toContain(`${field}:`);
});
it('normalises visibility to the two values the UI switches on', () => {
expect(LIST_MAPPER[1]).toContain('visibility: photo.visibility === \'hidden\' ? \'hidden\' : \'visible\'');
});
});
+3
View File
@@ -1368,6 +1368,9 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
// so the admin grid's "Hidden" badge could never render and a photo
// hidden from clients looked identical to a visible one (QA warning).
visibility: photo.visibility === 'hidden' ? 'hidden' : 'visible',
// Same omission: the grid's "Processing…" and "Failed"/Retry
// placeholders read this, so neither could ever render either.
processing_status: photo.processing_status || 'complete',
category_id: photo.category_id || photo.type,
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.pc_slug || photo.type,
@@ -361,6 +361,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
const isHidden = (photo as any).visibility === 'hidden';
return (
<div
key={photo.id}
@@ -391,10 +392,19 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div>
</button>
{/* Visibility badge (#172) */}
{(photo as any).visibility === 'hidden' && (
<div className="absolute top-2 left-2 z-20">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
{/* Visibility badge (#172). Same badge vocabulary as the list
view's row badges — icon + short label, tooltip carrying the
explanation. It shares the top-left corner with the category
badge, so that one drops a row while this is showing. */}
{isHidden && (
<div
className="absolute top-2 left-2 z-20"
data-testid={`admin-photo-hidden-badge-${photo.id}`}
>
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium"
title={t('admin.photos.hiddenTooltip', 'Hidden from guests — this photo is not shown in the client gallery.') as string}
>
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
@@ -496,7 +506,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{/* Category Badge - move to top-left and prevent overlap with select checkbox */}
{photo.category_name && (
<div className="absolute left-2 top-2 pointer-events-none">
<div className={`absolute left-2 ${isHidden ? 'top-9' : 'top-2'} pointer-events-none`}>
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded max-w-[70%] whitespace-nowrap overflow-hidden text-ellipsis">
{photo.category_name}
</span>
@@ -700,7 +710,10 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</span>
)}
{isHidden && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-[10px] font-medium">
<span
className="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-[10px] font-medium"
title={t('admin.photos.hiddenTooltip', 'Hidden from guests — this photo is not shown in the client gallery.') as string}
>
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
@@ -0,0 +1,121 @@
/**
* A photo hidden from client access has to be distinguishable from a visible
* one on the admin Photos tab — otherwise the admin cannot tell what guests
* actually see (QA warning). Both layouts carry the same badge vocabulary:
* EyeOff icon + "Hidden" label + an explanatory tooltip.
*
* The badge markup already existed; what was missing was `visibility` in the
* list route's response (guarded separately in the backend suite). These
* tests pin the UI half of that contract.
*/
import { render, screen } 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 type { ReactElement, ReactNode } from 'react';
import { AdminPhotoGrid } from '../AdminPhotoGrid';
import type { AdminPhoto } from '../../../services/photos.service';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: any) =>
typeof fallback === 'string' ? fallback : _key,
i18n: { language: 'en' }
})
};
});
vi.mock('../AdminAuthenticatedImage', () => ({
AdminAuthenticatedImage: ({ alt }: { alt: string }) => <img alt={alt} />
}));
vi.mock('../../../services/photos.service', () => ({
photosService: {
formatBytes: (n: number) => `${n} B`
}
}));
vi.mock('../PermissionGate', () => ({
PermissionGate: ({ children }: { children: ReactNode }) => <>{children}</>
}));
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const basePhoto = {
path: '/x.jpg', url: '/x.jpg', thumbnail_url: '/t/x.jpg',
type: 'photo', category_id: null, category_slug: null,
size: 1234, uploaded_at: '2026-01-01T00:00:00Z'
};
const photos = [
{ ...basePhoto, id: 1, filename: 'hidden.jpg', category_name: 'Ceremony', visibility: 'hidden' },
{ ...basePhoto, id: 2, filename: 'visible.jpg', category_name: 'Ceremony', visibility: 'visible' }
] as unknown as AdminPhoto[];
const renderGrid = () =>
renderWithQueryClient(
<AdminPhotoGrid
photos={photos}
eventId={42}
onPhotoClick={vi.fn()}
onPhotosDeleted={vi.fn()}
/>
);
describe('AdminPhotoGrid hidden-photo indicator', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('badges only the hidden tile in grid view', () => {
renderGrid();
expect(screen.getByTestId('admin-photo-hidden-badge-1')).toBeInTheDocument();
expect(screen.queryByTestId('admin-photo-hidden-badge-2')).not.toBeInTheDocument();
});
it('carries a tooltip explaining what "hidden" means', () => {
renderGrid();
const badge = screen.getByTestId('admin-photo-hidden-badge-1').firstElementChild;
expect(badge).toHaveAttribute('title', expect.stringContaining('Hidden from guests'));
});
it('does not let the category badge cover the hidden badge', () => {
renderGrid();
// Both live in the tile's top-left corner; the category badge drops a
// row while the hidden badge is showing.
const tile = screen.getByTestId('admin-photo-tile-1');
const category = Array.from(tile.querySelectorAll('div')).find(
(node) => node.textContent === 'Ceremony' && node.className.includes('absolute')
);
expect(category?.className).toContain('top-9');
const visibleTile = screen.getByTestId('admin-photo-tile-2');
const visibleCategory = Array.from(visibleTile.querySelectorAll('div')).find(
(node) => node.textContent === 'Ceremony' && node.className.includes('absolute')
);
expect(visibleCategory?.className).toContain('top-2');
});
it('badges the hidden photo in list view too', async () => {
const user = userEvent.setup();
renderGrid();
await user.click(screen.getByRole('radio', { name: /list view/i }));
const hiddenRow = screen.getByTestId('admin-photo-row-1');
const visibleRow = screen.getByTestId('admin-photo-row-2');
expect(hiddenRow).toHaveTextContent('Hidden');
expect(visibleRow).not.toHaveTextContent('Hidden');
});
});
@@ -1059,11 +1059,18 @@ export const CreateEventPage: React.FC = () => {
</label>
<div className="flex items-center gap-2">
<div className="w-32">
{/* `max` is required, not cosmetic: without it Blink
reports the spin button's range as unbounded and the
a11y tree exposes aria-valuemax="0" (QA warning), and
an out-of-range value only fails at INSERT time. The
ceiling is the events.photo_cap column's own — a
signed 32-bit integer (migration 074). */}
<Input
type="number"
value={formData.photo_cap}
onChange={(e) => setFormData({ ...formData, photo_cap: parseInt(e.target.value) || 0 })}
min={0}
max={2147483647}
leftIcon={<Image className="w-5 h-5" />}
/>
</div>