diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index f0d0a94e1..c28463e14 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059 +// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-060 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http, HttpResponse, delay } from 'msw' @@ -1072,4 +1072,19 @@ describe('EntryEditor', () => { expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`) } }) + + it('FE-JRN-EDITOR-060: emptying the location field keeps it empty and saves the entry without a place (#2615)', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10, location_name: 'Rome', location_lat: 41.9, location_lng: 12.5 })) + const input = screen.getByPlaceholderText('Search location...') + expect(input).toHaveValue('Rome') + + fireEvent.change(input, { target: { value: 'Rom' } }) + fireEvent.change(input, { target: { value: '' } }) + expect(input).toHaveValue('') + + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toMatchObject({ location_name: null, location_lat: null, location_lng: null }) + }) }) diff --git a/client/src/components/Journey/useJourneyEntryForm.test.ts b/client/src/components/Journey/useJourneyEntryForm.test.ts index 49225ae4c..71740f2bf 100644 --- a/client/src/components/Journey/useJourneyEntryForm.test.ts +++ b/client/src/components/Journey/useJourneyEntryForm.test.ts @@ -1,4 +1,4 @@ -// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-021: the journey entry form logic behind both +// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-024: the journey entry form logic behind both // the desktop editor dialog and the phone entry sheet. import { act, renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; @@ -388,4 +388,55 @@ describe('useJourneyEntryForm', () => { expect(onDone).not.toHaveBeenCalled(); expect(sheet.result.current.saving).toBe(false); }); + + it('FE-JRN-ENTRYFORM-022: emptying the location field removes the location and saves it as gone (#2615)', async () => { + const { result, props } = setup(); + act(() => result.current.searchLocation('Rom')); + act(() => result.current.searchLocation('')); + expect(result.current.locationQuery).toBe(''); + expect(result.current.locationName).toBe(''); + expect(result.current.locationLat).toBeNull(); + expect(result.current.locationLng).toBeNull(); + expect(result.current.showLocationResults).toBe(false); + expect(result.current.isDirty).toBe(true); + + await act(() => result.current.handleSave()); + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ location_name: null, location_lat: null, location_lng: null }), + 4 + ); + }); + + it('FE-JRN-ENTRYFORM-023: a search still on its way when the field is emptied does not bring its results back', async () => { + vi.useFakeTimers(); + let answer: (v: unknown) => void = () => {}; + vi.spyOn(mapsApi, 'search').mockReturnValue(new Promise((resolve) => (answer = resolve)) as never); + const { result } = setup(); + act(() => result.current.searchLocation('Par')); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(result.current.locationSearching).toBe(true); + + act(() => result.current.searchLocation('')); + expect(result.current.locationSearching).toBe(false); + await act(async () => { + answer({ places: [{ name: 'Paris', lat: '48.85', lng: '2.35' }] }); + }); + expect(result.current.locationResults).toEqual([]); + expect(result.current.locationSearching).toBe(false); + }); + + it('FE-JRN-ENTRYFORM-024: typed text without a picked result keeps the stored location', async () => { + const { result, props } = setup(); + act(() => result.current.searchLocation('Mil')); + expect(result.current.locationName).toBe('Rome'); + expect(result.current.locationLat).toBe(41.89); + + await act(() => result.current.handleSave()); + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ location_name: 'Rome', location_lat: 41.89, location_lng: 12.49 }), + 4 + ); + }); }); diff --git a/client/src/components/Journey/useJourneyEntryForm.ts b/client/src/components/Journey/useJourneyEntryForm.ts index 808f18d7d..b641890f6 100644 --- a/client/src/components/Journey/useJourneyEntryForm.ts +++ b/client/src/components/Journey/useJourneyEntryForm.ts @@ -102,6 +102,8 @@ export function useJourneyEntryForm({ const [locating, setLocating] = useState(false); const [locationError, setLocationError] = useState(''); const locationTimerRef = useRef | null>(null); + // Counts location lookups, so an answer that comes back after the field moved on is dropped. + const locationSearchSeqRef = useRef(0); const [mood, setMood] = useState(entry.mood || ''); const [weather, setWeather] = useState(entry.weather || ''); const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false); @@ -423,8 +425,19 @@ export function useJourneyEntryForm({ const searchLocation = (query: string) => { setLocationQuery(query); - setShowLocationResults(true); if (locationTimerRef.current) clearTimeout(locationTimerRef.current); + const seq = ++locationSearchSeqRef.current; + // An emptied field removes the place instead of falling back to the stored one (#2615). + if (!query.trim()) { + setLocationName(''); + setLocationLat(null); + setLocationLng(null); + setLocationResults([]); + setLocationSearching(false); + setShowLocationResults(false); + return; + } + setShowLocationResults(true); if (query.trim().length < 2) { setLocationResults([]); return; @@ -433,6 +446,7 @@ export function useJourneyEntryForm({ setLocationSearching(true); try { const res = await mapsApi.search(query, placeLang); + if (seq !== locationSearchSeqRef.current) return; setLocationResults( (res.places || []) .slice(0, 6) @@ -444,14 +458,16 @@ export function useJourneyEntryForm({ })) ); } catch { - setLocationResults([]); + if (seq === locationSearchSeqRef.current) setLocationResults([]); } finally { - setLocationSearching(false); + if (seq === locationSearchSeqRef.current) setLocationSearching(false); } }, 400); }; const pickLocation = (r: LocationResult) => { + locationSearchSeqRef.current++; + setLocationSearching(false); setLocationName(r.name); setLocationLat(r.lat); setLocationLng(r.lng); @@ -470,6 +486,7 @@ export function useJourneyEntryForm({ // reverse geocode comes back. const fallbackName = `${pos.lat.toFixed(5)}, ${pos.lng.toFixed(5)}`; if (locationTimerRef.current) clearTimeout(locationTimerRef.current); + locationSearchSeqRef.current++; setLocationSearching(false); setLocationLat(pos.lat); setLocationLng(pos.lng); diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx index 47029f37d..e27d96aab 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -1333,3 +1333,24 @@ describe('MJourneyEntrySheet clips', () => { expect(document.querySelectorAll('svg.lucide-play')).toHaveLength(1); }); }); + +// FE-MOB-JENTRY-057: the phone half of the location field that snapped back (#2615). + +describe('MJourneyEntrySheet location field', () => { + it('FE-MOB-JENTRY-057: emptying the location keeps the field empty, drops the pin and saves no place', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 5, location_name: 'Rome', location_lat: 41.9, location_lng: 12.5 })); + const input = screen.getByPlaceholderText('Search location...'); + expect(input).toHaveValue('Rome'); + expect(document.querySelector('svg.lucide-map-pin')).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'Rom' } }); + fireEvent.change(input, { target: { value: '' } }); + expect(input).toHaveValue(''); + expect(document.querySelector('svg.lucide-map-pin')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ location_name: null, location_lat: null, location_lng: null }); + }); +});