Files
TREK/client/src/components/Collections/CollectionPicker.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

301 lines
14 KiB
TypeScript

// FE-COMP-COLPICKER-001 to FE-COMP-COLPICKER-021
import React from 'react'
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
import type { Collection, CollectionDetailResponse, CollectionPlace } from '@trek/shared'
import { collectionsApi } from '../../api/collections'
import { useAuthStore } from '../../store/authStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser } from '../../../tests/helpers/factories'
import { useTranslation } from '../../i18n/TranslationContext'
import CollectionPicker from './CollectionPicker'
type Props = React.ComponentProps<typeof CollectionPicker>
function Harness(props: Omit<Props, 't'>): React.ReactElement {
const { t } = useTranslation()
return <CollectionPicker {...props} t={t} />
}
const listA: Collection = { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }
const listB: Collection = { id: 2, owner_id: 1, name: 'Rome', color: null }
function place(over: Partial<CollectionPlace> & { id: number; collection_id: number; name: string }): CollectionPlace {
return { status: 'idea', ...over }
}
// Zebra Cafe sits far from the bias box, Alpha Bar right inside it — so the
// alphabetical and the proximity order are deliberately opposites.
const zebra = place({ id: 10, collection_id: 1, name: 'Zebra Cafe', address: 'Shibuya 1', lat: 35.6, lng: 139.7 })
const alpha = place({ id: 11, collection_id: 2, name: 'Alpha Bar', address: 'Trastevere 9', lat: 41.9, lng: 12.5, category: { id: 3, name: 'Bar', color: '#f59e0b', icon: 'beer' } })
const noCoords = place({ id: 12, collection_id: 2, name: 'Mystery Spot', status: 'visited' })
function detail(collection: Collection, places: CollectionPlace[]): CollectionDetailResponse {
return { collection, places }
}
function setup(over: Partial<Omit<Props, 't'>> = {}) {
const props: Omit<Props, 't'> = { onSelect: vi.fn(), ...over }
const view = render(<Harness {...props} />)
return { ...view, props }
}
describe('CollectionPicker', () => {
beforeEach(() => {
resetAllStores()
// Keeps PlaceAvatar from reaching for provider photos in jsdom.
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false })
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [listA, listB], incomingInvites: [] })
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
id === 1 ? detail(listA, [zebra]) : detail(listB, [alpha, noCoords]),
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-COMP-COLPICKER-017: shows the first ten and offers the rest behind a button', async () => {
// Saved places accumulate across every list, so the panel used to scroll
// without end.
const many = Array.from({ length: 23 }, (_, i) =>
place({ id: 100 + i, collection_id: 1, name: `Place ${String(i).padStart(2, '0')}` }),
)
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
id === 1 ? detail(listA, many) : detail(listB, []),
)
setup()
expect(await screen.findByText('Place 00')).toBeInTheDocument()
expect(screen.getByText('Place 09')).toBeInTheDocument()
expect(screen.queryByText('Place 10')).not.toBeInTheDocument()
const more = () => screen.getByRole('button', { name: /show .* more/i })
fireEvent.click(more())
expect(screen.getByText('Place 19')).toBeInTheDocument()
expect(screen.queryByText('Place 20')).not.toBeInTheDocument()
fireEvent.click(more())
expect(screen.getByText('Place 22')).toBeInTheDocument()
// Nothing left to ask for.
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-018: searching resets the page and looks at every place, not just the visible ten', async () => {
const many = Array.from({ length: 23 }, (_, i) =>
place({ id: 100 + i, collection_id: 1, name: `Place ${String(i).padStart(2, '0')}` }),
)
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
id === 1 ? detail(listA, many) : detail(listB, []),
)
setup()
await screen.findByText('Place 00')
// "Place 22" is well past the first page; the filter has to reach it.
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'Place 22' } })
expect(screen.getByText('Place 22')).toBeInTheDocument()
expect(screen.queryByText('Place 00')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-001: shows a spinner until the lists have loaded', async () => {
const { container } = setup()
expect(container.querySelector('.animate-spin')).toBeTruthy()
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
expect(container.querySelector('.animate-spin')).toBeFalsy()
})
it('FE-COMP-COLPICKER-002: merges the places of every list and shows their addresses', async () => {
setup()
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
expect(screen.getByText('Shibuya 1')).toBeInTheDocument()
expect(screen.getByText('Saved places')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-003: sorts alphabetically without a bias box', async () => {
setup()
await screen.findByText('Zebra Cafe')
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
expect(names).toEqual(['Alpha BarTrastevere 9', 'Mystery Spot', 'Zebra CafeShibuya 1'])
})
it('FE-COMP-COLPICKER-004: a bias box sorts by proximity and pushes coordinate-less places last', async () => {
setup({ bias: { low: { lat: 35.5, lng: 139.6 }, high: { lat: 35.7, lng: 139.8 } } })
await screen.findByText('Zebra Cafe')
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
expect(names).toEqual(['Zebra CafeShibuya 1', 'Alpha BarTrastevere 9', 'Mystery Spot'])
})
it('FE-COMP-COLPICKER-005: picking a place hands the whole record to onSelect', async () => {
const { props } = setup()
fireEvent.click(await screen.findByRole('button', { name: /Zebra Cafe/ }))
expect(props.onSelect).toHaveBeenCalledTimes(1)
expect(props.onSelect).toHaveBeenCalledWith(zebra)
})
it('FE-COMP-COLPICKER-006: the search box matches on name', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'zebra' } })
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-007: the search box also matches on address', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'trastevere' } })
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-008: a search with no hits falls back to the empty copy', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'nothing here' } })
expect(screen.getByText('No saved places to add')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-009: the list dropdown counts each list and filters to it', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
const menu = screen.getByRole('listbox')
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveTextContent('3')
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveTextContent('2')
fireEvent.click(within(menu).getByRole('option', { name: /Tokyo 2026/ }))
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
// The trigger adopts the picked option and the menu closes.
expect(screen.getByRole('button', { name: /Tokyo 2026/ })).toBeInTheDocument()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-010: the status dropdown filters by saved status', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /Status.*All/ }))
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-011: the selected option is flagged for assistive tech', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
const menu = screen.getByRole('listbox')
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveAttribute('aria-selected', 'true')
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveAttribute('aria-selected', 'false')
})
it('FE-COMP-COLPICKER-012: a click outside closes an open dropdown', async () => {
setup()
await screen.findByText('Zebra Cafe')
const trigger = screen.getByRole('button', { name: /All lists/ })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
fireEvent.mouseDown(document.body)
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
})
it('FE-COMP-COLPICKER-013: a mousedown inside the dropdown keeps it open', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
fireEvent.mouseDown(screen.getByRole('listbox'))
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-014: Escape closes an open dropdown', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
// A different key leaves it alone.
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
fireEvent.keyDown(document, { key: 'a' })
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-015: a list whose detail request fails is skipped, the rest still load', async () => {
vi.mocked(collectionsApi.get).mockImplementation(async (id: number) => {
if (id === 1) throw new Error('403')
return detail(listB, [alpha])
})
setup()
expect(await screen.findByText('Alpha Bar')).toBeInTheDocument()
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-016: a failing list request degrades to the empty state without filters', async () => {
vi.mocked(collectionsApi.list).mockRejectedValue(new Error('offline'))
setup()
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-017: with no lists at all the filter row stays hidden', async () => {
vi.mocked(collectionsApi.list).mockResolvedValue({ collections: [], incomingInvites: [] })
setup()
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
expect(collectionsApi.get).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
})
})
/**
* FE-COMP-COLPICKER-019..020 — the column in the place dialog's look: a labelled
* search that says what it searches, and rows named by what they show.
*/
describe('CollectionPicker in the place dialog', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false })
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [listA, listB], incomingInvites: [] })
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
id === 1 ? detail(listA, [zebra]) : detail(listB, [alpha, noCoords]),
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-COMP-COLPICKER-019: the search has its own label and is named after the saved places', async () => {
setup()
await screen.findByText('Zebra Cafe')
const search = screen.getByRole('textbox', { name: 'Search your saved places' })
expect(search).toBe(screen.getByPlaceholderText('Search your saved places'))
expect(screen.getByText('Search', { selector: 'label' })).toHaveAttribute('for', search.id)
expect(screen.getByRole('heading', { name: 'Saved places' })).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-020: a row carries no native tooltip; its name and address say what it adds', async () => {
setup()
const row = await screen.findByRole('button', { name: /Zebra Cafe/ })
expect(row).not.toHaveAttribute('title')
expect(row).toHaveTextContent('Zebra CafeShibuya 1')
})
it('FE-COMP-COLPICKER-021: each filter has its own label, which names its trigger and its list', async () => {
setup()
await screen.findByText('Zebra Cafe')
const lists = screen.getByRole('button', { name: 'Collections All lists' })
const status = screen.getByRole('button', { name: 'Status All' })
expect(screen.getByText('Collections', { selector: 'span' })).toHaveAttribute('id', lists.getAttribute('aria-labelledby')?.split(' ')[0])
fireEvent.click(status)
expect(screen.getByRole('listbox', { name: 'Status' })).toBeInTheDocument()
// Picking changes the value half of the name, never the label half.
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
expect(screen.getByRole('button', { name: /^Status Visited/ })).toBe(status)
})
})