mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(reservations): accept a booking that starts and ends on the same day
A permit valid for one day has no clock on either end. isEndBeforeStart filled
both missing times with midnight and then compared strictly, so start and end
came out equal and the form refused it: inline error, dead save button, toast
on submit.
It also made existing rows uneditable. A booking whose end is a bare date on the
start day is exactly what the booking import writes, and what the mobile sheet
has been allowed to save since 72a82b3c. Opening one of those on the desktop
left the save button dead with no way to clear it.
With a time missing on either side the booking is all-day, so only the dates are
compared. An end date before the start is still refused, with or without times,
and two explicit clocks still have to be in order.
The hotel guard comes along for the same reason mobile carries it: the date
panel is hidden for hotels, so switching type after typing dates left the button
disabled with its explanation inside the hidden block.
Fixes #2107
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-080
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-093
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -1437,6 +1437,96 @@ describe('ReservationModal', () => {
|
||||
expect(times.map(i => i.value)).toEqual(['', '', '']);
|
||||
});
|
||||
|
||||
// #2107 — a booking that lasts a whole day has no clock to compare, and filling the
|
||||
// missing one with midnight made the comparison read as inverted.
|
||||
it('FE-PLANNER-RESMODAL-089: the same start and end date with no times is accepted', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={reviewDays()} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Park permit');
|
||||
const datePickers = screen.getAllByTestId('date-picker');
|
||||
fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } });
|
||||
fireEvent.change(datePickers[1], { target: { value: '2026-05-02' } });
|
||||
|
||||
expect(screen.queryByText(/End date\/time must be after start/i)).toBeNull();
|
||||
fireEvent.submit(document.querySelector('form')!);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-090: an all-day booking is saved as bare dates on both ends', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={reviewDays()} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Park permit');
|
||||
const datePickers = screen.getAllByTestId('date-picker');
|
||||
fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } });
|
||||
fireEvent.change(datePickers[1], { target: { value: '2026-05-02' } });
|
||||
fireEvent.submit(document.querySelector('form')!);
|
||||
|
||||
// The stored shape matters: the calendar export branches on whether the value
|
||||
// carries a clock, and a bare date is what makes it an all-day event.
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reservation_time: '2026-05-02', reservation_end_time: '2026-05-02' }),
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-091: an end date before the start is still refused without times', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={reviewDays()} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Backwards');
|
||||
const datePickers = screen.getAllByTestId('date-picker');
|
||||
fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } });
|
||||
fireEvent.change(datePickers[1], { target: { value: '2026-05-01' } });
|
||||
fireEvent.submit(document.querySelector('form')!);
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/End date\/time must be after start/i), 'error', undefined);
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-092: a stored booking whose end is a bare date on the start day stays editable', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
// The shape the booking import and the mobile sheet both write. Nothing is typed
|
||||
// here: the row alone used to leave the save button dead.
|
||||
const res = buildReservation({
|
||||
id: 21, type: 'event', title: 'Day permit',
|
||||
reservation_time: '2026-05-02T10:00:00', reservation_end_time: '2026-05-02',
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} reservation={res} onSave={onSave} days={reviewDays()} />);
|
||||
|
||||
expect(screen.queryByText(/End date\/time must be after start/i)).toBeNull();
|
||||
fireEvent.submit(document.querySelector('form')!);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-093: switching to hotel clears a date error the hidden panel cannot explain', async () => {
|
||||
render(<ReservationModal {...defaultProps} days={reviewDays()} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Switched');
|
||||
const datePickers = screen.getAllByTestId('date-picker');
|
||||
const timePickers = screen.getAllByTestId('time-picker');
|
||||
fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } });
|
||||
fireEvent.change(timePickers[0], { target: { value: '19:00' } });
|
||||
fireEvent.change(datePickers[1], { target: { value: '2026-05-01' } });
|
||||
expect(screen.getByText(/End date\/time must be after start/i)).toBeTruthy();
|
||||
// The footer button reads 'Add' while creating and 'Update' while editing.
|
||||
const save = () => Array.from(document.querySelectorAll('button'))
|
||||
.find(b => /^\s*(Add|Update)\s*$/.test(b.textContent || '')) as HTMLButtonElement;
|
||||
expect(save().disabled).toBe(true);
|
||||
|
||||
// The date panel is hidden for hotels, and the message sits inside it, so
|
||||
// asserting on the message proves nothing here. The save button is the part
|
||||
// that stayed dead with no visible reason.
|
||||
// The hotel type is labelled 'Accommodation' in the picker.
|
||||
const hotelBtn = Array.from(document.querySelectorAll('button'))
|
||||
.find(b => /^\s*Accommodation\s*$/.test(b.textContent || ''))!;
|
||||
fireEvent.click(hotelBtn);
|
||||
expect(save().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => {
|
||||
const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' });
|
||||
(res as unknown as { metadata: string }).metadata =
|
||||
|
||||
@@ -224,14 +224,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
return next
|
||||
})
|
||||
|
||||
// Mirrors the mobile sheet, which has had this shape since 72a82b3c while the
|
||||
// desktop copy was never pulled across (#2107). Filling a missing clock with '00:00' made a
|
||||
// day-long booking compare as midnight against midnight, and the comparison is
|
||||
// strict, so an all-day permit on a single date was refused. It also left every
|
||||
// booking with a date-only end time uneditable here, which is the shape the booking
|
||||
// import and the mobile sheet both write.
|
||||
//
|
||||
// The hotel guard matters for the same reason it does on mobile: the date panel is
|
||||
// hidden for hotels, so a type switch after typing dates would leave the save button
|
||||
// dead with its explanation inside the hidden block.
|
||||
const isEndBeforeStart = (() => {
|
||||
if (!form.end_date || !form.reservation_time) return false
|
||||
if (form.type === 'hotel' || !form.end_date || !form.reservation_time) return false
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
const startTime = form.reservation_time.split('T')[1] || ''
|
||||
const endTime = form.reservation_end_time || ''
|
||||
// Without a time on either side the booking is all-day, so an end on the
|
||||
// start day is fine — only compare the dates there.
|
||||
if (!startTime || !endTime) return form.end_date < startDate
|
||||
return `${form.end_date}T${endTime}` <= `${startDate}T${startTime}`
|
||||
})()
|
||||
|
||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||
|
||||
Reference in New Issue
Block a user