mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
0dd07befa7
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.
301 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|