diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 957dc378f..7932e40bd 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -95,6 +95,36 @@ describe('TransportModal', () => { expect(screen.getByText(/Edit transport/i)).toBeInTheDocument(); }); + it('FE-PLANNER-TRANSMODAL-003b: shows a delete action when editing with onDelete', () => { + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + render(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-003c: no delete action when creating, or when onDelete is omitted', () => { + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + const { rerender } = render(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-003d: confirming delete calls onDelete then onClose', async () => { + const onDelete = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + render(); + + await userEvent.click(screen.getByRole('button', { name: /delete/i })); + expect(screen.getByText('Delete booking?')).toBeInTheDocument(); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + await userEvent.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 197a12e45..3ef684565 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams } from 'react-router' import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react' import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' import CustomSelect from '../shared/CustomSelect' import { BookingCodeInput } from '../shared/BookingCode' import CustomTimePicker from '../shared/CustomTimePicker' @@ -167,6 +168,8 @@ interface TransportModalProps { files?: TripFile[] onFileUpload?: (fd: FormData) => Promise onFileDelete?: (fileId: number) => Promise + /** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */ + onDelete?: () => void | Promise onOpenExpense?: (req: BookingExpenseRequest) => void // Pre-fill a brand-new transport booking from a parsed import item (review- // before-save); like `reservation` for the form but stays in create mode. @@ -186,7 +189,7 @@ interface TransportModalProps { tripMembers?: TripMember[] } -export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) { +export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() // The trip's places, offered by every location field of the manual tab (#2468). @@ -204,6 +207,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // Manual vs Automated (public transit search) creation mode (#1065). const [automated, setAutomated] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [fromPick, setFromPick] = useState({}) const [toPick, setToPick] = useState({}) // Flight route as an ordered list of airports (origin .. stops .. destination). @@ -724,15 +728,24 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} size="2xl" footer={ -
- - {!automated && ( - - )} +
+
+ {!automated && reservation?.id && onDelete && ( + + )} +
+
+ + {!automated && ( + + )} +
} > @@ -1245,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel )} + + setShowDeleteConfirm(false)} + title={t('reservations.confirm.deleteTitle')} + message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + onConfirm={async () => { + setShowDeleteConfirm(false) + await onDelete?.() + onClose() + }} + /> ) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 59deb9195..a9455f057 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1103,7 +1103,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {showTransportModal && ( - { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} /> + { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onDelete={can('reservation_edit', trip) && editingTransport ? () => handleDeleteReservation(editingTransport.id) : undefined} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} /> )} diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index e46add188..2b58563f9 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -1170,6 +1170,27 @@ describe('TripPlannerPage — modals', () => { expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 2) }) + it('FE-PAGE-TPW-053b: the transport edit window deletes the booking when the user may edit reservations', async () => { + const res = buildReservation({ id: 55, type: 'flight', title: 'LH 400' }) + renderPage({ showTransportModal: true, editingTransport: res }) + + await act(async () => { await props('transportModal').onDelete() }) + expect(hookState.handleDeleteReservation).toHaveBeenCalledWith(55) + }) + + it('FE-PAGE-TPW-053c: no transport delete without an edited booking or permission', () => { + renderPage({ showTransportModal: true }) + expect(props('transportModal').onDelete).toBeUndefined() + + cleanup() + renderPage({ + showTransportModal: true, + editingTransport: buildReservation({ id: 55, type: 'flight' }), + can: vi.fn((action: string) => action !== 'reservation_edit'), + }) + expect(props('transportModal').onDelete).toBeUndefined() + }) + it('FE-PAGE-TPW-054: during a review the transport modal exits advance the queue too', async () => { renderPage({ showTransportModal: true, importReviewActive: true })