From 15fdd70a0865c085edd472349351861dcff66bed Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 19:12:18 +0200 Subject: [PATCH] fix(search): match the original filename, and honour the date-format setting Two warnings, both of which turned out to be mis-stated. Search: the name printed on every card is photos.original_filename (not source_filename, which is the replacement-stable ingest key and is not in the gallery payload at all), but search matched only the stored renamed filename. So a substring the admin or guest can literally read on screen returned zero results. Fixed on the admin Photos tab, which filters server-side -- grouped OR, because the feedback AND/OR conditions are appended immediately below and a bare orWhere would leak across them -- and on the Story theme's own scene filter, which is a second independent client-side search box. Dates: the warning read "Transfers uses DD/MM/YYYY while the rest of the app uses long-form dot dates", but it is inverted. TransfersPage already routes every date through useLocalizedDate and was correctly honouring the rig's own configured general_date_format of {"format":"DD/MM/YYYY","locale":"en-GB"}. The surfaces it was compared against are the ones ignoring the admin setting, by passing an explicit format string that overrides it. Dropped the hardcoded 'MMM d, yyyy' from the two EventsListPage table dates so they follow the setting like Transfers does. AdminHeader's format(new Date(), 'PPPP') is left as-is: that is the decorative "today" banner, where a long weekday form is a deliberate design choice rather than a data date, and forcing it to DD/MM/YYYY would read worse. Refs testplan REPORT.md, search-by-original-filename and transfers-date warnings. --- backend/src/routes/adminPhotos.js | 15 ++- .../gallery/layouts/GalleryStoryLayout.tsx | 15 ++- .../GalleryStoryLayout.search.test.tsx | 103 ++++++++++++++++++ frontend/src/pages/admin/EventsListPage.tsx | 4 +- 4 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 00ee79ee..1a8058c0 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -1226,12 +1226,17 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ query = query.where({ 'photos.type': type }); } - // Search by filename + // Search by filename. original_filename is included because that is the + // name printed on every card ("Original: …") — matching only the stored + // renamed filename returned 0 results for a substring the admin can read + // on screen. Grouped, because the feedback AND/OR conditions are appended + // right below and a bare orWhere would leak across them. if (search) { - query = query.whereRaw( - likeWithEscape('photos.filename'), - [`%${escapeLikePattern(search)}%`] - ); + const pattern = `%${escapeLikePattern(search)}%`; + query = query.where((qb) => { + qb.whereRaw(likeWithEscape('photos.filename'), [pattern]) + .orWhereRaw(likeWithEscape('photos.original_filename'), [pattern]); + }); } // Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index 2866284b..6f91e82e 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -108,12 +108,17 @@ export const GalleryStoryLayout: React.FC = ({ const scenes = useMemo(() => { const photosByCategory: PhotosByCategory = {}; - // Filter by search query + // Filter by search query. `original_filename` is in here because that is + // the camera name the guest actually sees on the card/lightbox — matching + // only the internal renamed `filename` gave "no results" for a substring + // the guest could read on screen (QA P4-B.02). const filteredPhotos = searchQuery - ? photos.filter(p => - p.filename.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.category_name && p.category_name.toLowerCase().includes(searchQuery.toLowerCase())) - ) + ? photos.filter(p => { + const term = searchQuery.toLowerCase(); + return p.filename.toLowerCase().includes(term) || + (p.original_filename?.toLowerCase().includes(term) ?? false) || + (p.category_name && p.category_name.toLowerCase().includes(term)); + }) : photos; // Group by category diff --git a/frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx b/frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx new file mode 100644 index 00000000..b82503f4 --- /dev/null +++ b/frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx @@ -0,0 +1,103 @@ +/** + * Gallery search matched only the internal, renamed `filename`, never the + * camera `original_filename` the guest can actually read on the card and in + * the lightbox. Typing a substring of the visible name returned "no photos + * found" for a photo sitting right there (QA P4-B.02 / G.08). + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { GalleryStoryLayout } from '../GalleryStoryLayout'; +import type { Photo } from '../../../../types'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => (typeof fallback === 'string' ? fallback : key), + }), +})); + +vi.mock('framer-motion', () => { + const stub = (tag: string) => + React.forwardRef>(({ children, className, onClick }, ref) => + React.createElement(tag, { ref, className, onClick }, children as React.ReactNode) + ); + return { + motion: new Proxy({} as Record, { + get: (cache, tag: string) => (cache[tag] ??= stub(tag)), + }), + AnimatePresence: ({ children }: { children?: React.ReactNode }) => <>{children}, + useInView: () => true, + }; +}); + +vi.mock('../../../common', () => ({ + AuthenticatedImage: ({ src, alt }: { src: string; alt?: string }) => {alt}, + PoweredBy: () => null, +})); + +vi.mock('../../PhotoLightbox', () => ({ PhotoLightbox: () =>
})); + +vi.mock('../../../../services/feedback.service', () => ({ + feedbackService: { submitFeedback: vi.fn().mockResolvedValue({}) }, +})); +vi.mock('../../../../services/gallery.service', () => ({ + galleryService: { downloadSelectedPhotos: vi.fn() }, +})); +vi.mock('../../../../services/analytics.service', () => ({ + analyticsService: { trackGalleryEvent: vi.fn() }, +})); + +// Mirrors a real upload: the stored name is the renamed one, the camera name +// survives in original_filename and is what the guest sees. +const photos: Photo[] = [1, 2, 3].map((i) => ({ + id: i, + filename: `ZZTEST-Hochzeit_individual_000${i}_a1b2c3.jpg`, + original_filename: `zztest-photo-${i}.jpg`, + url: `/api/gallery/x/photo/${i}`, + thumbnail_url: `/api/gallery/x/thumbnail/${i}`, + type: 'individual', + size: 1, + uploaded_at: '2026-01-01T00:00:00Z', + category_name: 'Ceremony', +} as Photo)); + +const props = { + photos, + slug: 'x', + eventName: 'Sarah & Tom', + onPhotoClick: () => {}, + onDownload: () => {}, + selectedPhotos: new Set(), + isSelectionMode: false, + allowDownloads: true, +} as never; + +function search(container: HTMLElement, term: string) { + fireEvent.change(screen.getByPlaceholderText('Search memories...'), { target: { value: term } }); + return Array.from(container.querySelectorAll('a[data-photo-id]')).map((a) => + a.getAttribute('data-photo-id') + ); +} + +describe('GalleryStoryLayout search', () => { + it('matches a substring of the visible original filename', () => { + const { container } = render(); + expect(search(container, 'photo-2')).toEqual(['2']); + }); + + it('still matches the stored/renamed filename', () => { + const { container } = render(); + expect(search(container, 'individual_0003')).toEqual(['3']); + }); + + it('still matches the category name', () => { + const { container } = render(); + expect(search(container, 'ceremony')).toEqual(['1', '2', '3']); + }); + + it('returns nothing for a term present in neither name', () => { + const { container } = render(); + expect(search(container, 'nomatch')).toEqual([]); + }); +}); diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index 0d631c34..f6cfc507 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -552,7 +552,7 @@ export const EventsListPage: React.FC = () => { {event.event_type} - {event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'} + {event.event_date ? format(parseISO(event.event_date)) : 'N/A'} {event.photo_count ?? 0} @@ -563,7 +563,7 @@ export const EventsListPage: React.FC = () => { - {event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'} + {event.expires_at ? format(parseISO(event.expires_at)) : 'N/A'} e.stopPropagation()}>