From 11ede4fbef0f5dfdfcd6641ca6020650083fda86 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 09:33:00 +0200 Subject: [PATCH] fix(journey): emptying the location field in the entry editor removes the location The field showed the stored place as soon as the search query was empty, so deleting the last character snapped it back and the location could never be removed. Clearing the field now empties the place and its coordinates, closes the suggestions and drops any search answer that comes back afterwards. Typed text without a picked result keeps the stored location as before. The hook is shared, so the desktop dialog and the phone sheet both get the fix. Refs #2615 --- .../JourneyDetailPageEntryEditor.test.tsx | 17 +++++- .../Journey/useJourneyEntryForm.test.ts | 53 ++++++++++++++++++- .../components/Journey/useJourneyEntryForm.ts | 23 ++++++-- .../journey/MJourneyEntrySheet.test.tsx | 21 ++++++++ 4 files changed, 109 insertions(+), 5 deletions(-) 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 }); + }); +});