From e8431d64263cc4ac4cd4467791faa290a4025123 Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Wed, 9 Sep 2026 16:42:26 +0800 Subject: [PATCH] fix: close file picker on outside click and modal dismiss --- .../Planner/ReservationModal.test.tsx | 31 ++++++++++++++++++- .../components/Planner/ReservationModal.tsx | 16 +++++++++- .../Planner/TransportModal.test.tsx | 31 ++++++++++++++++++- .../src/components/Planner/TransportModal.tsx | 16 +++++++++- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 8258f2d42..b129bea73 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-093 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -558,6 +558,35 @@ describe('ReservationModal', () => { }); }); + it('FE-PLANNER-RESMODAL-094: an outside pointer closes the file picker while an inside pointer keeps it open', async () => { + const res = buildReservation({ id: 5 }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + + const pickerItem = screen.getByText('invoice.pdf'); + fireEvent.pointerDown(pickerItem); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-095: closing and reopening the modal resets the file picker', async () => { + const res = buildReservation({ id: 5 }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + const { rerender } = render(); + + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + + rerender(); + rerender(); + + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-RESMODAL-040: removing pending file removes it from list', async () => { render(); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 92034200a..6fb092ffa 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -84,6 +84,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const [linkedFileIds, setLinkedFileIds] = useState([]) // Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves. const [travelerIds, setTravelerIds] = useState>(new Set()) + const filePickerRef = useRef(null) + + useEffect(() => { + if (!showFilePicker) return + const closeOnOutsidePointer = (event: PointerEvent) => { + if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false) + } + document.addEventListener('pointerdown', closeOnOutsidePointer) + return () => document.removeEventListener('pointerdown', closeOnOutsidePointer) + }, [showFilePicker]) + + useEffect(() => { + if (!isOpen) setShowFilePicker(false) + }, [isOpen]) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), @@ -731,7 +745,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p {uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')} } {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( - + setShowFilePicker(v => !v)} className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index a77d2f877..c8c651384 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-061 +// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -259,6 +259,35 @@ describe('TransportModal', () => { }); }); + it('FE-PLANNER-TRANSMODAL-063: an outside pointer closes the file picker while an inside pointer keeps it open', async () => { + const res = buildReservation({ id: 5, type: 'flight' }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + + const pickerItem = screen.getByText('invoice.pdf'); + fireEvent.pointerDown(pickerItem); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-064: closing and reopening the modal resets the file picker', async () => { + const res = buildReservation({ id: 5, type: 'flight' }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + const { rerender } = render(); + + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + + rerender(); + rerender(); + + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => { render(); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 466c47924..9e2b330c2 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -234,6 +234,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // Travelers assigned to this booking (#1517) — seeded on open, persisted after save. const [travelerIds, setTravelerIds] = useState>(new Set()) const fileInputRef = useRef(null) + const filePickerRef = useRef(null) + + useEffect(() => { + if (!showFilePicker) return + const closeOnOutsidePointer = (event: PointerEvent) => { + if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false) + } + document.addEventListener('pointerdown', closeOnOutsidePointer) + return () => document.removeEventListener('pointerdown', closeOnOutsidePointer) + }, [showFilePicker]) + + useEffect(() => { + if (!isOpen) setShowFilePicker(false) + }, [isOpen]) useEffect(() => { if (!isOpen) return @@ -1182,7 +1196,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel {uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')} } {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( - + setShowFilePicker(v => !v)} className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',