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
This commit is contained in:
Maurice
2026-10-12 09:33:00 +02:00
parent 1ea72dca97
commit 11ede4fbef
4 changed files with 109 additions and 5 deletions
@@ -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 })
})
})
@@ -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
);
});
});
@@ -102,6 +102,8 @@ export function useJourneyEntryForm({
const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState('');
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | 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);