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.
This commit is contained in:
@@ -108,12 +108,17 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
const scenes = useMemo<CategoryScene[]>(() => {
|
||||
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
|
||||
|
||||
@@ -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<HTMLElement, Record<string, unknown>>(({ children, className, onClick }, ref) =>
|
||||
React.createElement(tag, { ref, className, onClick }, children as React.ReactNode)
|
||||
);
|
||||
return {
|
||||
motion: new Proxy({} as Record<string, unknown>, {
|
||||
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 }) => <img src={src} alt={alt} />,
|
||||
PoweredBy: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../PhotoLightbox', () => ({ PhotoLightbox: () => <div data-testid="lightbox" /> }));
|
||||
|
||||
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<number>(),
|
||||
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(<GalleryStoryLayout {...props} />);
|
||||
expect(search(container, 'photo-2')).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('still matches the stored/renamed filename', () => {
|
||||
const { container } = render(<GalleryStoryLayout {...props} />);
|
||||
expect(search(container, 'individual_0003')).toEqual(['3']);
|
||||
});
|
||||
|
||||
it('still matches the category name', () => {
|
||||
const { container } = render(<GalleryStoryLayout {...props} />);
|
||||
expect(search(container, 'ceremony')).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('returns nothing for a term present in neither name', () => {
|
||||
const { container } = render(<GalleryStoryLayout {...props} />);
|
||||
expect(search(container, 'nomatch')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -552,7 +552,7 @@ export const EventsListPage: React.FC = () => {
|
||||
{event.event_type}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'}
|
||||
{event.event_date ? format(parseISO(event.event_date)) : 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-right tabular-nums text-neutral-700 dark:text-neutral-300">
|
||||
{event.photo_count ?? 0}
|
||||
@@ -563,7 +563,7 @@ export const EventsListPage: React.FC = () => {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
|
||||
{event.expires_at ? format(parseISO(event.expires_at)) : 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user