From 40253d2fdf8e2f563fea662645a0bea28a6b3346 Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:14:01 +0200 Subject: [PATCH] fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198) Clicking an auto-suggest dropdown item did a second /maps/details lookup that could fail (details kill-switch off, an overloaded OSM Overpass mirror behind a proxy, or any upstream error), dead-ending on "Place search failed" while the search button stayed reliable. handleSelectSuggestion now treats a missing or coordinate-less details result (or a thrown error) as a miss and falls back to the text-search path the search button uses, applying the first result. The error toast only fires if the fallback also returns nothing. Adds tests for the previously untested suggestion-click path. --- .../Planner/PlaceFormModal.test.tsx | 95 +++++++++++++++++++ .../src/components/Planner/PlaceFormModal.tsx | 27 +++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index f054a628..f2b1691e 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -253,6 +253,101 @@ describe('PlaceFormModal', () => { 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-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: hasMapsKey=false shows OSM active message', () => { // hasMapsKey is false by default in beforeEach render(); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 271e6dd7..9466bc15 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -249,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) { setForm(prev => ({ ...prev, name: suggestion.mainText })) setIsSearchingMaps(true) try { - const result = await mapsApi.details(suggestion.placeId, language) - if (result.place) { - handleSelectMapsResult(result.place) + // The details lookup is a fragile second hop — it can fail when the + // details kill-switch is off, when the OSM Overpass mirror is overloaded, + // or on any upstream error. Treat a missing/coordinate-less place as a + // miss and fall back to the reliable text-search path the search button + // uses (its results already carry coordinates), so dropdown items stay + // clickable instead of dead-ending on "Place search failed". (#1192) + let place: Record | null = null + try { + const result = await mapsApi.details(suggestion.placeId, language) + if (result.place && result.place.lat != null && result.place.lng != null) { + place = result.place + } + } catch (err) { + console.error('Failed to fetch place details:', err) + } + if (!place) { + const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ') + const search = await mapsApi.search(query, language) + place = search.places?.[0] ?? null + } + if (place) { + handleSelectMapsResult(place) } else { setMapsSearch(previousSearch) toast.error(t('places.mapsSearchError')) } } catch (err) { - console.error('Failed to fetch place details:', err) + console.error('Place suggestion lookup failed:', err) setMapsSearch(previousSearch) toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally {