fix: close file picker on outside click and modal dismiss

This commit is contained in:
Luffy
2026-09-09 16:42:26 +08:00
committed by Maurice
parent 41b18f970c
commit e8431d6426
4 changed files with 90 additions and 4 deletions
@@ -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(<ReservationModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
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(<ReservationModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
rerender(<ReservationModal {...defaultProps} isOpen={false} reservation={res} files={[unattachedFile]} />);
rerender(<ReservationModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-040: removing pending file removes it from list', async () => {
render(<ReservationModal {...defaultProps} reservation={null} />);
@@ -84,6 +84,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
// Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const filePickerRef = useRef<HTMLDivElement>(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')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<div ref={filePickerRef} style={{ position: 'relative' }}>
<button type="button" onClick={() => 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',
@@ -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(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
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(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
rerender(<TransportModal {...defaultProps} isOpen={false} reservation={res} files={[unattachedFile]} />);
rerender(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
@@ -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<Set<number>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const filePickerRef = useRef<HTMLDivElement>(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')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<div ref={filePickerRef} style={{ position: 'relative' }}>
<button type="button" onClick={() => 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',