mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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.
This commit is contained in:
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
|
|||||||
delete window.__addToast;
|
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<typeof userEvent.setup>) {
|
||||||
|
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(<PlaceFormModal {...defaultProps} />);
|
||||||
|
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(<PlaceFormModal {...defaultProps} />);
|
||||||
|
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(<PlaceFormModal {...defaultProps} />);
|
||||||
|
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', () => {
|
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||||
// hasMapsKey is false by default in beforeEach
|
// hasMapsKey is false by default in beforeEach
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
|||||||
@@ -249,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
try {
|
||||||
const result = await mapsApi.details(suggestion.placeId, language)
|
// The details lookup is a fragile second hop — it can fail when the
|
||||||
if (result.place) {
|
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||||
handleSelectMapsResult(result.place)
|
// 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<string, unknown> | 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 {
|
} else {
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(t('places.mapsSearchError'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch place details:', err)
|
console.error('Place suggestion lookup failed:', err)
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user