diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index a051bfb7c..f11bfc782 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-106 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-107 import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1441,6 +1441,22 @@ describe('ReservationModal', () => { ); }); + it('FE-PLANNER-RESMODAL-107: a create-expense click that saves nothing does not open the editor on the next plain save', async () => { + budgetEnabled(); + const onSave = vi.fn().mockResolvedValue({ id: 85 }); + const onOpenExpense = vi.fn(); + render(); + + // No title yet: the save returns early and nothing is created. + await userEvent.click(screen.getByRole('button', { name: /Create expense/i })); + expect(onSave).not.toHaveBeenCalled(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Dinner'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onOpenExpense).not.toHaveBeenCalled(); + }); + // ── File error paths ──────────────────────────────────────────────────────── it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => { diff --git a/client/src/components/Planner/useBookingExpenseIntent.test.ts b/client/src/components/Planner/useBookingExpenseIntent.test.ts index c3c0f2f30..872da5bc8 100644 --- a/client/src/components/Planner/useBookingExpenseIntent.test.ts +++ b/client/src/components/Planner/useBookingExpenseIntent.test.ts @@ -1,4 +1,4 @@ -// FE-PLANNER-BOOKINGEXPENSE-001 to -004: the expense wish of the booking and +// FE-PLANNER-BOOKINGEXPENSE-001 to -006: the expense wish of the booking and // transport forms, desktop and phone alike. import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -51,4 +51,33 @@ describe('useBookingExpenseIntent', () => { await expect(result.current.remove(buildBudgetItem())).resolves.toBeUndefined(); expect(submit).not.toHaveBeenCalled(); }); + + it('FE-PLANNER-BOOKINGEXPENSE-005: a save that ends without reading the wish does not leave it for the next save', async () => { + // The desktop reads the wish only after a successful save; an empty title + // returns before that point. + const submit = vi.fn(async () => undefined); + const { result } = renderHook(() => useBookingExpenseIntent(submit)); + await act(async () => result.current.create()); + expect(result.current.take()).toBeNull(); + }); + + it('FE-PLANNER-BOOKINGEXPENSE-006: a save that throws does not leave the wish for the next save', async () => { + let fail: (e: Error) => void = () => undefined; + const submit = vi.fn( + () => + new Promise((_resolve, reject) => { + fail = reject; + }) + ); + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + try { + const { result } = renderHook(() => useBookingExpenseIntent(submit)); + act(() => result.current.edit(buildBudgetItem({ id: 9 }))); + await act(async () => fail(new Error('save failed'))); + expect(result.current.take()).toBeNull(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); }); diff --git a/client/src/components/Planner/useBookingExpenseIntent.ts b/client/src/components/Planner/useBookingExpenseIntent.ts index 01937019f..31f2a48b7 100644 --- a/client/src/components/Planner/useBookingExpenseIntent.ts +++ b/client/src/components/Planner/useBookingExpenseIntent.ts @@ -20,6 +20,16 @@ export interface BookingExpenseRemoval { export function useBookingExpenseIntent(submit: () => Promise, removal?: BookingExpenseRemoval) { const intentRef = useRef(null); + // The wish belongs to the save its click starts. A save that returns early + // (no title) or throws never reads it, and it must not open the editor on + // the next, unrelated save. A newer click's wish is left alone. + const saveWith = (intent: BookingExpenseIntent) => { + intentRef.current = intent; + void submit().finally(() => { + if (intentRef.current === intent) intentRef.current = null; + }); + }; + return { /** Forgets a wish left from an earlier opening. */ reset: () => { @@ -31,15 +41,9 @@ export function useBookingExpenseIntent(submit: () => Promise, removal? intentRef.current = null; return intent; }, - create: () => { - intentRef.current = { create: true }; - void submit(); - }, + create: () => saveWith({ create: true }), /** Saves the booking first, then opens the linked expense. */ - edit: (item: BudgetItem) => { - intentRef.current = { editItem: item }; - void submit(); - }, + edit: (item: BudgetItem) => saveWith({ editItem: item }), remove: async (item: BudgetItem) => { if (!removal) return; try {