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
|
// so the admin grid's "Hidden" badge could never render and a photo
|
||||||
// hidden from clients looked identical to a visible one (QA warning).
|
// hidden from clients looked identical to a visible one (QA warning).
|
||||||
visibility: photo.visibility === 'hidden' ? 'hidden' : 'visible',
|
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_id: photo.category_id || photo.type,
|
||||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||||
category_slug: photo.pc_slug || photo.type,
|
category_slug: photo.pc_slug || photo.type,
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
const isVideo = (photo.media_type === 'video') ||
|
const isVideo = (photo.media_type === 'video') ||
|
||||||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
|
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
|
||||||
photo.type === 'video';
|
photo.type === 'video';
|
||||||
|
const isHidden = (photo as any).visibility === 'hidden';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
@@ -391,10 +392,19 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Visibility badge (#172) */}
|
{/* Visibility badge (#172). Same badge vocabulary as the list
|
||||||
{(photo as any).visibility === 'hidden' && (
|
view's row badges — icon + short label, tooltip carrying the
|
||||||
<div className="absolute top-2 left-2 z-20">
|
explanation. It shares the top-left corner with the category
|
||||||
<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">
|
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" />
|
<EyeOff className="w-3 h-3" />
|
||||||
{t('admin.photos.hidden', 'Hidden')}
|
{t('admin.photos.hidden', 'Hidden')}
|
||||||
</span>
|
</span>
|
||||||
@@ -496,7 +506,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
|
|
||||||
{/* Category Badge - move to top-left and prevent overlap with select checkbox */}
|
{/* Category Badge - move to top-left and prevent overlap with select checkbox */}
|
||||||
{photo.category_name && (
|
{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">
|
<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}
|
{photo.category_name}
|
||||||
</span>
|
</span>
|
||||||
@@ -700,7 +710,10 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isHidden && (
|
{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" />
|
<EyeOff className="w-3 h-3" />
|
||||||
{t('admin.photos.hidden', 'Hidden')}
|
{t('admin.photos.hidden', 'Hidden')}
|
||||||
</span>
|
</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>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-32">
|
<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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.photo_cap}
|
value={formData.photo_cap}
|
||||||
onChange={(e) => setFormData({ ...formData, photo_cap: parseInt(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, photo_cap: parseInt(e.target.value) || 0 })}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={2147483647}
|
||||||
leftIcon={<Image className="w-5 h-5" />}
|
leftIcon={<Image className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user