fix(planner): transport edit window can delete the booking

A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
This commit is contained in:
gso
2026-09-26 05:39:26 +02:00
committed by Maurice
parent 06bee95ae3
commit e6f66014a5
4 changed files with 89 additions and 11 deletions
@@ -95,6 +95,36 @@ describe('TransportModal', () => {
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument(); 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(<TransportModal {...defaultProps} reservation={res} onDelete={vi.fn()} />);
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(<TransportModal {...defaultProps} reservation={null} onDelete={vi.fn()} />);
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
rerender(<TransportModal {...defaultProps} reservation={res} />);
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(<TransportModal {...defaultProps} reservation={res} onDelete={onDelete} onClose={onClose} />);
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 () => { it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />); render(<TransportModal {...defaultProps} onSave={onSave} />);
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router' 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 { 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 Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode' import { BookingCodeInput } from '../shared/BookingCode'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
@@ -167,6 +168,8 @@ interface TransportModalProps {
files?: TripFile[] files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<unknown> onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void> onFileDelete?: (fileId: number) => Promise<void>
/** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */
onDelete?: () => void | Promise<void>
onOpenExpense?: (req: BookingExpenseRequest) => void onOpenExpense?: (req: BookingExpenseRequest) => void
// Pre-fill a brand-new transport booking from a parsed import item (review- // 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. // before-save); like `reservation` for the form but stays in create mode.
@@ -186,7 +189,7 @@ interface TransportModalProps {
tripMembers?: TripMember[] 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 { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
// The trip's places, offered by every location field of the manual tab (#2468). // 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). // Manual vs Automated (public transit search) creation mode (#1065).
const [automated, setAutomated] = useState(false) const [automated, setAutomated] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({}) const [toPick, setToPick] = useState<EndpointPick>({})
// Flight route as an ordered list of airports (origin .. stops .. destination). // Flight route as an ordered list of airports (origin .. stops .. destination).
@@ -724,7 +728,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl" size="2xl"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}>
<div>
{!automated && reservation?.id && onDelete && (
<button type="button" onClick={() => setShowDeleteConfirm(true)} className="text-danger" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Trash2 size={13} /> {t('common.delete')}
</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}> <button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
@@ -734,6 +746,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</button> </button>
)} )}
</div> </div>
</div>
} }
> >
{/* Manual vs Automated creation switch (#1065) — creating only; editing a {/* Manual vs Automated creation switch (#1065) — creating only; editing a
@@ -1245,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</form> </form>
)} )}
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => 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()
}}
/>
</Modal> </Modal>
) )
} }
+1 -1
View File
@@ -1103,7 +1103,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{showTransportModal && ( {showTransportModal && (
<ErrorBoundary boundaryId="planner-panel:transport" fallback={null}> <ErrorBoundary boundaryId="planner-panel:transport" fallback={null}>
<Suspense fallback={null}> <Suspense fallback={null}>
<TransportModal isOpen={showTransportModal} onClose={() => { 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} /> <TransportModal isOpen={showTransportModal} onClose={() => { 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} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
@@ -1170,6 +1170,27 @@ describe('TripPlannerPage — modals', () => {
expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 2) 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 () => { it('FE-PAGE-TPW-054: during a review the transport modal exits advance the queue too', async () => {
renderPage({ showTransportModal: true, importReviewActive: true }) renderPage({ showTransportModal: true, importReviewActive: true })