mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(planner): closing the place dialog resets the whole search block
The planner keeps the place dialog mounted while it is closed, so its search block survived a closing. Clearing the field on close (#2465) left the result list under the empty field, together with the "Search Google instead" line offering that list's query again, and a pick from that list wrote its name, address and coordinates over whichever place the dialog was opened for next. Closing now drops the list, the suggestions, the Google line's query and the billing session, and cancels a pending autocomplete. A full search or a suggestion lookup that answers after the dialog closed is discarded instead of filling the next opening, and no longer ends the session a new opening may have started.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -080
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -091
|
||||
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';
|
||||
@@ -162,7 +162,7 @@ describe('PlaceFormModal', () => {
|
||||
expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the maps search input after the modal closes and reopens', async () => {
|
||||
it('FE-PLANNER-PLACEFORM-088: closing the dialog clears the search field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel Tower');
|
||||
@@ -173,6 +173,77 @@ describe('PlaceFormModal', () => {
|
||||
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(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
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(<PlaceFormModal {...defaultProps} isOpen={false} />);
|
||||
rerender(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
|
||||
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<void>(res => { gate.release = res; });
|
||||
return HttpResponse.json({ suggestions: [{ placeId: 'late-1', mainText: 'Late Suggestion', secondaryText: 'Nowhere' }], source: 'trek-places' });
|
||||
}),
|
||||
);
|
||||
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Late');
|
||||
await waitFor(() => expect(gate.started).toBe(true));
|
||||
|
||||
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
|
||||
gate.release?.();
|
||||
await act(async () => { await new Promise(res => setTimeout(res, 100)); });
|
||||
rerender(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
|
||||
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<void>(res => { gate.release = res; });
|
||||
return HttpResponse.json({ places: [{ name: 'Late Result', address: 'Nowhere', lat: '1', lng: '2' }] });
|
||||
}),
|
||||
);
|
||||
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
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(<PlaceFormModal {...defaultProps} isOpen={false} />);
|
||||
gate.release?.();
|
||||
await act(async () => { await new Promise(res => setTimeout(res, 100)); });
|
||||
rerender(<PlaceFormModal {...defaultProps} isOpen />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -191,6 +191,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const acAbortRef = useRef<AbortController | null>(null)
|
||||
// Counts the closings of this dialog, so an answer still on its way when it
|
||||
// closed can tell that the opening it was asked for is over. A counter rather
|
||||
// than an abort, because the full search takes no signal.
|
||||
const searchEpochRef = useRef(0)
|
||||
// Ties one search's keystrokes and its details lookup into a single Google
|
||||
// billing session (see utils/placesSession).
|
||||
const placesSessionRef = useRef(new PlacesSession())
|
||||
@@ -318,10 +322,29 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place, prefillCoords, isOpen, assignmentId])
|
||||
|
||||
// The planner keeps this dialog mounted while it is closed (Modal only stops
|
||||
// drawing it), so everything the search block holds would greet the next
|
||||
// opening: the last query, its list, the Google line offering that list's
|
||||
// query again, suggestions still on their way. A pick from that list writes
|
||||
// over the place being edited, whichever place that is by then. Reset when
|
||||
// it closes, in an effect of its own: the one above also reruns while the
|
||||
// dialog is open, when a right-click's reverse lookup fills prefillCoords,
|
||||
// and must not wipe a search the user has started since.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMapsSearch('')
|
||||
}
|
||||
if (isOpen) return
|
||||
searchEpochRef.current += 1
|
||||
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
||||
acAbortRef.current?.abort()
|
||||
placesSessionRef.current.end()
|
||||
searchMetaRef.current = null
|
||||
acMetaRef.current = null
|
||||
setMapsSearch('')
|
||||
setMapsResults([])
|
||||
setSearchSource('')
|
||||
setAcSuggestions([])
|
||||
setAcSource('')
|
||||
setAcHighlight(-1)
|
||||
setIsSearchingMaps(false)
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -409,11 +432,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// it promises the same query.
|
||||
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
|
||||
if (!trimmed) return
|
||||
const epoch = searchEpochRef.current
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
// A pasted Google Maps or Amap link resolves server-side into a place
|
||||
if (!provider && isMapUrl(trimmed)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
if (resolved.lat && resolved.lng) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
@@ -430,13 +455,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
setSearchSource(result.source || '')
|
||||
} catch (err: unknown) {
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +523,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
const epoch = searchEpochRef.current
|
||||
setMapsSearch('')
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
@@ -517,6 +545,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
// Closed while the details were on their way: the pick belongs to an
|
||||
// opening that is over, and the fallback search below is not worth a
|
||||
// request nobody will see.
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
|
||||
// The layer's rows carry no address; their second line is the name
|
||||
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
|
||||
@@ -535,6 +567,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
@@ -544,12 +577,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
}
|
||||
} catch (err) {
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
console.error('Place suggestion lookup failed:', err)
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
placesSessionRef.current.end()
|
||||
// Closing already ended this session; ending it again here would cut off
|
||||
// the one a new opening may have started meanwhile.
|
||||
if (epoch === searchEpochRef.current) {
|
||||
setIsSearchingMaps(false)
|
||||
placesSessionRef.current.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user