mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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(<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 () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
@@ -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<unknown>
|
||||
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
|
||||
// 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<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// 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={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', 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' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!automated && (
|
||||
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
<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' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!automated && (
|
||||
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1245,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1103,7 +1103,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
{showTransportModal && (
|
||||
<ErrorBoundary boundaryId="planner-panel:transport" 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>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user