diff --git a/client/src/api/client.ts b/client/src/api/client.ts index db14421f6..2f42296ef 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -26,7 +26,7 @@ import { type TripAddMemberRequest, type TripTransferOwnershipRequest, type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, - type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse, + type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse, type TripSearchResponse, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, type PlaceCreateRequest, type PlaceUpdateRequest, type PlaceImageFromFileRequest, type ReservationCreateRequest, type ReservationUpdateRequest, @@ -427,6 +427,7 @@ export const tripsApi = { delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData), searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data), + search: (q: string): Promise => apiClient.get('/trips/search', { params: { q } }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index c81ba427f..53bcc8ef3 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -1056,4 +1056,48 @@ describe('DashboardPage', () => { expect(card.querySelector('[aria-label="Archive"]')).toBeNull(); }); }); + + describe('FE-PAGE-DASH-2190: trip search', () => { + it('finds trips across filters by title and date, and by a place on them, then clears', async () => { + const oldTrip = buildTrip({ id: 77, title: 'Old Rome Trip', start_date: '2024-05-01', end_date: '2024-05-07', is_archived: 1 }); + const philly = buildTrip({ id: 78, title: 'East Coast', start_date: '2026-09-01', end_date: '2026-09-07' }); + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [oldTrip] }); + return HttpResponse.json({ trips: [buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' }), philly] }); + }), + http.get('/api/trips/search', ({ request }) => { + const q = new URL(request.url).searchParams.get('q'); + return HttpResponse.json({ matches: q === 'diner' ? [{ trip_id: 78, places: ["Dante's Diner"] }] : [] }); + }), + ); + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument()); + + const box = screen.getByRole('searchbox', { name: 'Search all trips by title, date or place' }); + await user.type(box, 'rome'); + // The archived trip shows up without switching the filter. + expect(await screen.findByText('Old Rome Trip')).toBeInTheDocument(); + + await user.clear(box); + await user.type(box, 'may 2024'); + expect(await screen.findByText('Old Rome Trip')).toBeInTheDocument(); + + await user.clear(box); + await user.type(box, 'diner'); + expect(await screen.findByText("Dante's Diner")).toBeInTheDocument(); + expect(screen.getByText('East Coast')).toBeInTheDocument(); + + await user.clear(box); + await user.type(box, 'zzzz'); + expect(await screen.findByText('No trip matches “zzzz”')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Clear search' })); + expect(box).toHaveValue(''); + expect(screen.queryByText('Old Rome Trip')).not.toBeInTheDocument(); + }); + }); }); + diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 9fa3c159a..f15a82136 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -19,7 +19,7 @@ import { import { Plus, Edit2, Trash2, Archive, ArchiveRestore, Copy, ArrowRight, MapPin, Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar, - LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare, LogIn, LogOut, + LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare, LogIn, LogOut, Search, } from 'lucide-react' import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal' import CollectionsWidget from '../components/Dashboard/CollectionsWidget' @@ -120,7 +120,7 @@ function DashboardPageDesktop(): React.ReactElement { showForm, setShowForm, editingTrip, setEditingTrip, deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, applyCoverUpdate, handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy, - allSubOpen, setAllSubOpen, + allSubOpen, setAllSubOpen, search, } = useDashboard() // Dashboard widget visibility (from the appearance config). Phones never reach this @@ -191,7 +191,24 @@ function DashboardPageDesktop(): React.ReactElement {

{t('dashboard.title')}

-
+ {/* Trip search (#2190): every trip, by title, date or a place on it */} + +
@@ -222,7 +239,13 @@ function DashboardPageDesktop(): React.ReactElement { {/* "No trips yet" only when there really are none — a user whose trips are all finished has a hero, and telling them to create their first trip is simply wrong (#1706). Same condition the mobile dashboard already uses. */} - {gridTrips.length === 0 && !spotlight && tripFilter === 'planned' && !isLoading && !loadError && ( + {search.active && gridTrips.length === 0 && ( +
+ +
+ )} + + {!search.active && gridTrips.length === 0 && !spotlight && tripFilter === 'planned' && !isLoading && !loadError && (
@@ -235,6 +258,7 @@ function DashboardPageDesktop(): React.ReactElement { trip={trip} locale={locale} badges={badgesFor(trip.id)} + matchedPlaces={search.active ? search.placeHits.get(trip.id) : undefined} onOpen={() => navigate(`/trips/${trip.id}`)} onEdit={() => { setEditingTrip(trip); setShowForm(true) }} onCopy={() => setCopyTrip(trip)} @@ -249,7 +273,7 @@ function DashboardPageDesktop(): React.ReactElement { )} - {tripFilter === 'planned' && !isLoading && ( + {tripFilter === 'planned' && !search.active && !isLoading && (