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