refactor(planner): share the booking form and its hotel stay between desktop and phone

The booking dialog and the phone booking sheet keep their markup and run on useReservationForm: how a saved booking or an import fills the form, the end before start check, the start date and time, the place and hotel picks, the stay range and the request body with the stay a hotel booking creates come from reservationFormModel. The desktop's check-in window, its link to a stop inside the form and its imported price stay its own, behind options where the code is shared.
This commit is contained in:
Maurice
2026-10-09 10:43:50 +02:00
parent 203e425e7e
commit fc3cc1bbbe
14 changed files with 1360 additions and 571 deletions
@@ -0,0 +1,96 @@
// FE-PLANNER-BOOKINGFILES-001 to -005: the Files row of the desktop booking and
// transport dialogs.
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import type { TripFile } from '../../types';
import { type BookingFileAttachOptions, useBookingFileAttach } from './useBookingFileAttach';
type FileEvent = React.ChangeEvent<HTMLInputElement> & { target: { value: string } };
function changeEvent(file?: File): FileEvent {
const target = { files: file ? [file] : [], value: 'picked.pdf' };
return { target } as unknown as FileEvent;
}
function setup(overrides: Partial<BookingFileAttachOptions> = {}, files: TripFile[] = []) {
const toast = { success: vi.fn(), error: vi.fn() };
const t = (key: string) => key;
const view = renderHook(() => {
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const attach = useBookingFileAttach({
reservation: { id: 5, title: 'Hotel' },
files,
setPendingFiles,
toast,
t,
...overrides,
});
return { attach, pendingFiles };
});
return { ...view, toast };
}
describe('useBookingFileAttach', () => {
it('FE-PLANNER-BOOKINGFILES-001: a file on a saved booking goes up at once and the input clears', async () => {
const onFileUpload = vi.fn(async (_fd: FormData) => undefined);
const { result, toast } = setup({ onFileUpload });
const e = changeEvent(new File(['a'], 'a.pdf'));
await act(() => result.current.attach.handleFileChange(e));
const fd = onFileUpload.mock.calls[0][0];
expect(fd.get('reservation_id')).toBe('5');
expect(fd.get('description')).toBe('Hotel');
expect(toast.success).toHaveBeenCalledWith('reservations.toast.fileUploaded');
expect(e.target.value).toBe('');
expect(result.current.attach.uploadingFile).toBe(false);
expect(result.current.pendingFiles).toEqual([]);
});
it('FE-PLANNER-BOOKINGFILES-002: a failed upload, or a dialog without one, says so', async () => {
const onFileUpload = vi.fn(async () => {
throw new Error('boom');
});
const failing = setup({ onFileUpload });
await act(() => failing.result.current.attach.handleFileChange(changeEvent(new File(['a'], 'a.pdf'))));
expect(failing.toast.error).toHaveBeenCalledWith('reservations.toast.uploadError');
const without = setup({ onFileUpload: undefined });
await act(() => without.result.current.attach.handleFileChange(changeEvent(new File(['a'], 'a.pdf'))));
expect(without.toast.error).toHaveBeenCalledWith('reservations.toast.uploadError');
expect(without.toast.success).not.toHaveBeenCalled();
});
it('FE-PLANNER-BOOKINGFILES-003: on a new booking the file waits for the save and can be taken back', async () => {
const onFileUpload = vi.fn(async () => undefined);
const { result } = setup({ reservation: null, onFileUpload });
const a = new File(['a'], 'a.pdf');
const b = new File(['b'], 'b.pdf');
const e = changeEvent(a);
await act(() => result.current.attach.handleFileChange(e));
await act(() => result.current.attach.handleFileChange(changeEvent(b)));
await act(() => result.current.attach.handleFileChange(changeEvent()));
expect(onFileUpload).not.toHaveBeenCalled();
expect(e.target.value).toBe('');
expect(result.current.pendingFiles).toEqual([a, b]);
act(() => result.current.attach.removePending(0));
expect(result.current.pendingFiles).toEqual([b]);
});
it('FE-PLANNER-BOOKINGFILES-004: files linked and detached in the dialog show on the booking', () => {
const files = [buildTripFile({ id: 1, reservation_id: 5 }), buildTripFile({ id: 2, reservation_id: null })];
const { result } = setup({}, files);
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1]);
act(() => result.current.attach.linkFile(2));
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1, 2]);
act(() => result.current.attach.detachFile(2));
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1]);
});
it('FE-PLANNER-BOOKINGFILES-005: a new booking lists no attached files', () => {
const { result } = setup({ reservation: null }, [buildTripFile({ id: 1, reservation_id: 5 })]);
expect(result.current.attach.attachedFiles).toEqual([]);
});
});