fix(client): the trip form's review and the slow-open splash, and the tests that followed

Inside the trip settings, Escape and the backdrop no longer close the dialog
while the date question is up, and the embedded form does not load the
members it does not show. Every trip open is timed afresh for the splash.
The tests follow the cover image card, the danger button, the delayed splash
and the open and download mocks.
This commit is contained in:
Maurice
2026-11-13 12:57:57 +01:00
parent 71cc97822a
commit 9cbf31da27
16 changed files with 731 additions and 484 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
"@typescript-eslint/no-non-null-assertion": 1907,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 27,
"@typescript-eslint/no-floating-promises": 33,
"trek/no-window-globals": 325,
"trek/no-swallowed-catch": 14,
"react-hooks/globals": 5,
-1
View File
@@ -1025,7 +1025,6 @@
"src/components/Tours/useTourPermissions.ts",
"src/components/Trips/TripDateReview.tsx",
"src/components/Trips/TripFormModal.test.tsx",
"src/components/Trips/TripFormModal.tsx",
"src/components/Trips/TripMemberAvatar.tsx",
"src/components/Trips/TripMembersModal.test.tsx",
"src/components/Trips/TripMembersModal.tsx",
@@ -152,7 +152,7 @@ describe('TripFormModal', () => {
it('FE-COMP-TRIPFORM-013: shows Cover Image section', () => {
render(<TripFormModal {...defaultProps} />);
expect(screen.getByText('Cover Image')).toBeInTheDocument();
expect(screen.getByText('Cover image')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-014: shows start and end date labels', () => {
@@ -538,7 +538,7 @@ describe('TripFormModal', () => {
it('FE-COMP-TRIPFORM-040: a users payload without a list hides the member section', async () => {
server.use(http.get('/api/auth/users', () => HttpResponse.json({})));
render(<TripFormModal {...defaultProps} trip={null} />);
await screen.findByText('Cover Image');
await screen.findByText('Cover image');
expect(screen.queryByText('Travel buddies')).not.toBeInTheDocument();
});
@@ -910,7 +910,7 @@ describe('TripFormModal', () => {
seedStore(usePermissionsStore, { permissions: { trip_cover_upload: 'admin', trip_edit: 'admin' } });
render(<TripFormModal {...defaultProps} trip={buildTrip({ id: 1, user_id: 7, title: 'Locked' })} />);
expect(screen.queryByText('Cover Image')).not.toBeInTheDocument();
expect(screen.queryByText('Cover image')).not.toBeInTheDocument();
fireEvent.paste(document.querySelector('form')!, {
clipboardData: { items: [{ type: 'image/png', getAsFile: () => pngFile() }] },
});
@@ -1365,7 +1365,7 @@ describe('TripFormModal', () => {
// No start moved, so no shift question.
expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument();
// The longest confirm label of any language stays on one line.
expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger', 'whitespace-nowrap');
expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger');
expect(onSave).not.toHaveBeenCalled();
});
File diff suppressed because it is too large Load Diff
@@ -79,6 +79,7 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, editTrip }:
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null)
const [area, setArea] = useState<Area>('people')
const [tripReviewing, setTripReviewing] = useState(false)
const journalSection = useTripJournalSectionVisible(tripId)
const shows: Record<Area, boolean> = { people: true, sharing: m.canManageShare, journal: journalSection, trip: !!editTrip }
const areas = AREAS.filter(a => shows[a.id])
@@ -93,7 +94,7 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, editTrip }:
onClose={onClose}
labelledBy={labelId}
width={withNav ? 'wide' : 'detail'}
blocked={!!ask}
blocked={!!ask || tripReviewing}
bodyClassName={BODY}
header={(
<DialogHeader
@@ -133,7 +134,7 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, editTrip }:
)}
{editTrip && (
<AreaPanel area={panelOf('trip')} shown={current.id === 'trip'} titled>
<TripFormModal embedded isOpen onClose={onClose} {...editTrip} />
<TripFormModal embedded isOpen onClose={onClose} onReviewChange={setTripReviewing} {...editTrip} />
</AreaPanel>
)}
</div>
@@ -60,4 +60,4 @@ export function useTripSplashBeats({ liveReducedMotion }: TripSplashBeatsOptions
* slower one gets the splash right after, so a slow connection never sees the
* app stand still.
*/
export const TRIP_SPLASH_AFTER_MS = 400
export const TRIP_SPLASH_AFTER_MS = 400;
@@ -19,8 +19,8 @@ import { useState, type ReactNode } from 'react';
import CustomSelect from '../../../../components/shared/CustomSelect';
import { LinkedJournalIcon } from '../../../../components/Trips/LinkedJournalIcon';
import { TripJournalLinkSection } from '../../../../components/Trips/TripJournalLinkSection';
import { useTripJournalSectionVisible } from '../../../../components/Trips/useTripJournalLink';
import { TripMemberAvatar } from '../../../../components/Trips/TripMemberAvatar';
import { useTripJournalSectionVisible } from '../../../../components/Trips/useTripJournalLink';
import {
SHARE_OPTIONS,
SHARE_SECTIONS,
@@ -36,11 +36,11 @@ import MSheet from '../../../components/MSheet';
import MConfirmSheet from '../../settings/MConfirmSheet';
import {
SHEET_FIELD as FIELD,
INNER_CLS,
SHEET_PILL as PILL,
SHEET_PRIMARY as PRIMARY,
SHEET_ROUND as ROUND,
SHEET_ROUND_PRIMARY as ROUND_PRIMARY,
INNER_CLS,
SheetSection as Section,
TileHeader,
} from './MTripSheetUi';
@@ -400,10 +400,22 @@ function GuestsSection({ m, ask }: { m: Members; ask: Ask }) {
type Page = 'people' | 'sharing' | 'journal';
/** One row of the sheet's first page: an icon tile, the area's name, what it holds, a chevron. */
function AreaRow({ icon, label, value, onOpen }: { icon: ReactNode; label: string; value?: string; onOpen: () => void }) {
function AreaRow({
icon,
label,
value,
onOpen,
}: {
icon: ReactNode;
label: string;
value?: string;
onOpen: () => void;
}) {
return (
<button type="button" onClick={onOpen} className="flex w-full items-center gap-3 px-2.5 py-[11px] text-start">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-[color:var(--m-ic)] text-m-ink">{icon}</span>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-[color:var(--m-ic)] text-m-ink">
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{label}</span>
{value && <span className="flex-none font-geist text-caption font-semibold text-m-muted">{value}</span>}
<ChevronRight size={16} strokeWidth={2} className="flex-none text-m-faint rtl:rotate-180" />
@@ -461,7 +473,12 @@ export default function MTripSettingsSheet({
<TileHeader
icon={
page ? (
<button type="button" onClick={() => setPage(null)} aria-label={t('common.back')} className="flex h-full w-full items-center justify-center">
<button
type="button"
onClick={() => setPage(null)}
aria-label={t('common.back')}
className="flex h-full w-full items-center justify-center"
>
<ChevronLeft size={19} strokeWidth={2} className="rtl:rotate-180" />
</button>
) : (
@@ -477,7 +494,10 @@ export default function MTripSettingsSheet({
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{page === null && (
<nav aria-label={t('trip.settings.title')} className={`flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[18px] px-1.5 ${INNER_CLS}`}>
<nav
aria-label={t('trip.settings.title')}
className={`flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[18px] px-1.5 ${INNER_CLS}`}
>
<AreaRow
icon={<Users size={16} strokeWidth={2} />}
label={pageTitle.people}
@@ -485,10 +505,18 @@ export default function MTripSettingsSheet({
onOpen={() => setPage('people')}
/>
{m.canManageShare && (
<AreaRow icon={<Link2 size={16} strokeWidth={2} />} label={pageTitle.sharing} onOpen={() => setPage('sharing')} />
<AreaRow
icon={<Link2 size={16} strokeWidth={2} />}
label={pageTitle.sharing}
onOpen={() => setPage('sharing')}
/>
)}
{journalVisible && (
<AreaRow icon={<LinkedJournalIcon size={16} />} label={pageTitle.journal} onOpen={() => setPage('journal')} />
<AreaRow
icon={<LinkedJournalIcon size={16} />}
label={pageTitle.journal}
onOpen={() => setPage('journal')}
/>
)}
</nav>
)}
@@ -48,9 +48,7 @@ export default function MPackingCategorySharingSheet({
);
};
const note = (text: string) => (
<p className="mt-3 font-geist text-caption leading-relaxed text-m-muted">{text}</p>
);
const note = (text: string) => <p className="mt-3 font-geist text-caption leading-relaxed text-m-muted">{text}</p>;
return (
<MSheet open={category != null} onClose={sharing.close} form ariaLabel={t('packing.menuShareCat')}>
@@ -87,9 +85,7 @@ export default function MPackingCategorySharingSheet({
onClick={() => sharing.toggleRecipient(m.id)}
className="flex items-center gap-[8px] rounded-[9px] py-[7px] pe-[10px] ps-9 text-start"
>
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">
{m.username}
</span>
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{m.username}</span>
{picked && <Check size={14} strokeWidth={2.4} className="flex-none text-m-ink" />}
</button>
);
+4 -3
View File
@@ -301,7 +301,7 @@ describe('TripPlannerPage', () => {
});
describe('FE-PAGE-PLANNER-002: Loading state shown while loadTrip in progress', () => {
it('shows loading animation when isLoading is true', () => {
it('shows loading animation when isLoading is true', async () => {
seedStore(useTripStore, {
trip: null,
isLoading: true,
@@ -315,8 +315,9 @@ describe('TripPlannerPage', () => {
renderPlannerPage(99);
// Loading state: shows the trip-open loading splash
expect(screen.getByTestId('trip-loading-splash')).toBeInTheDocument();
// A quick open shows nothing but the page's ground; a slow one gets the splash.
expect(screen.queryByTestId('trip-loading-splash')).not.toBeInTheDocument();
expect(await screen.findByTestId('trip-loading-splash', {}, { timeout: 2000 })).toBeInTheDocument();
});
});
+1 -1
View File
@@ -63,7 +63,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// selection, CRUD handlers with undo, map filters, splash) lives in the hook.
const pageState = useTripPlannerPage()
const {
tripId, navigate, toast, t, language, placesPhotosEnabled,
tripId, navigate, t, language, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
@@ -206,7 +206,6 @@ function dialogState(): HookState {
setShowTripForm: vi.fn(),
showMembersModal: false,
setShowMembersModal: vi.fn(),
editTripFromSettings: vi.fn(),
showReservationModal: false,
setShowReservationModal: vi.fn(),
editingReservation: null,
@@ -361,7 +361,7 @@ export function useTripPlanner() {
// The dialog state that only passes through to the shells.
const {
showPlaceForm, prefillCoords, reservationModalDayId, setReservationModalDayId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, editTripFromSettings, showReservationModal,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport, bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, transportModalDayId, transportModalAutomated, setTransportModalAutomated,
@@ -710,6 +710,12 @@ export function useTripPlanner() {
// loads within TRIP_SPLASH_AFTER_MS opens straight away, with no splash and no
// minimum on screen.
const [slowOpen, setSlowOpen] = useState(false)
// Every open is timed afresh: a trip opened after a slow one starts without the splash too.
const [wasLoading, setWasLoading] = useState(isLoading)
if (wasLoading !== isLoading) {
setWasLoading(isLoading)
if (isLoading) setSlowOpen(false)
}
useEffect(() => {
if (!isLoading) return
const timer = setTimeout(() => setSlowOpen(true), TRIP_SPLASH_AFTER_MS)
@@ -758,7 +764,7 @@ export function useTripPlanner() {
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
dropPoiOnRoute,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, editTripFromSettings,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
-1
View File
@@ -134,7 +134,6 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
setShowTripForm: vi.fn(),
showMembersModal: false,
setShowMembersModal: vi.fn(),
editTripFromSettings: vi.fn(),
showReservationModal: false,
setShowReservationModal: vi.fn(),
editingReservation: null,
@@ -617,7 +617,7 @@ describe('MCollections', () => {
expect(screen.getByRole('dialog', { name: 'collections.deleteList' })).toBeInTheDocument()
expect(screen.getByText('collections.deleteListConfirm')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'packing.editDone' }))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByRole('button', { name: /common.delete/ }))
@@ -18,7 +18,7 @@ const money = (amount: number, currency: string) => formatMoney(amount, currency
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(),
openFile: vi.fn(() => Promise.resolve()),
}))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
@@ -18,7 +18,7 @@ const money = (amount: number, currency: string) => formatMoney(amount, currency
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(),
openFile: vi.fn(() => Promise.resolve()),
}))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({