// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -124 import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { collectionsApi } from '../../api/collections'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useAddonStore } from '../../store/addonStore'; import { usePermissionsStore } from '../../store/permissionsStore'; import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories'; import PlaceFormModal from './PlaceFormModal'; // Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI. // The aria props land on the input, as the real picker spreads them onto its own. vi.mock('../shared/CustomTimePicker', () => ({ default: ({ value, onChange, placeholder, ...aria }: { value: string onChange: (v: string) => void placeholder?: string 'aria-label'?: string 'aria-describedby'?: string 'aria-invalid'?: boolean }) => ( onChange(e.target.value)} placeholder={placeholder ?? '00:00'} /> ), })); const defaultProps = { isOpen: true, onClose: vi.fn(), onSave: vi.fn(), place: null, prefillCoords: null, tripId: 1, categories: [], onCategoryCreated: vi.fn(), assignmentId: null, dayAssignments: [], }; beforeEach(() => { resetAllStores(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); }); describe('PlaceFormModal', () => { it('FE-COMP-PLACEFORM-2472: an empty search offers adding the place by hand and takes the query as the name', async () => { const user = userEvent.setup(); server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'osm' }))); render(); const search = screen.getByPlaceholderText(/Search places/i); await user.type(search, 'Tiny Garage Cafe'); await user.click(screen.getByRole('button', { name: 'Search' })); expect(await screen.findByText('Nothing found for “Tiny Garage Cafe”')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Add by hand' })); expect(screen.queryByText('Nothing found for “Tiny Garage Cafe”')).not.toBeInTheDocument(); // The search field keeps the query and the name field now holds it too. expect(screen.getAllByDisplayValue('Tiny Garage Cafe')).toHaveLength(2); expect(screen.getByLabelText('Phone')).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-001: renders modal when isOpen is true', () => { render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-002: shows Add Place title for new place', () => { render(); // places.addPlace = "Add Place/Activity" expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0); }); it('FE-COMP-PLACEFORM-003: shows Edit Place title when editing', () => { const place = buildPlace({ name: 'Eiffel Tower' }); render(); expect(screen.getByText('Edit Place')).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-004: shows Name field with placeholder', () => { render(); expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-005: shows Description field', () => { render(); expect(screen.getByPlaceholderText(/Short description/i)).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-006: shows Address field', () => { render(); expect(screen.getByPlaceholderText(/Street, City, Country/i)).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-007: shows Add button for new place', () => { render(); expect(screen.getByRole('button', { name: /^Add$/i })).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-008: shows Update button when editing', () => { const place = buildPlace({ name: 'Test Place' }); render(); expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-009: shows Cancel button', () => { render(); expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-010: clicking Cancel calls onClose', async () => { const user = userEvent.setup(); const onClose = vi.fn(); render(); await user.click(screen.getByRole('button', { name: /Cancel/i })); expect(onClose).toHaveBeenCalled(); }); it('FE-COMP-PLACEFORM-011: pre-fills name field when editing existing place', () => { const place = buildPlace({ name: 'Notre Dame' }); render(); const nameInput = screen.getByDisplayValue('Notre Dame'); expect(nameInput).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-012: pre-fills address when editing existing place', () => { const place = buildPlace({ name: 'Test', address: '123 Main St' }); render(); expect(screen.getByDisplayValue('123 Main St')).toBeInTheDocument(); }); it('FE-COMP-PLACEFORM-013: submitting empty form does not call onSave (name required)', async () => { const user = userEvent.setup(); const onSave = vi.fn(); render(); await user.click(screen.getByRole('button', { name: /^Add$/i })); // Form validation prevents calling onSave without a name expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-PLACEFORM-014: typing in name field and submitting calls onSave', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Sacre Coeur'); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sacre Coeur' })); }); it('FE-COMP-PLACEFORM-015: categories appear in category selector', () => { const cats = [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Park' })]; render(); // The category is a pill on the head band, named by what it picks. expect(screen.getByRole('button', { name: /^Category:/ })).toBeInTheDocument(); }); // ── Form initialization ────────────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-016: prefillCoords populates lat/lng/name', () => { render( , ); expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument(); expect(screen.getByDisplayValue('Paris')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-092: a plugin POI prefill asks the details column about its plugin id and saves it as the osm id', async () => { const asked: unknown[] = []; server.use( http.post('/api/maps/enrichment', async ({ request }) => { asked.push(await request.json()); return HttpResponse.json({ photos: [], description: null, facts: [], rating: null, hours: null }); }), ); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); render( , ); expect(screen.getByDisplayValue('Trailhead')).toBeInTheDocument(); expect(screen.getByDisplayValue('https://trails.example/th-092')).toBeInTheDocument(); // The server picks the provider by this id and never takes a `plugin:` one for Google's. await waitFor(() => expect(asked).toEqual([expect.objectContaining({ placeId: 'plugin:trail-finder:th-092' })])); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Trailhead', osm_id: 'plugin:trail-finder:th-092' })); expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ google_place_id: expect.anything() })); }); it('FE-PLANNER-PLACEFORM-017: form resets when isOpen changes from place to null', () => { const place = buildPlace({ name: 'Old Place' }); const { rerender } = render(); expect(screen.getByDisplayValue('Old Place')).toBeInTheDocument(); rerender(); expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-088: closing the dialog clears the search field', async () => { const user = userEvent.setup(); const { rerender } = render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel Tower'); rerender(); rerender(); expect(screen.getByPlaceholderText('Search places...')).toHaveValue(''); }); it('FE-PLANNER-PLACEFORM-089: closing the dialog drops the result list and its Google line', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true }); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }), ), ); const { rerender } = render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Tokyo Station'); await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); expect(await screen.findByText('Weigh station')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Search Google instead/ })).toBeInTheDocument(); rerender(); rerender(); expect(screen.getByPlaceholderText('Search places...')).toHaveValue(''); expect(screen.queryByText('Weigh station')).toBeNull(); expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); }); it('FE-PLANNER-PLACEFORM-090: suggestions still on their way when the dialog closes never open over the next one', async () => { const user = userEvent.setup(); const gate: { started: boolean; release?: () => void } = { started: false }; server.use( http.post('/api/maps/autocomplete', async () => { gate.started = true; await new Promise(res => { gate.release = res; }); return HttpResponse.json({ suggestions: [{ placeId: 'late-1', mainText: 'Late Suggestion', secondaryText: 'Nowhere' }], source: 'trek-places' }); }), ); const { rerender } = render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Late'); await waitFor(() => expect(gate.started).toBe(true)); rerender(); gate.release?.(); await act(async () => { await new Promise(res => setTimeout(res, 100)); }); rerender(); expect(screen.queryByText('Late Suggestion')).toBeNull(); }); it('FE-PLANNER-PLACEFORM-091: a search that answers after the dialog closed leaves the next opening empty', async () => { const user = userEvent.setup(); const gate: { started: boolean; release?: () => void } = { started: false }; server.use( http.post('/api/maps/search', async () => { gate.started = true; await new Promise(res => { gate.release = res; }); return HttpResponse.json({ places: [{ name: 'Late Result', address: 'Nowhere', lat: '1', lng: '2' }] }); }), ); const { rerender } = render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Late'); await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); await waitFor(() => expect(gate.started).toBe(true)); rerender(); gate.release?.(); await act(async () => { await new Promise(res => setTimeout(res, 100)); }); rerender(); expect(screen.queryByText('Late Result')).toBeNull(); // The button is back to its icon rather than the "..." of a search in flight. expect(within(screen.getByPlaceholderText('Search places...').closest('.flex') as HTMLElement).getByRole('button')).not.toHaveTextContent('...'); }); // ── Maps search ────────────────────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-018: maps search populates results via button click', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }], }), ), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiffel Tower'); // The search button is the sibling button of the search input const searchRow = searchInput.closest('.flex') as HTMLElement; const searchBtn = within(searchRow).getByRole('button'); await user.click(searchBtn); expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-018c: a pin lists the places around it, nearest first with their distance, and one pick fills the form', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true }); const bodies: Record[] = []; server.use( http.post('/api/maps/nearby', async ({ request }) => { bodies.push((await request.json()) as Record); return HttpResponse.json({ places: [{ name: 'Café am Tor', address: 'Pariser Platz 1', lat: 52.5163, lng: 13.378, distance_m: 40 }], source: 'trek-places', }); }), ); render(); await user.click(screen.getByRole('button', { name: 'Places near this pin' })); expect(bodies).toEqual([{ lat: 52.5163, lng: 13.3777 }]); expect(await screen.findByText('Café am Tor')).toBeInTheDocument(); expect(screen.getByText('40 m')).toBeInTheDocument(); // No typed query, so nothing to send to Google instead. expect(screen.queryByText('Not the right place? Search Google instead')).not.toBeInTheDocument(); await user.click(screen.getByText('Café am Tor')); expect(screen.getByDisplayValue('Pariser Platz 1')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-018d: without a pin there is nothing to look around', () => { render(); expect(screen.queryByRole('button', { name: 'Places near this pin' })).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-018b: a list the index answered offers Google instead, and the link sends the same query there alone', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true }); const bodies: Record[] = []; server.use( http.post('/api/maps/search', async ({ request }) => { const body = (await request.json()) as Record; bodies.push(body); return body.provider === 'google' ? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' }) : HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Tokyo Station'); const searchRow = searchInput.closest('.flex') as HTMLElement; await user.click(within(searchRow).getByRole('button')); expect(await screen.findByText('Weigh station')).toBeInTheDocument(); // The quiet line under the list, only because the instance has a key and // this list did not come from Google. const retry = screen.getByRole('button', { name: /Search Google instead/ }); await user.click(retry); expect(await screen.findByText('Tokyo Station')).toBeInTheDocument(); expect(bodies[0]).not.toHaveProperty('provider'); expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' }); // A list Google produced has nowhere further to go. expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); }); it('FE-PLANNER-PLACEFORM-018c: without a Google key the list offers nothing', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }), ), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Tokyo Station'); await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); expect(await screen.findByText('Weigh station')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); }); it('FE-PLANNER-PLACEFORM-018d: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => { // The server only honours the request while Google holds the keyed slot; // under another provider the link would re-run the same search and stay. const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, placesProvider: 'openstreetmap' }); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }), ), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Tokyo Station'); await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); expect(await screen.findByText('Weigh station')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); }); it('FE-PLANNER-PLACEFORM-018e: the link sends the query the list came from, not what the field holds by then', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true }); const bodies: Record[] = []; server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })), http.post('/api/maps/search', async ({ request }) => { const body = (await request.json()) as Record; bodies.push(body); return body.provider === 'google' ? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' }) : HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Tokyo Station'); await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); expect(await screen.findByText('Weigh station')).toBeInTheDocument(); // The list stays while the field is retyped; the line still means this list. await user.clear(searchInput); await user.type(searchInput, 'Kyoto'); await user.click(screen.getByRole('button', { name: /Search Google instead/ })); expect(await screen.findByText('Tokyo Station')).toBeInTheDocument(); expect(bodies).toHaveLength(2); expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' }); }); it('FE-PLANNER-PLACEFORM-018f: the link still works after the field was cleared', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true }); const bodies: Record[] = []; server.use( http.post('/api/maps/search', async ({ request }) => { const body = (await request.json()) as Record; bodies.push(body); return body.provider === 'google' ? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' }) : HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Tokyo Station'); await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); expect(await screen.findByText('Weigh station')).toBeInTheDocument(); // An empty field used to make the click a silent no-op. await user.clear(searchInput); await user.click(screen.getByRole('button', { name: /Search Google instead/ })); expect(await screen.findByText('Tokyo Station')).toBeInTheDocument(); expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' }); }); it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }], }), ), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiffel Tower'); await user.keyboard('{Enter}'); expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-020: clicking a maps result fills the form', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }], }), ), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiffel Tower'); await user.keyboard('{Enter}'); const resultBtn = await screen.findByText('Eiffel Tower'); await user.click(resultBtn); expect(screen.getByDisplayValue('Eiffel Tower')).toBeInTheDocument(); expect(screen.getByDisplayValue('48.8584')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-021: maps search error surfaces the server-provided reason', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); // The backend forwards the real upstream error (e.g. a Google Places API message); // the modal must show it instead of a generic "search failed" so the cause is visible. server.use( http.post('/api/maps/search', () => HttpResponse.json({ error: 'Places API (New) has not been used in project 123 or it is disabled' }, { status: 403 })), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'someplace'); await user.keyboard('{Enter}'); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringMatching(/Places API \(New\) has not been used/i), 'error', undefined, ); }); delete window.__addToast; }); // ── Autocomplete suggestion click (#1192) ───────────────────────────────────── // Selecting a dropdown suggestion does a second `details` lookup which is fragile // (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When // it yields no usable place the modal must fall back to the reliable text search // instead of dead-ending on "Place search failed". async function openSuggestion(user: ReturnType) { const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiffel'); // Debounced autocomplete (300ms) then the dropdown renders the suggestion. return screen.findByText('Paris, France'); } it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], source: 'nominatim', }), ), // details rejects (e.g. proxy 504 from a hung Overpass mirror) http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }], source: 'openstreetmap', }), ), ); render(); const suggestion = await openSuggestion(user); await user.click(suggestion); // Form is populated from the search fallback, and no error toast is shown. expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument(); expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument(); expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything()); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => { // The layer's second line is the name written on the building, not an // address. Joining the two and searching for it asks a question nobody // typed, and whatever came back first was taken as the place the user had // already picked. The row carries coordinates, so the fallback uses those. const user = userEvent.setup(); let searched = 0; server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅丸の内駅舎', source: 'openstreetmap', lat: 35.6811816, lng: 139.76598265, }], source: 'trek-places', }), ), http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })), http.post('/api/maps/search', () => { searched += 1; return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' }); }), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio'); await user.click(await screen.findByText('Tokio Hauptbahnhof')); expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument(); expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument(); expect(searched).toBe(0); }); it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], source: 'nominatim', }), ), http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })), http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }], source: 'openstreetmap', }), ), ); render(); const suggestion = await openSuggestion(user); await user.click(suggestion); expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], source: 'nominatim', }), ), http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })), http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })), ); render(); const suggestion = await openSuggestion(user); await user.click(suggestion); await waitFor(() => { expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined); }); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => { // It used to say "Using OpenStreetMap" above the box whenever no Google key // was set, which was a sentence about configuration in the middle of a form // about a place. Each result names its own source now, which is the question // people actually had. render(); expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], source: 'trek-places', }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); expect(await screen.findByText('TREK')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => { // The keystroke path asks both indexes at once, so the name the response // carries for the whole list ('trek-places') is true of the call and wrong // for half the rows. Marking every suggestion TREK is how a list that did // contain OpenStreetMap places read as if it never had. const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [ { placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' }, { placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' }, ], source: 'trek-places', }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station'); expect(await screen.findByText('TREK')).toBeInTheDocument(); expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => { // The whole point of putting it on the row: with both indexes answering, // a label above the list could only name one of them. The index marks its // own rows, so the unmarked one is OpenStreetMap by elimination. const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [ { name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' }, { name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' }, ], source: 'trek-places+openstreetmap', }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await user.keyboard('{Enter}'); expect(await screen.findByText('TREK')).toBeInTheDocument(); expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-022d: an Amap list marks its rows, and a pick keeps the Amap id', async () => { // Amap answers from the slot Google otherwise holds, so its rows get the // same mark the other sources get. The id travels with the place: it is // what lets the saved place keep opening against Amap after the admin // switches providers. const user = userEvent.setup(); const onSave = vi.fn(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [ { name: '天安门', address: '北京市东城区', lat: '39.9087', lng: '116.3975', amap_poi_id: 'amap:B000A7BD6C', source: 'amap' }, ], source: 'amap', }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Tiananmen'); await user.keyboard('{Enter}'); expect(await screen.findByText('Amap')).toBeInTheDocument(); await user.click(screen.getByText('天安门')); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave.mock.calls[0][0].amap_poi_id).toBe('amap:B000A7BD6C'); }); // ── Category ───────────────────────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-023: category selector renders options', () => { // The component conditionally shows CustomSelect (showNewCategory=false) or text input // Default state shows CustomSelect; no visible "+" trigger exists in current code const cats = [buildCategory({ name: 'Beaches' }), buildCategory({ name: 'Museums' })]; render(); // The "No category" placeholder text from CustomSelect should be visible expect(screen.getByText(/No category/i)).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => { // Regression: form.category_id is a string but the option values were numbers, // so CustomSelect's strict-equality match failed and the trigger fell back to // "No category". With string option values the chosen category renders. const cat = buildCategory({ name: 'Museums' }); const place = buildPlace({ name: 'Louvre', category_id: cat.id }); render(); // Dropdown is closed, so the only place the category name can appear is the trigger. expect(screen.getByText('Museums')).toBeInTheDocument(); expect(screen.queryByText(/No category/i)).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => { const user = userEvent.setup(); const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); render(); expect(onCategoryCreated).not.toHaveBeenCalled(); // The plus next to the category select swaps in the inline name field. await user.click(screen.getByRole('button', { name: 'New category' })); await user.type(screen.getByPlaceholderText('Category name'), 'Beaches'); await user.click(screen.getByRole('button', { name: 'OK' })); expect(onCategoryCreated).toHaveBeenCalledWith({ name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); // The new category is selected and the inline field closes again. await waitFor(() => expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument()); }); it('FE-PLANNER-PLACEFORM-024b: an empty category name does not call onCategoryCreated', async () => { const user = userEvent.setup(); const onCategoryCreated = vi.fn(); render(); await user.click(screen.getByRole('button', { name: 'New category' })); await user.click(screen.getByRole('button', { name: 'OK' })); expect(onCategoryCreated).not.toHaveBeenCalled(); const inlineRow = screen.getByPlaceholderText('Category name').parentElement!; await user.click(within(inlineRow).getByRole('button', { name: 'Cancel' })); expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument(); }); // ── Time section (edit mode only) ──────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-025: time section is NOT shown in create mode', () => { render(); // English labels are 'Start' and 'End' (places.startTime / places.endTime) expect(screen.queryByText(/^Start$/i)).not.toBeInTheDocument(); expect(screen.queryByText(/^End$/i)).not.toBeInTheDocument(); // Also verify no time pickers rendered expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => { // Times are per day-assignment; editing a pool place with no day in context // (assignmentId null) hides the fields, which otherwise would not persist (#1247) const place = buildPlace({ name: 'Test' }); render(); expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => { const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' }); const assignment = buildAssignment({ id: 10, day_id: 5, place }); render(); expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2); }); it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => { // The pool Place carries no times — they live on the day-assignment. Opening the // editor with an assignmentId must hydrate the fields from assignment.place, not // the (timeless) pool place that the Places panel passes in. const poolPlace = buildPlace({ id: 7, name: 'Museum' }); const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' }); const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace }); render(); expect(screen.getByDisplayValue('20:20')).toBeInTheDocument(); expect(screen.getByDisplayValue('20:34')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-086: on a road trip the End says the drive leaves at it', () => { const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' }); const assignment = buildAssignment({ id: 10, day_id: 5, place }); render(); expect(screen.getByText('On the road trip, the drive leaves at this time.')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-087: in Days the End stays a plain label', () => { const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' }); const assignment = buildAssignment({ id: 10, day_id: 5, place }); render(); expect(screen.queryByText('On the road trip, the drive leaves at this time.')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => { // Build an assignment whose place has end_time before place_time const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' }); const assignment = buildAssignment({ id: 11, day_id: 5, place }); render(); // hasTimeError = true → submit button disabled const submitBtn = screen.getByRole('button', { name: /^Update$/i }); expect(submitBtn).toBeDisabled(); }); it('FE-PLANNER-PLACEFORM-028: time collision warning appears when assignments overlap', () => { // Create an assignment for the "current" place being edited const currentPlace = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' }); const conflictingPlace = buildPlace({ name: 'Other Event', place_time: '13:00', end_time: '14:00' }); const currentAssignment = buildAssignment({ id: 10, day_id: 5, place: currentPlace }); const otherAssignment = buildAssignment({ id: 20, day_id: 5, place: conflictingPlace }); render( , ); // English translation: 'places.timeCollision' = 'Time overlap with:' expect(screen.getByText(/Time overlap with:/i)).toBeInTheDocument(); }); // ── File attachments ────────────────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-029: file attachment section shown when canUploadFiles=true', () => { // Default: permissions={} → not configured → allow → canUploadFiles=true render(); expect(screen.getByText('Attach')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-030: file attachment section hidden when canUploadFiles=false', () => { // Set file_upload to 'admin' level; non-admin user cannot upload seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } }); render(); expect(screen.queryByText('Attach')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => { render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; expect(fileInput).toBeTruthy(); const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); fireEvent.change(fileInput, { target: { files: [file] } }); await screen.findByText('photo.jpg'); }); it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => { const user = userEvent.setup(); render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' }); fireEvent.change(fileInput, { target: { files: [file] } }); await screen.findByText('remove-me.jpg'); // The X button is inside the file item's container div const fileItem = screen.getByText('remove-me.jpg').closest('div.flex') as HTMLElement; const removeBtn = within(fileItem).getByRole('button'); await user.click(removeBtn); expect(screen.queryByText('remove-me.jpg')).not.toBeInTheDocument(); }); // ── Submit ──────────────────────────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-033: onSave receives parsed lat/lng as numbers', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame'); const latInput = screen.getByPlaceholderText(/Latitude/i); await user.clear(latInput); await user.type(latInput, '48.853'); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ lat: 48.853 })); }); it('FE-PLANNER-PLACEFORM-034: onSave error shows toast', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockRejectedValue(new Error('Server error')); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame'); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith('Server error', 'error', undefined); }); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-035: save button shows "Saving..." while saving', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame'); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => { expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument(); }); }); it('FE-PLANNER-PLACEFORM-036: lat/lng paste splits "48.8566, 2.3522" into lat and lng fields', () => { render(); const latInput = screen.getByPlaceholderText(/Latitude/i); fireEvent.paste(latInput, { clipboardData: { getData: () => '48.8566, 2.3522', }, }); expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument(); expect(screen.getByDisplayValue('2.3522')).toBeInTheDocument(); }); // ── Duplicate detection (#1152) ──────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-037: a same-named place warns first and saves only on the second click', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre' })] }); render(); // Case-insensitive match against the existing trip place. await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), ' louvre '); await user.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined); // The button now reads as the explicit confirmation. const addAnyway = await screen.findByRole('button', { name: /Add anyway/i }); await user.click(addAnyway); await waitFor(() => expect(onSave).toHaveBeenCalled()); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-038: near-identical coordinates count as a duplicate even under a new name', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })], }); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Iron Lady'); fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), { clipboardData: { getData: () => '48.85845, 2.29455' }, }); await user.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-039: a far-away place with a fresh name saves straight away', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })], }); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Colosseum'); fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), { clipboardData: { getData: () => '41.8902, 12.4922' }, }); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Colosseum', lat: 41.8902, lng: 12.4922 })); }); it('FE-PLANNER-PLACEFORM-040: editing an existing place never triggers the duplicate guard', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const place = buildPlace({ name: 'Louvre' }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [place] }); render(); await user.click(screen.getByRole('button', { name: /^Update$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); }); it('FE-PLANNER-PLACEFORM-041: an empty name is rejected with the name-required toast', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn(); render(); await user.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); expect(addToast).toHaveBeenCalledWith('Please enter a name', 'error', undefined); delete window.__addToast; }); // ── Location bias from the trip's existing places ─────────────────────────── it('FE-PLANNER-PLACEFORM-042: a tight cluster of trip places biases the autocomplete bounding box', async () => { const user = userEvent.setup(); const bodies: Record[] = []; server.use( http.post('/api/maps/autocomplete', async ({ request }) => { bodies.push((await request.json()) as Record); return HttpResponse.json({ suggestions: [] }); }), ); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [ buildPlace({ lat: 48.85, lng: 2.34 }), buildPlace({ lat: 48.87, lng: 2.37 }), // Garbage coordinates must be skipped, not poison the box. buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number }), ], }); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await waitFor(() => expect(bodies).toHaveLength(1)); expect(bodies[0].locationBias).toEqual({ low: { lat: 48.85, lng: 2.34 }, high: { lat: 48.87, lng: 2.37 }, }); }); it('FE-PLANNER-PLACEFORM-043: places spread over more than 500 km send no location bias', async () => { const user = userEvent.setup(); const bodies: Record[] = []; server.use( http.post('/api/maps/autocomplete', async ({ request }) => { bodies.push((await request.json()) as Record); return HttpResponse.json({ suggestions: [] }); }), ); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ lat: 48.85, lng: 2.34 }), buildPlace({ lat: 41.89, lng: 12.49 })], }); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await waitFor(() => expect(bodies).toHaveLength(1)); expect(bodies[0].locationBias).toBeUndefined(); }); it('FE-PLANNER-PLACEFORM-043b: trip places with unusable coordinates yield no bias at all', async () => { const user = userEvent.setup(); const bodies: Record[] = []; server.use( http.post('/api/maps/autocomplete', async ({ request }) => { bodies.push((await request.json()) as Record); return HttpResponse.json({ suggestions: [] }); }), ); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number })], }); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await waitFor(() => expect(bodies).toHaveLength(1)); expect(bodies[0].locationBias).toBeUndefined(); }); it('FE-PLANNER-PLACEFORM-045b: focusing with a Google Maps URL in the box fetches no suggestions', async () => { const user = userEvent.setup(); let calls = 0; server.use( http.post('/api/maps/autocomplete', () => { calls += 1; return HttpResponse.json({ suggestions: [] }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'https://maps.google.com/maps?q=x'); fireEvent.focus(searchInput); await waitFor(() => expect(screen.queryByText('Paris, France')).not.toBeInTheDocument()); expect(calls).toBe(0); }); it('FE-PLANNER-PLACEFORM-044: a failing autocomplete clears the dropdown instead of surfacing an error', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await waitFor(() => expect(consoleError).toHaveBeenCalledWith('Autocomplete failed:', expect.anything())); expect(addToast).not.toHaveBeenCalled(); consoleError.mockRestore(); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-045: re-focusing the search box refetches when the dropdown is empty', async () => { const user = userEvent.setup(); let calls = 0; server.use( http.post('/api/maps/autocomplete', () => { calls += 1; return HttpResponse.json({ suggestions: [] }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiffel'); await waitFor(() => expect(calls).toBe(1)); fireEvent.focus(searchInput); await waitFor(() => expect(calls).toBe(2)); }); // ── Maps search / Google Maps URL resolve ────────────────────────────────── it('FE-PLANNER-PLACEFORM-046: clicking search with an empty box makes no request', async () => { const user = userEvent.setup(); let calls = 0; server.use( http.post('/api/maps/search', () => { calls += 1; return HttpResponse.json({ places: [] }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); const searchBtn = within(searchInput.closest('.flex') as HTMLElement).getByRole('button'); await user.click(searchBtn); expect(calls).toBe(0); }); it('FE-PLANNER-PLACEFORM-047: pasting a Google Maps URL resolves it straight into the form', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); server.use( http.post('/api/maps/resolve-url', () => HttpResponse.json({ name: 'Notre-Dame', address: 'Parvis Notre-Dame, Paris', lat: 48.8530, lng: 2.3499, google_ftid: 'ftid-1' }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame'); await user.keyboard('{Enter}'); expect(await screen.findByDisplayValue('Notre-Dame')).toBeInTheDocument(); expect(screen.getByDisplayValue('48.853')).toBeInTheDocument(); expect(screen.getByDisplayValue('Parvis Notre-Dame, Paris')).toBeInTheDocument(); // The URL is consumed, so the search box empties out. expect(screen.getByPlaceholderText('Search places...')).toHaveValue(''); expect(addToast).toHaveBeenCalledWith('Place imported from URL', 'success', undefined); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-048: an unresolvable Google Maps URL falls through to the text search', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/resolve-url', () => HttpResponse.json({ name: 'Notre-Dame', lat: null, lng: null })), http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Notre-Dame de Paris', address: 'Paris', lat: '48.853', lng: '2.3499' }] }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame'); await user.keyboard('{Enter}'); expect(await screen.findByText('Notre-Dame de Paris')).toBeInTheDocument(); }); // ── Autocomplete keyboard navigation ─────────────────────────────────────── const twoSuggestions = () => http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [ { placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }, { placeId: 'node:2', mainText: 'Eiffel Museum', secondaryText: 'Berlin, Germany' }, ], }), ); it('FE-PLANNER-PLACEFORM-049: ArrowDown/ArrowUp move the highlight and Enter picks the highlighted suggestion', async () => { const user = userEvent.setup(); server.use( twoSuggestions(), http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: { name: 'Eiffel Museum', address: 'Berlin', lat: 52.52, lng: 13.405 } }), ), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiffel'); await screen.findByText('Paris, France'); // -1 → wraps to the last entry, then ArrowDown wraps back to the first. await user.keyboard('{ArrowUp}'); expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-surface-tertiary'); await user.keyboard('{ArrowDown}'); expect(screen.getByText('Eiffel Tower').closest('button')).toHaveClass('bg-surface-tertiary'); await user.keyboard('{ArrowDown}'); expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-surface-tertiary'); await user.keyboard('{Enter}'); expect(await screen.findByDisplayValue('52.52')).toBeInTheDocument(); expect(screen.getByDisplayValue('13.405')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-050: Escape closes the dropdown without searching', async () => { const user = userEvent.setup(); let searches = 0; server.use( twoSuggestions(), http.post('/api/maps/search', () => { searches += 1; return HttpResponse.json({ places: [] }); }), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await screen.findByText('Paris, France'); await user.keyboard('{Escape}'); expect(screen.queryByText('Paris, France')).not.toBeInTheDocument(); expect(searches).toBe(0); }); it('FE-PLANNER-PLACEFORM-051: Enter with nothing highlighted closes the dropdown and runs the full search', async () => { const user = userEvent.setup(); server.use( twoSuggestions(), http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Champ de Mars', lat: '48.8584', lng: '2.2945' }] }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await screen.findByText('Paris, France'); await user.keyboard('{Enter}'); expect(await screen.findByText('Champ de Mars')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-052: when both details and the search fallback fail the query is restored', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); server.use( twoSuggestions(), http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), http.post('/api/maps/search', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await user.click(await screen.findByText('Paris, France')); await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Place search failed|nope/i), 'error', undefined), ); // The typed query comes back so the user can retry instead of starting over. await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('Eiffel')); consoleError.mockRestore(); delete window.__addToast; }); it('FE-PLANNER-PLACEFORM-053: a Google place id shared with an existing trip place is flagged as duplicate', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Already Here', google_place_id: 'gp-1', lat: null, lng: null })], }); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Same Spot', address: 'Somewhere', lat: '10', lng: '10', google_place_id: 'gp-1' }] }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'same spot'); await user.keyboard('{Enter}'); await user.click(await screen.findByText('Same Spot')); await user.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Already Here'), 'warning', undefined); delete window.__addToast; }); // ── Paste-to-attach ──────────────────────────────────────────────────────── function pasteItems(target: Element, items: { type: string; file: File | null }[]) { fireEvent.paste(target, { clipboardData: { items: items.map(i => ({ type: i.type, getAsFile: () => i.file })), }, }); } it('FE-PLANNER-PLACEFORM-054: pasting an image attaches it as a pending file', () => { render(); const form = document.querySelector('form') as HTMLFormElement; pasteItems(form, [ { type: 'text/plain', file: null }, { type: 'image/png', file: new File(['x'], 'screenshot.png', { type: 'image/png' }) }, ]); expect(screen.getByText('screenshot.png')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-057: a prefilled place drives the detail column, not the previous one', () => { // A POI tapped on the map opens the dialog through prefillCoords, never // through the search handler — the column used to keep the last place. const { rerender } = render( , ); expect(screen.getByDisplayValue('Helgas Kitchen')).toBeInTheDocument(); rerender( , ); expect(screen.getByDisplayValue('Museum Ludwig')).toBeInTheDocument(); expect(screen.queryByDisplayValue('Helgas Kitchen')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-055: pasting plain text alone attaches nothing', () => { render(); const form = document.querySelector('form') as HTMLFormElement; pasteItems(form, [{ type: 'text/plain', file: null }]); // The list only renders once something is attached, so its absence is the // assertion (this used to lean on the clipboard hint, which is gone). expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument(); // Counter-check, so the assertion above cannot pass on a missing testid: // a real file does show up in that list. pasteItems(form, [{ type: 'image/png', file: new File(['x'], 'shot.png', { type: 'image/png' }) }]); expect(screen.getByTestId('pending-files')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-056: paste is ignored entirely without upload permission', () => { seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } }); render(); const form = document.querySelector('form') as HTMLFormElement; pasteItems(form, [{ type: 'application/pdf', file: new File(['x'], 'ticket.pdf', { type: 'application/pdf' }) }]); expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => { const user = userEvent.setup(); render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); await user.click(screen.getByRole('button', { name: /Attach/i })); expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); // ── Remaining form fields ────────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-058: description, notes, website and category all reach the save payload', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const cat = buildCategory({ name: 'Museums' }); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre'); await user.type(screen.getByPlaceholderText(/Short description/i), 'Glass pyramid'); await user.type(screen.getByPlaceholderText(/Personal notes/i), 'Book ahead'); await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli'); await user.type(screen.getByPlaceholderText(/Longitude/i), '2.3376'); await user.type(screen.getByPlaceholderText('https://...'), 'https://louvre.fr'); // Pick the category through the CustomSelect trigger. await user.click(screen.getByText(/No category/i)); await user.click(screen.getByRole('button', { name: 'Museums' })); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ name: 'Louvre', description: 'Glass pyramid', notes: 'Book ahead', address: 'Rue de Rivoli', lng: 2.3376, website: 'https://louvre.fr', category_id: String(cat.id), }), ); }); it('FE-PLANNER-PLACEFORM-059: the Collections column only appears on desktop with the addon enabled', () => { seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }], loaded: true, }); const { unmount } = render(); expect(screen.getByPlaceholderText(/Search your saved places/i)).toBeInTheDocument(); unmount(); render(); expect(screen.queryByPlaceholderText(/Search your saved places/i)).not.toBeInTheDocument(); }); // ── Time section edge cases ──────────────────────────────────────────────── it('FE-PLANNER-PLACEFORM-060: editing the times through the pickers updates the save payload', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const place = buildPlace({ name: 'Museum' }); const assignment = buildAssignment({ id: 12, day_id: 4, place }); render(); const [start, end] = screen.getAllByTestId('time-picker'); fireEvent.change(start, { target: { value: '09:00' } }); fireEvent.change(end, { target: { value: '10:30' } }); await user.click(screen.getByRole('button', { name: /^Update$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ place_time: '09:00', end_time: '10:30' })); }); it('FE-PLANNER-PLACEFORM-061: an assignment id that is not in the day list reports no collisions', () => { const place = buildPlace({ name: 'Ghost', place_time: '12:00', end_time: '13:00' }); render(); expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2); expect(screen.queryByText(/Time overlap with:/i)).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-062: collisions ignore other days and timeless assignments', () => { const current = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' }); const otherDay = buildPlace({ name: 'Other Day Event', place_time: '13:00', end_time: '14:00' }); const timeless = buildPlace({ name: 'Timeless Event' }); const overlapping = buildPlace({ name: 'Overlapping Event', place_time: '13:00', end_time: '14:00' }); render( , ); const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement; expect(warning).toHaveTextContent('Overlapping Event'); expect(warning).not.toHaveTextContent('Other Day Event'); expect(warning).not.toHaveTextContent('Timeless Event'); }); }); // FE-W5PFM-001 to FE-W5PFM-014 — the remaining prefill, response-shape and // error branches of the place form. describe('PlaceFormModal remaining branches', () => { it('FE-W5PFM-001: an existing place with empty fields opens on the defaults', () => { const place = buildPlace({ name: null, description: null, address: null, lat: null, lng: null, category_id: null, notes: null, transport_mode: null, website: null, }); render(); expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue(''); expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue(''); expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue(''); expect(screen.getByPlaceholderText('https://...')).toHaveValue(''); }); it('FE-W5PFM-002: prefilled coordinates without a name only fill the coordinates', () => { render(); expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584'); expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945'); expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue(''); expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue(''); }); it('FE-W5PFM-003: an autocomplete response without a suggestions array shows no dropdown', async () => { const user = userEvent.setup(); server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({}))); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); expect(screen.queryByText('Eiffel Tower, Paris')).not.toBeInTheDocument(); }); it('FE-W5PFM-004: an aborted autocomplete is swallowed without logging', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const user = userEvent.setup(); const gate: { release?: () => void } = {}; let seen = 0; server.use( http.post('/api/maps/autocomplete', async () => { seen += 1; if (seen === 1) await new Promise(res => { gate.release = res; }); return HttpResponse.json({ suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], }); }), ); render(); const searchInput = screen.getByPlaceholderText('Search places...'); await user.type(searchInput, 'Eiff'); await waitFor(() => expect(seen).toBe(1)); // The second query aborts the still-open first one await user.type(searchInput, 'el tower'); await waitFor(() => expect(seen).toBe(2)); gate.release?.(); expect(await screen.findByText('Paris, France')).toBeInTheDocument(); expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1); expect(consoleError).not.toHaveBeenCalledWith('Autocomplete failed:', expect.anything()); consoleError.mockRestore(); }); it('FE-W5PFM-005: a resolved URL without a name or address keeps the form values', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: 48.853, lng: 2.3499 })), ); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Kept Name'); await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=x'); await user.keyboard('{Enter}'); expect(await screen.findByDisplayValue('48.853')).toBeInTheDocument(); expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Kept Name'); }); it('FE-W5PFM-006: a search response without a places array renders no results', async () => { const user = userEvent.setup(); server.use(http.post('/api/maps/search', () => HttpResponse.json({}))); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'nowhere'); await user.keyboard('{Enter}'); await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('nowhere')); expect(screen.queryByRole('button', { name: /nowhere/i })).not.toBeInTheDocument(); }); it('FE-W5PFM-007: picking a bare search result leaves the typed fields alone', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Bare Result' }] })), ); render(); await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli'); await user.type(screen.getByPlaceholderText('Search places...'), 'bare'); await user.keyboard('{Enter}'); await user.click(await screen.findByText('Bare Result')); expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Bare Result'); expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('Rue de Rivoli'); expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue(''); }); it('FE-W5PFM-008: a place sharing only the name is flagged as a duplicate', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre', lat: null, lng: null })] }); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'louvre'); await user.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined); // The second click is the explicit confirmation await user.click(screen.getByRole('button', { name: /Add anyway/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); delete window.__addToast; }); it('FE-W5PFM-009: a place sharing only the coordinates is flagged as a duplicate', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: null, lat: 48.8584, lng: 2.2945 })] }); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Tour Eiffel'); await user.type(screen.getByPlaceholderText(/Latitude/i), '48.85841'); await user.type(screen.getByPlaceholderText(/Longitude/i), '2.29451'); await user.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); // the existing place has no name, so the warning falls back to the typed one expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined); delete window.__addToast; }); it('FE-W5PFM-010: a rejected save without an Error surfaces the generic message', async () => { const addToast = vi.fn(); window.__addToast = addToast; const user = userEvent.setup(); const onSave = vi.fn().mockRejectedValue('nope'); const onClose = vi.fn(); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre'); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined)); expect(onClose).not.toHaveBeenCalled(); delete window.__addToast; }); it('FE-W5PFM-011: pasting a coordinate pair into latitude fills both fields', () => { render(); const lat = screen.getByPlaceholderText(/Latitude/i); fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8584, 2.2945 ' } }); expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584'); expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945'); }); it('FE-W5PFM-012: pasting anything else into latitude is left to the input', () => { render(); const lat = screen.getByPlaceholderText(/Latitude/i); fireEvent.paste(lat, { clipboardData: { getData: () => 'not a coordinate' } }); expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue(''); }); it('FE-W5PFM-013: a missing category list still renders the no-category option', () => { render(); expect(screen.getByText(/No category/i)).toBeInTheDocument(); }); it('FE-W5PFM-014: an open-ended assignment collapses to a point in time', () => { const current = buildPlace({ name: 'My Event', place_time: '11:30', end_time: '' }); const spanning = buildPlace({ name: 'Spanning Event', place_time: '11:00', end_time: '12:00' }); const earlier = buildPlace({ name: 'Earlier Event', place_time: '11:00', end_time: '' }); render( , ); const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement; expect(warning).toHaveTextContent('Spanning Event'); expect(warning).not.toHaveTextContent('Earlier Event'); }); /** * FE-PLANNER-PLACEFORM-063..068 — what a second pick may overwrite. * * Search Hamburg Airport, then Berlin Hauptbahnhof, and the airport's website * was still in the field: `result.website || prev.website` cannot tell "the * user typed this" from "the last result wrote this". Saving then gave the * station the airport's website. */ describe('picking a second place', () => { const searchFor = async (user: ReturnType, place: Record) => { server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [place] }))); const searchInput = screen.getByPlaceholderText('Search places...'); await user.clear(searchInput); await user.type(searchInput, String(place.name)); const searchRow = searchInput.closest('.flex') as HTMLElement; await user.click(within(searchRow).getByRole('button', { name: 'Search' })); await user.click(await screen.findByText(String(place.name))); }; const AIRPORT = { name: 'Hamburg Airport', address: 'Hamburg', lat: '53.6323', lng: '10.0067', website: 'https://www.hamburg-airport.de/', }; const STATION = { name: 'Berlin Hauptbahnhof', address: 'Berlin', lat: '52.525', lng: '13.3694' }; const websiteInput = () => screen.getByPlaceholderText('https://...') as HTMLInputElement; it('FE-PLANNER-PLACEFORM-063: a place with no website clears the previous one', async () => { const user = userEvent.setup(); render(); await searchFor(user, AIRPORT); expect(websiteInput().value).toBe('https://www.hamburg-airport.de/'); await searchFor(user, STATION); expect(websiteInput().value).toBe(''); }); it('FE-PLANNER-PLACEFORM-064: a website the user typed survives the next pick', async () => { const user = userEvent.setup(); render(); await user.type(websiteInput(), 'https://mine.example'); await searchFor(user, STATION); expect(websiteInput().value).toBe('https://mine.example'); }); it('FE-PLANNER-PLACEFORM-065: a picked value the user then edited also survives', async () => { const user = userEvent.setup(); render(); await searchFor(user, AIRPORT); await user.clear(websiteInput()); await user.type(websiteInput(), 'https://corrected.example'); await searchFor(user, STATION); expect(websiteInput().value).toBe('https://corrected.example'); }); it('FE-PLANNER-PLACEFORM-066: the ids of the previous place do not travel with the new one', async () => { const user = userEvent.setup(); const onSave = vi.fn(); render(); await searchFor(user, { ...AIRPORT, google_place_id: 'ChIJham' }); await searchFor(user, { ...STATION, osm_id: 'relation:3600565' }); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); const payload = onSave.mock.calls[0][0]; expect(payload.osm_id).toBe('relation:3600565'); expect(payload.google_place_id).toBeFalsy(); }); it('FE-PLANNER-PLACEFORM-067: editing a saved place keeps its stored website', async () => { // The dangerous direction: the form was filled from the database, so a // search result that says nothing about a field must not empty it. const user = userEvent.setup(); render( , ); expect(websiteInput().value).toBe('https://stored.example'); await searchFor(user, STATION); expect(websiteInput().value).toBe('https://stored.example'); }); }); // ── Linked expense (#1298) — the same block bookings have ────────────────── describe('Costs section', () => { const withBudget = () => seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], loaded: true, }); it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => { const { unmount } = render(); expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); unmount(); withBudget(); render(); expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => { withBudget(); const onSave = vi.fn().mockResolvedValue({ id: 42 }); const onOpenExpense = vi.fn(); render(); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); await waitFor(() => expect(onOpenExpense).toHaveBeenCalled()); expect(onSave).toHaveBeenCalled(); expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]); expect(onOpenExpense).toHaveBeenCalledWith({ prefill: { placeId: 42, name: 'Louvre', category: 'activities' }, }); }); it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => { withBudget(); const onSave = vi.fn().mockResolvedValue(undefined); const onOpenExpense = vi.fn(); render(); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onOpenExpense).not.toHaveBeenCalled(); }); it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => { withBudget(); const onSave = vi.fn().mockResolvedValue({ id: 42 }); const onOpenExpense = vi.fn(); render(); // No name yet — the save is refused and the intent must not survive it. fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); await waitFor(() => expect(onSave).not.toHaveBeenCalled()); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onOpenExpense).not.toHaveBeenCalled(); }); it('FE-PLANNER-PLACEFORM-071: an already-linked expense is listed with its amount, and another can still be created', () => { withBudget(); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }], }); render(); expect(screen.getByText('Louvre tickets')).toBeInTheDocument(); expect(screen.getByText('Linked expenses')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Unlink, keep the expense' })).toBeInTheDocument(); // A place can carry several expenses (#2084), so the create button stays and the hint goes. expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); expect(screen.queryByText('Saves the place, then opens the Costs editor.')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => { withBudget(); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }], }); render(); expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); }); }); }); // ── The road trip's own use of this form: a service stop added by hand ──────── /** * FE-PLANNER-PLACEFORM-073 to -080. * * The corridor search reads OpenStreetMap and misses a good share of the chargers * actually standing at a junction, so "add manually" opens THIS form rather than a * bespoke one: its typed-ahead search is the one the rest of TREK uses. Three things * change and nothing else does, which is what the first two tests here pin. * * Where the stop goes cannot be decided when the dialog opens, because nothing has been * chosen yet. It is worked out at the save, from the coordinates being saved. */ describe('PlaceFormModal as a road trip service stop', () => { type Mode = NonNullable['serviceStop']>; const serviceStop = (over: Partial = {}): Mode => ({ days: [ { dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen', 'Berlin'] }, { dayId: 6, dayNumber: 2, stops: ['Berlin', 'Dresden'] }, ], appendDay: { dayId: 5, dayNumber: 1, position: 3 }, // Everything east of the twelfth meridian falls on the second day's leg here; the // real projection is the planner's and has its own suite. targetFor: (_lat: number, lng: number) => lng > 12 ? { dayId: 6, position: 1, offRouteKm: 0.4 } : { dayId: 5, position: 1, offRouteKm: 0.2 }, ...over, }); const withBudgetAddon = () => seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], loaded: true, }); const typeCoords = (lat: string, lng: string) => { fireEvent.change(screen.getByPlaceholderText(/Latitude/i), { target: { value: lat } }); fireEvent.change(screen.getByPlaceholderText(/Longitude/i), { target: { value: lng } }); }; const named = (name: string) => { fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: name } }); }; it('FE-PLANNER-PLACEFORM-073: the ordinary add place keeps its category and its costs', () => { withBudgetAddon(); render(); expect(screen.getByRole('button', { name: /^Category:/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-074: a service stop swaps the category for the kind and drops the costs', () => { withBudgetAddon(); render(); expect(screen.getByText('Kind of stop')).toBeInTheDocument(); expect(screen.getByText('Time at this stop')).toBeInTheDocument(); // Nothing has been chosen yet, so the row that asks where on the drive it goes says // what it needs first. It turns into the list of legs the moment a place arrives. expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument(); // A petrol stop is not an activity with a budget line, and it is not a taste either. expect(screen.queryByRole('button', { name: /^Category:/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New category' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-075: it opens on a kind rather than on none, and says which dialog it is', () => { render(); // A stop left without a kind is a numbered destination that counts in every total, // which is the very thing this path exists to avoid. expect(screen.getByRole('button', { name: 'Fuel' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: /10 min/ })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getAllByText('Add as a stop').length).toBeGreaterThan(0); }); it('FE-PLANNER-PLACEFORM-085: it opens on the kind the corridor was looking for, dwell and all', () => { // The panel seeds itself to charging on an electric car, and a form opening on a // fuel pump under a picker that says Charging is the fault this carries the answer to. render(); expect(screen.getByRole('button', { name: 'Charging' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: /30 min/ })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Fuel' })).toHaveAttribute('aria-pressed', 'false'); }); it('FE-PLANNER-PLACEFORM-076: picking a kind also picks how long that kind takes', async () => { const onSave = vi.fn().mockResolvedValue({ id: 9 }); render(); named('Supercharger'); fireEvent.click(screen.getByRole('button', { name: 'Charging' })); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ stop_type: 'charging', duration_minutes: 30, category_id: null, })); }); it('FE-PLANNER-PLACEFORM-077: where it goes is worked out from the coordinates that are saved', async () => { const onSave = vi.fn().mockResolvedValue({ id: 9 }); render(); // The form opened on nothing at all. These coordinates arrive afterwards, and they // are what the position has to follow. named('Supercharger'); typeCoords('51.05', '13.73'); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _serviceStop: { dayId: 6, position: 1, offRouteKm: 0.4 }, })); }); it('FE-PLANNER-PLACEFORM-078: the leg the traveller overrules is the one that lands', async () => { const onSave = vi.fn().mockResolvedValue({ id: 9 }); render(); named('Supercharger'); typeCoords('51.05', '13.73'); fireEvent.click(screen.getByRole('button', { name: /Berlin\s+Dresden/ })); fireEvent.click(screen.getByText(/Bremen\s+Berlin/)); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ // Overruled onto another stretch, so the projection's distance does not come // along: it was measured against the leg the drive guessed, not this one. _serviceStop: { dayId: 5, position: 2, offRouteKm: null }, })); }); it('FE-PLANNER-PLACEFORM-079: a place far off the drawn line is noted, never refused', async () => { const onSave = vi.fn().mockResolvedValue({ id: 9 }); const far = serviceStop({ targetFor: () => ({ dayId: 5, position: 2, offRouteKm: 18.3 }) }); render(); named('Supercharger'); typeCoords('41.9', '12.5'); expect(screen.getByText(/check which leg it belongs on/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _serviceStop: { dayId: 5, position: 2, offRouteKm: 18.3 }, })); }); it('FE-PLANNER-PLACEFORM-081: a stop with no coordinates is not filed onto a leg by guesswork', async () => { const onSave = vi.fn().mockResolvedValue({ id: 9 }); render(); // The premise case: the charger is in no index, so the traveller types the name. It // cannot be measured onto a road, and picking a leg for it anyway used to file it as // the first stop of the trip's first day without a word. named('Supercharger Dammer Berge'); expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument(); expect(screen.queryByText('Add between')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _serviceStop: null })); }); it('FE-PLANNER-PLACEFORM-082: a second stop of the same brand saves on the first press', async () => { // Four hundred kilometres apart and both called Aral, which on a motorway is the // normal case and is exactly what this path is for. The name check used to refuse // the first press and demand a second. seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ id: 3, name: 'Aral', lat: 53.1, lng: 8.8 })], }); const onSave = vi.fn().mockResolvedValue({ id: 9 }); render(); named('Aral'); typeCoords('51.05', '13.73'); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(screen.queryByRole('button', { name: /Add anyway/i })).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-083: the same pump twice over is noted beside the name, not refused', async () => { seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ id: 3, name: 'Aral Dammer Berge', lat: 52.5, lng: 8.2 })], }); const onSave = vi.fn().mockResolvedValue({ id: 9 }); render(); named('Tankstelle'); typeCoords('52.5', '8.2'); // The same place standing at the same coordinates is worth saying. It is a note // while the form is being filled, and the press that saves still saves. expect(screen.getByText(/is already on this trip/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); }); it('FE-PLANNER-PLACEFORM-084: an expense intent left over from a refused save never reaches this form', async () => { withBudgetAddon(); const refused = vi.fn().mockRejectedValue(new Error('Server error')); const onOpenExpense = vi.fn(); const { rerender } = render( , ); // An ordinary add place, asked for an expense, that the server refuses. named('Louvre'); fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); await waitFor(() => expect(refused).toHaveBeenCalled()); expect(onOpenExpense).not.toHaveBeenCalled(); const saved = vi.fn().mockResolvedValue({ id: 9 }); rerender(); rerender( , ); named('Supercharger'); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); // The one mode whose contract is that costs are gone: an editor opening here would // file a petrol stop as an activity nobody asked to pay for. await waitFor(() => expect(saved).toHaveBeenCalled()); expect(onOpenExpense).not.toHaveBeenCalled(); }); it('FE-PLANNER-PLACEFORM-080: with nothing routed it appends to the day the panel is on', async () => { const onSave = vi.fn().mockResolvedValue({ id: 9 }); const unrouted = serviceStop({ days: [], appendDay: { dayId: 5, dayNumber: 1, position: 2 } }); render(); named('Supercharger'); typeCoords('53.55', '9.99'); expect(screen.getByText(/it goes at the end of day 1/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _serviceStop: { dayId: 5, position: 2, offRouteKm: 0 }, })); }); }); describe('PlaceFormModal plugin search (#2221)', () => { const ATP = { id: 'all-the-places', name: 'All the Places', type: 'integration' as const, icon: null }; const hit = { osm_id: 'plugin:all-the-places:ichiran-ueno', name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito', lat: 35.7101, lng: 139.7745, rating: 4.3, website: 'https://ichiran.com/shop/ueno', phone: '+81 3 5818 3531', category: 'restaurant', description: null, source: 'plugin:all-the-places', pluginId: 'all-the-places', }; afterEach(() => { usePluginStore.setState({ plugins: [] }); }); it('FE-PLANNER-PLACEFORM-093: a search plugin that answers as you type lists its places under the core ones, marked with its name', async () => { usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] }); const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'gers:1', mainText: 'Ichiran Shibuya', secondaryText: 'Jinnan', source: 'trek-places' }], source: 'trek-places' })), http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [hit] })), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Ichiran'); const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!; const coreRow = screen.getByText('Ichiran Shibuya').closest('button')!; expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument(); expect(within(coreRow).getByText('TREK')).toBeInTheDocument(); // Appended, never ahead of the ranked core list. expect(coreRow.compareDocumentPosition(pluginRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it('FE-PLANNER-PLACEFORM-094: picking a plugin row takes its place as it is, with no details lookup and no search', async () => { usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] }); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const detour = vi.fn(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })), http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [hit] })), http.get('/api/maps/details/:placeId', () => { detour(); return HttpResponse.json({ place: null }); }), http.post('/api/maps/search', () => { detour(); return HttpResponse.json({ places: [], source: 'trek-places' }); }), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Ichiran'); await user.click(await screen.findByText('Ichiran Ueno')); expect(await screen.findByDisplayValue('35.7101')).toBeInTheDocument(); expect(screen.getByDisplayValue('139.7745')).toBeInTheDocument(); expect(screen.getByDisplayValue('https://ichiran.com/shop/ueno')).toBeInTheDocument(); expect(detour).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito', osm_id: 'plugin:all-the-places:ichiran-ueno', })); }); it('FE-PLANNER-PLACEFORM-095: without a search plugin nothing is asked per keystroke, and a searched plugin row still names its plugin', async () => { usePluginStore.setState({ plugins: [ATP] }); const user = userEvent.setup(); const perKeystroke = vi.fn(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })), http.get('/api/plugin-search/suggest', () => { perKeystroke(); return HttpResponse.json({ places: [] }); }), http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Ichiran Shibuya', address: 'Jinnan', lat: 35.66, lng: 139.7, source: 'trek-places' }], source: 'trek-places' })), http.get('/api/plugin-search', () => HttpResponse.json({ places: [hit] })), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Ichiran{Enter}'); const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!; expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument(); expect(perKeystroke).not.toHaveBeenCalled(); }); }); // ── The dialog frame: keys, focus, paste and the columns beside the form ────── describe('PlaceFormModal on the dialog frame', () => { const withCollections = () => { seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }], loaded: true, }); vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }], incomingInvites: [], }); vi.spyOn(collectionsApi, 'get').mockResolvedValue({ collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }, places: [{ id: 10, collection_id: 1, name: 'Zebra Cafe', status: 'idea' }], }); }; afterEach(() => { vi.restoreAllMocks(); }); it('FE-PLANNER-PLACEFORM-096: the dialog is named by its title', () => { render(); expect(screen.getByRole('dialog', { name: 'Edit Place' })).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-097: Escape in the suggestion list closes the list, and only the next one closes the dialog', async () => { const user = userEvent.setup(); const onClose = vi.fn(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }] }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); await screen.findByText('Paris, France'); await user.keyboard('{Escape}'); expect(screen.queryByText('Paris, France')).not.toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); await user.keyboard('{Escape}'); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-PLANNER-PLACEFORM-098: Escape in a saved-places filter closes the filter, not the dialog', async () => { withCollections(); const onClose = vi.fn(); render(); await screen.findByText('Zebra Cafe'); const trigger = screen.getByRole('button', { name: /All lists/ }); fireEvent.click(trigger); expect(screen.getByRole('listbox')).toBeInTheDocument(); fireEvent.keyDown(trigger, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); expect(onClose).not.toHaveBeenCalled(); }); it('FE-PLANNER-PLACEFORM-099: Escape with nothing open closes the dialog', () => { const onClose = vi.fn(); render(); fireEvent.keyDown(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-PLANNER-PLACEFORM-100: a new place opens with the search focused; an edit and a place from the map do not', () => { const { unmount } = render(); expect(screen.getByPlaceholderText('Search places...')).toHaveFocus(); unmount(); const edit = render(); expect(screen.getByPlaceholderText('Search places...')).not.toHaveFocus(); expect(screen.getByRole('dialog')).toHaveFocus(); edit.unmount(); render(); expect(screen.getByPlaceholderText('Search places...')).not.toHaveFocus(); }); it('FE-PLANNER-PLACEFORM-101: Enter in a field never saves; only the footer button does', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); render(); await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre{Enter}'); await user.type(screen.getByPlaceholderText('https://...'), 'https://louvre.fr{Enter}'); expect(onSave).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); }); it('FE-PLANNER-PLACEFORM-102: an image pasted anywhere in the dialog, head band included, is attached', () => { render(); fireEvent.paste(screen.getByText('Add Place/Activity'), { clipboardData: { items: [{ type: 'image/png', getAsFile: () => new File(['x'], 'from-header.png', { type: 'image/png' }) }] }, }); expect(within(screen.getByTestId('pending-files')).getByText('from-header.png')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => { const user = userEvent.setup(); render(); fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] }, }); await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' })); expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-104: every field is labelled, the coordinates as one group', () => { const place = buildPlace({ name: 'Louvre' }); const assignment = buildAssignment({ id: 10, day_id: 5, place }); render(); expect(screen.getByRole('textbox', { name: 'Search' })).toBe(screen.getByPlaceholderText('Search places...')); // The icon button beside it carries the same name. expect(within(screen.getByPlaceholderText('Search places...').closest('.flex') as HTMLElement).getByRole('button', { name: 'Search' })).toBeInTheDocument(); expect(screen.getByLabelText('Name *')).toHaveValue('Louvre'); expect(screen.getByLabelText('Description')).toBe(screen.getByPlaceholderText(/Short description/i)); expect(screen.getByLabelText('Notes')).toBe(screen.getByPlaceholderText(/Personal notes/i)); expect(screen.getByLabelText('Address')).toBe(screen.getByPlaceholderText(/Street, City, Country/i)); expect(screen.getByLabelText('Notes for this day')).toBeInTheDocument(); expect(screen.getByLabelText('Website')).toBe(screen.getByPlaceholderText('https://...')); const coords = screen.getByRole('group', { name: 'Coordinates' }); expect(within(coords).getByPlaceholderText(/Latitude/i)).toBeInTheDocument(); expect(within(coords).getByPlaceholderText(/Longitude/i)).toBeInTheDocument(); // Each Markdown field keeps its own formatting bar. expect(screen.getAllByRole('toolbar')).toHaveLength(2); }); it('FE-PLANNER-PLACEFORM-105: the new category field takes the focus when it opens', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'New category' })); expect(screen.getByPlaceholderText('Category name')).toHaveFocus(); }); it('FE-PLANNER-PLACEFORM-106: the dialog widens with the columns beside the form', async () => { seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, placesEnrichEnabled: false }); const alone = render(); expect(screen.getByRole('dialog')).toHaveClass('max-w-[600px]'); alone.unmount(); seedStore(useAuthStore, { placesEnrichEnabled: true }); const withDetails = render(); expect(screen.getByRole('dialog')).toHaveClass('max-w-[960px]'); withDetails.unmount(); withCollections(); render(); expect(screen.getByRole('dialog')).toHaveClass('max-w-[1280px]'); // Details on the left, the form, saved places on the right. const [details, form, saved] = Array.from(document.querySelector('form')!.parentElement!.children); expect(details).toHaveTextContent('Place details'); expect(form.tagName).toBe('FORM'); expect(saved).toContainElement(screen.getByPlaceholderText(/Search your saved places/i)); await screen.findByText('Zebra Cafe'); }); }); // ── The head band and the panels: the booking editors' look ────────────────── describe('PlaceFormModal in the look of the booking editors', () => { const header = () => screen.getByRole('dialog').querySelector('header') as HTMLElement; it('FE-PLANNER-PLACEFORM-107: the name is typed in the head band, under the title as its eyebrow', async () => { const user = userEvent.setup(); render(); const name = screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i); expect(header()).toContainElement(name); expect(within(header()).getByRole('heading', { name: 'Add Place/Activity' })).toBeInTheDocument(); // Until there is a name, the line under it says one is required. expect(within(header()).getByText('Name *')).toBeInTheDocument(); await user.type(name, 'Louvre'); expect(within(header()).queryByText('Name *')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-108: the category pill picks a category and clears it again', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const cat = buildCategory({ name: 'Museums' }); render(); await user.click(screen.getByRole('button', { name: /^Category:/ })); await user.click(screen.getByRole('button', { name: 'Museums' })); expect(within(header()).getByText('Museums')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /^Category:/ })); await user.click(screen.getByRole('button', { name: 'No Category' })); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave.mock.calls[0][0].category_id).toBeNull(); }); it('FE-PLANNER-PLACEFORM-109: Enter in the new category field creates it, and never saves the place', async () => { const user = userEvent.setup(); const onSave = vi.fn(); const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); render(); await user.click(screen.getByRole('button', { name: 'New category' })); // The field takes the pill's place on the band, OK and Cancel beside it. expect(header()).toContainElement(screen.getByPlaceholderText('Category name')); await user.type(screen.getByPlaceholderText('Category name'), 'Beaches{Enter}'); expect(onCategoryCreated).toHaveBeenCalledWith({ name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); await waitFor(() => expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument()); expect(onSave).not.toHaveBeenCalled(); }); it('FE-PLANNER-PLACEFORM-110: a picked result names its index in the band until the dialog reopens', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Louvre', address: 'Paris', lat: '48.86', lng: '2.33' }], source: 'openstreetmap' }), ), ); const { rerender } = render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Louvre{Enter}'); await user.click(await screen.findByText('Louvre')); expect(within(header()).getByText('OpenStreetMap')).toBeInTheDocument(); rerender(); rerender(); expect(within(header()).queryByText('OpenStreetMap')).not.toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-111: a picked suggestion is named after the row that was clicked', async () => { const user = userEvent.setup(); server.use( http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], source: 'trek-places' }), ), http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: { name: 'Eiffel Tower', address: 'Paris', lat: 48.858, lng: 2.294 } }), ), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); fireEvent.mouseDown(await screen.findByText('Paris, France')); expect(await screen.findByDisplayValue('48.858')).toBeInTheDocument(); expect(within(header()).getByText('TREK')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-112: a lookup in flight shows as a status pill in the band', async () => { const user = userEvent.setup(); let answer: () => void = () => {}; server.use( http.post('/api/maps/search', async () => { await new Promise(resolve => { answer = resolve; }); return HttpResponse.json({ places: [], source: 'openstreetmap' }); }), ); render(); await user.type(screen.getByPlaceholderText('Search places...'), 'Louvre{Enter}'); const busy = await screen.findByRole('status', { name: 'Loading place details…' }); expect(header()).toContainElement(busy); answer(); await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading place details…' })).not.toBeInTheDocument()); }); it('FE-PLANNER-PLACEFORM-113: each coordinate has its own label, and the pair stays one group', () => { render(); const coords = screen.getByRole('group', { name: 'Coordinates' }); expect(within(coords).getByLabelText('Latitude')).toBe(screen.getByPlaceholderText(/Latitude/i)); expect(within(coords).getByLabelText('Longitude')).toBe(screen.getByPlaceholderText(/Longitude/i)); }); it('FE-PLANNER-PLACEFORM-114: the actions in the body are white, not grey', () => { seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], loaded: true, }); render(); for (const button of [screen.getByRole('button', { name: /Attach/i }), screen.getByRole('button', { name: /Create expense/i })]) { expect(button).toHaveClass('bg-surface-card'); expect(button).not.toHaveClass('bg-surface-secondary'); } }); it('FE-PLANNER-PLACEFORM-115: a stop on a drive shows its kind in the band, and a double as the line under the name', () => { seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ id: 3, name: 'Aral Dammer Berge', lat: 52.5, lng: 8.2 })], }); render( ({ dayId: 5, position: 1, offRouteKm: 0.2 }), }} />, ); expect(within(header()).getByText('Fuel')).toBeInTheDocument(); expect(within(header()).queryByRole('button', { name: /^Category:/ })).not.toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Tankstelle' } }); fireEvent.change(screen.getByPlaceholderText(/Latitude/i), { target: { value: '52.5' } }); fireEvent.change(screen.getByPlaceholderText(/Longitude/i), { target: { value: '8.2' } }); expect(header()).toContainElement(screen.getByText(/is already on this trip/)); }); it('FE-PLANNER-PLACEFORM-116: the details column waits with a quiet hint, not the mascot', () => { seedStore(useAuthStore, { placesEnrichEnabled: true }); render(); const column = screen.getByText('Place details', { exact: true }).closest('aside') as HTMLElement; expect(within(column).getByText('Pick a result to see more')).toBeInTheDocument(); }); it('FE-PLANNER-PLACEFORM-117: Escape closes only the new category field, and the focus goes back to its +', async () => { const user = userEvent.setup(); const onClose = vi.fn(); render(); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); await user.click(screen.getByRole('button', { name: 'New category' })); await user.type(screen.getByPlaceholderText('Category name'), 'Beaches'); await user.keyboard('{Escape}'); expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Louvre'); expect(screen.getByRole('button', { name: 'New category' })).toHaveFocus(); }); it('FE-PLANNER-PLACEFORM-118: Cancel and OK hand the focus back to the + as well', async () => { const user = userEvent.setup(); const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); render(); await user.click(screen.getByRole('button', { name: 'New category' })); await user.click(within(header()).getByRole('button', { name: 'Cancel' })); expect(screen.getByRole('button', { name: 'New category' })).toHaveFocus(); await user.click(screen.getByRole('button', { name: 'New category' })); await user.type(screen.getByPlaceholderText('Category name'), 'Beaches'); await user.click(within(header()).getByRole('button', { name: 'OK' })); await waitFor(() => expect(screen.getByRole('button', { name: 'New category' })).toHaveFocus()); }); it('FE-PLANNER-PLACEFORM-119: the time fields carry their names, and End says when it is wrong', () => { const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' }); const assignment = buildAssignment({ id: 11, day_id: 5, place }); render(); expect(screen.getByRole('textbox', { name: 'Start' })).not.toHaveAttribute('aria-invalid', 'true'); const end = screen.getByRole('textbox', { name: 'End' }); expect(end).toHaveAttribute('aria-invalid', 'true'); expect(end).toHaveAccessibleDescription('End time is before start time'); }); it('FE-PLANNER-PLACEFORM-120: the formatting bars show their names in the shared tooltip, never the native one', async () => { const user = userEvent.setup(); render(); const bars = screen.getAllByRole('toolbar'); expect(bars).toHaveLength(2); for (const bar of bars) { for (const button of within(bar).getAllByRole('button')) expect(button).not.toHaveAttribute('title'); } await user.hover(within(bars[0]).getByRole('button', { name: 'Bold' })); expect(await screen.findByRole('tooltip')).toHaveTextContent('Bold'); }); it('FE-PLANNER-PLACEFORM-121: stacked in a narrow window, the details column takes the full width', () => { seedStore(useAuthStore, { placesEnrichEnabled: true }); const { unmount } = render(); const stacked = screen.getByText('Place details', { exact: true }).closest('aside') as HTMLElement; expect(stacked).not.toHaveClass('sm:w-80'); unmount(); render(); const beside = screen.getByText('Place details', { exact: true }).closest('aside') as HTMLElement; expect(beside).toHaveClass('sm:w-80'); }); }); describe('PlaceFormModal and the columns beside it', () => { it('FE-PLANNER-PLACEFORM-122: a picture picked and a description adopted in the details column are saved with the place', async () => { seedStore(useAuthStore, { placesEnrichEnabled: true }); const photoUrl = '/api/maps/place-photo/way%3A122~p0/bytes'; server.use( http.post('/api/maps/enrichment', () => HttpResponse.json({ photos: [{ key: 'way:122~p0', url: photoUrl, attribution: 'Alice', license: 'CC BY 4.0', licenseUrl: null, sourceUrl: null, source: 'wikimedia' }], facts: [], description: { text: 'A museum by the river.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' }, rating: null, hours: null, })), ); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); render(); await user.click(await screen.findByRole('button', { name: 'Use this text' })); expect(screen.getByLabelText('Description')).toHaveValue('A museum by the river.'); // With a description in place, the column asks for it to be cleared first. expect(screen.getByRole('button', { name: 'Use this text' })).toBeDisabled(); const tile = screen.getByRole('button', { name: /^Pick a picture/ }); await user.click(tile); expect(tile).toHaveAttribute('aria-pressed', 'true'); await user.click(tile); expect(tile).toHaveAttribute('aria-pressed', 'false'); await user.click(tile); await user.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ image_url: photoUrl, description: 'A museum by the river.' })); }); it('FE-PLANNER-PLACEFORM-123: the notes for this day are saved when they changed', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const place = buildPlace({ name: 'Cafe 123' }); const assignment = buildAssignment({ id: 123, day_id: 5, place, notes: 'Old note' }); render(); const notes = screen.getByRole('textbox', { name: 'Notes for this day' }); expect(notes).toHaveValue('Old note'); await user.clear(notes); await user.type(notes, 'Book a table by the window'); await user.click(screen.getByRole('button', { name: /^Update$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ assignment_notes: 'Book a table by the window' })); }); it('FE-PLANNER-PLACEFORM-124: unchanged notes for the day are left out of the save', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue(undefined); const place = buildPlace({ name: 'Cafe 124' }); const assignment = buildAssignment({ id: 124, day_id: 5, place, notes: 'Keep me' }); render(); await user.click(screen.getByRole('button', { name: /^Update$/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave.mock.calls[0][0]).not.toHaveProperty('assignment_notes'); }); });