fix(journey): a failed entry save on the phone says so instead of failing silently

Saving a journey entry on the phone sheet while the server refused it
(a locked journey, a dropped connection) left the sheet sitting there
with no message, and the rejection went unhandled. The desktop dialog
toasted journey.settings.saveFailed or the server's reason.

The cause was the toastSaveError option of the shared useJourneyEntryForm
hook: only the desktop set it, and without it handleSave rethrew into a
click handler that nothing awaited. The option only kept the phone on
the broken path, so it is gone and both views toast and keep the form
open.

Red before the fix: src/components/Journey/useJourneyEntryForm.test.ts
FE-JRN-ENTRYFORM-021 failed because handleSave rejected with
"{ response: { data: { error: 'Locked' } } }" instead of toasting.

Changed assertions in FE-JRN-ENTRYFORM-007 (renamed to "a failed save
toasts for the desktop dialog and the sheet alike"): the sheet's save
was expected to reject (rejected true) with one toast in total; it now
resolves (rejected false) and the toast count is 2.
This commit is contained in:
Maurice
2026-10-09 17:33:43 +02:00
parent f19b52fb20
commit 65448ce186
3 changed files with 16 additions and 10 deletions
@@ -149,7 +149,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
handleSave, handleFileChange, pickGalleryPhoto, removePhoto, searchLocation, pickLocation, handleUseCurrentLocation,
} = useJourneyEntryForm({
entry, journeyId, trips, galleryPhotos, onSave, onUploadPhotos, onAddProviderPhotos, onDone,
blankVerdictRow: true, dirtyOnCoordinates: true, toastSaveError: true, autoFillWeather: showWeather,
blankVerdictRow: true, dirtyOnCoordinates: true, autoFillWeather: showWeather,
})
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
@@ -1,4 +1,4 @@
// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-020: the journey entry form logic behind both
// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-021: 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';
@@ -198,9 +198,9 @@ describe('useJourneyEntryForm', () => {
);
});
it('FE-JRN-ENTRYFORM-007: a failed save toasts for the desktop dialog and rejects for the sheet', async () => {
it('FE-JRN-ENTRYFORM-007: a failed save toasts for the desktop dialog and the sheet alike', async () => {
const onSave = vi.fn().mockRejectedValue({ response: { data: { error: 'Locked' } } });
const desktop = setup({ onSave, toastSaveError: true });
const desktop = setup({ onSave, blankVerdictRow: true, dirtyOnCoordinates: true });
await act(() => desktop.result.current.handleSave());
expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined);
expect(desktop.result.current.saving).toBe(false);
@@ -208,9 +208,9 @@ describe('useJourneyEntryForm', () => {
const sheet = setup({ onSave });
let rejected = false;
await act(() => sheet.result.current.handleSave().catch(() => void (rejected = true)));
expect(rejected).toBe(true);
expect(rejected).toBe(false);
expect(sheet.result.current.saving).toBe(false);
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledTimes(2);
});
it('FE-JRN-ENTRYFORM-008: links gallery photos picked before the first save', async () => {
@@ -378,4 +378,14 @@ describe('useJourneyEntryForm', () => {
.offersStatsToggle
).toBe(true);
});
it('FE-JRN-ENTRYFORM-021: a failed save on the phone sheet toasts and keeps the form open', async () => {
const onSave = vi.fn().mockRejectedValue({ response: { data: { error: 'Locked' } } });
const onDone = vi.fn();
const sheet = setup({ onSave, onDone, withTags: true });
await act(() => sheet.result.current.handleSave());
expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined);
expect(onDone).not.toHaveBeenCalled();
expect(sheet.result.current.saving).toBe(false);
});
});
@@ -49,8 +49,6 @@ export interface JourneyEntryFormOptions {
withTags?: boolean;
/** Moving the point alone, without renaming it, counts as an unsaved change (the desktop dialog). */
dirtyOnCoordinates?: boolean;
/** Toast a failed save and swallow it (the desktop dialog); otherwise the save rejects. */
toastSaveError?: boolean;
/** Show a failed "use my location" under the field (the phone sheet) instead of as a toast. */
inlineLocateError?: boolean;
/** Fill an empty weather field from the forecast once the place and day are known (the desktop dialog). */
@@ -81,7 +79,6 @@ export function useJourneyEntryForm({
blankVerdictRow = false,
withTags = false,
dirtyOnCoordinates = false,
toastSaveError = false,
inlineLocateError = false,
autoFillWeather = false,
}: JourneyEntryFormOptions) {
@@ -378,7 +375,6 @@ export function useJourneyEntryForm({
}
onDone();
} catch (err) {
if (!toastSaveError) throw err;
// Neither the page callback nor journeyStore toasts, so without this the
// whole entry just fails to save with no sign of it.
toast.error(getApiErrorMessage(err, t('journey.settings.saveFailed')));