feat(mobile): calmer trip settings, list editing, selecting places and the journey map switch

The trip settings sheet opens on a list of its areas, each a page of its own
with a way back, and the journal there is drawn in the phone's own cards. A
new packing list is typed into a card with its add and cancel, selecting
places keeps its way out in view with the actions as icons below, and the
journey's satellite switch sits in its bar beside the menu.
This commit is contained in:
Maurice
2026-11-13 12:40:16 +01:00
parent bd58f17ec0
commit 75aeba29ed
8 changed files with 247 additions and 68 deletions
@@ -26,6 +26,11 @@ interface Props extends MapProps {
hideLayerControl?: boolean;
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
controlsTop?: number | string;
/**
* The base layer, when the page keeps the satellite switch in its own bar (the
* phone); left out, the map carries the switch in its top corner.
*/
baseLayer?: BaseLayer;
/** Leads from a picked place or stay to its entry. */
onOpenEntry?: (entryId: string) => void;
entryTitleOf?: (entryId: number) => string | null;
@@ -41,6 +46,36 @@ function offeredIf<T>(offered: unknown, action: T): T | undefined {
return offered ? action : undefined;
}
/**
* The base layer the map shows: the page's, where it keeps the switch in its own
* bar, otherwise the map's own, switched from its corner.
*/
function useBaseLayer(page: BaseLayer | undefined) {
const [own, setOwn] = useState<BaseLayer>('default');
const toggleBaseLayer = useCallback(() => setOwn((was) => (was === 'satellite' ? 'default' : 'satellite')), []);
return { baseLayer: page ?? own, toggleBaseLayer, switchOnMap: page === undefined };
}
/** The satellite switch in the map's top corner, unless the page keeps it in its own bar. */
function CornerSwitcher({
hidden,
top,
active,
onToggle,
}: {
hidden: boolean;
top: number | string;
active: BaseLayer;
onToggle: () => void;
}) {
if (hidden) return null;
return (
<div className="pointer-events-auto absolute end-3 z-10" style={{ top }}>
<MapLayerSwitcher active={active} onToggle={onToggle} tooltipPlacement="bottom" />
</div>
);
}
/**
* A journey map with its layers (JRN-04): the renderer, the reader's layer
* switches and the card of a picked plan place or stay. Every journey map
@@ -59,6 +94,7 @@ export default function JourneyLayeredMap({
engine = 'auto',
hideLayerControl = false,
controlsTop = 12,
baseLayer: pageBaseLayer,
onOpenEntry,
entryTitleOf,
onWriteAboutPlace,
@@ -68,8 +104,7 @@ export default function JourneyLayeredMap({
...rest
}: Props) {
const [picked, setPicked] = useState<{ marks: JourneyMapMark[]; key: string } | null>(null);
const [baseLayer, setBaseLayer] = useState<BaseLayer>('default');
const toggleBaseLayer = useCallback(() => setBaseLayer((was) => (was === 'satellite' ? 'default' : 'satellite')), []);
const { baseLayer, toggleBaseLayer, switchOnMap } = useBaseLayer(pageBaseLayer);
const visibility = layers?.visibility;
const onMarksPick = useCallback((marks: JourneyMapMark[]) => {
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
@@ -111,9 +146,7 @@ export default function JourneyLayeredMap({
<div className="isolate h-full w-full">
{engine === 'leaflet' ? <JourneyMap ref={ref} {...mapProps} /> : <JourneyMapAuto ref={ref} {...mapProps} />}
</div>
<div className="pointer-events-auto absolute end-3 z-10" style={{ top: controlsTop }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} tooltipPlacement="bottom" />
</div>
<CornerSwitcher hidden={!switchOnMap} top={controlsTop} active={baseLayer} onToggle={toggleBaseLayer} />
{layers && (
<div
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router'
import {
Bookmark, Check, CheckCheck, CheckSquare, ChevronDown, Copy, CopyPlus,
FolderInput, Layers, List, Map as MapIcon, Pencil, Search, Settings2, Shapes, Share2,
Tag, Tags, Trash2, X,
Tag, Tags, Trash2,
} from 'lucide-react'
import type { CollectionStatus } from '@trek/shared'
import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek'
@@ -211,6 +211,7 @@ export default function MCollections() {
/>
)
const none = c.selectedIds.length === 0
const selectChip = (disabled: boolean) =>
`flex flex-none items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-2 text-[0.75rem] font-semibold text-m-ink ${disabled ? 'opacity-40' : ''}`
@@ -466,44 +467,45 @@ export default function MCollections() {
</div>
)}
{/* Selecting: what is picked and the way out on one line, the actions as icons on
the next, so nothing scrolls sideways or hides the way back. */}
{c.view === 'list' && c.selectMode && (
<div className="mt-2 flex items-center gap-[6px] overflow-x-auto pb-[2px]">
<button type="button" onClick={c.handleSelectAll} className={selectChip(false)}>
<CheckCheck size={13} strokeWidth={2.2} />
{c.allVisibleSelected ? t('collections.deselectAll') : t('collections.selectAll')}
</button>
<span className="flex-none whitespace-nowrap px-1 font-geist text-[0.6875rem] font-bold text-m-muted">
{t('collections.selectedCount', { count: c.selectedIds.length })}
</span>
{canManageLabels && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
<Tags size={13} strokeWidth={2.2} /> {t('collections.labels.assign')}
<div className="mt-2">
<div className="flex items-center gap-2">
<button type="button" onClick={c.handleSelectAll} className={selectChip(false)}>
<CheckCheck size={13} strokeWidth={2.2} />
{c.allVisibleSelected ? t('collections.deselectAll') : t('collections.selectAll')}
</button>
)}
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
<Copy size={13} strokeWidth={2.2} /> {t('collections.copyToTrip')}
</button>
{c.canDelete && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
<FolderInput size={13} strokeWidth={2.2} /> {t('collections.moveToList')}
<span className="min-w-0 flex-1 truncate text-center font-geist text-[0.6875rem] font-bold text-m-muted">
{t('collections.selectedCount', { count: c.selectedIds.length })}
</span>
<button type="button" onClick={() => c.setSelectMode(false)} className="flex-none rounded-full bg-m-act px-[14px] py-2 text-caption font-semibold text-m-actfg">
{t('packing.editDone')}
</button>
)}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
<CopyPlus size={13} strokeWidth={2.2} /> {t('collections.duplicateToList')}
</button>
{c.canDelete && (
<button
type="button"
onClick={c.handleDeleteSelected}
disabled={c.selectedIds.length === 0}
className={`${selectChip(c.selectedIds.length === 0)} !text-[color:var(--m-st-danger)]`}
>
<Trash2 size={13} strokeWidth={2.2} /> {t('common.delete')}
</button>
)}
<button type="button" onClick={() => c.setSelectMode(false)} aria-label={t('common.cancel')} className={selectChip(false)}>
<X size={14} strokeWidth={2.2} />
</button>
</div>
<div className="mt-2 flex items-center justify-between gap-[6px]">
{canManageLabels && (
<SelectionAction label={t('collections.labels.assign')} onClick={() => c.setLabelPickerOpen(true)} disabled={none}>
<Tags size={16} strokeWidth={2} />
</SelectionAction>
)}
<SelectionAction label={t('collections.copyToTrip')} onClick={c.openCopyForSelection} disabled={none}>
<Copy size={16} strokeWidth={2} />
</SelectionAction>
{c.canDelete && (
<SelectionAction label={t('collections.moveToList')} onClick={() => c.setListPickerMode('move')} disabled={none}>
<FolderInput size={16} strokeWidth={2} />
</SelectionAction>
)}
<SelectionAction label={t('collections.duplicateToList')} onClick={() => c.setListPickerMode('copy')} disabled={none}>
<CopyPlus size={16} strokeWidth={2} />
</SelectionAction>
{c.canDelete && (
<SelectionAction label={t('common.delete')} onClick={c.handleDeleteSelected} disabled={none} danger>
<Trash2 size={16} strokeWidth={2} />
</SelectionAction>
)}
</div>
</div>
)}
</div>
@@ -651,3 +653,25 @@ export default function MCollections() {
</div>
)
}
/** One action on the picked places: an icon, named by its label, as wide as its share of the row. */
function SelectionAction({ label, onClick, disabled, danger = false, children }: {
label: string
onClick: () => void
disabled: boolean
danger?: boolean
children: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={`flex h-10 min-w-0 flex-1 items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-m-sheetop disabled:opacity-40 ${danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'}`}
>
{children}
</button>
)
}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
import { Outlet, useSearchParams } from 'react-router'
import { BookOpen, ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
import { BookOpen, ChevronLeft, MapPin, Grid3x3, Map as MapIcon, MoreHorizontal, Play, Image, Camera, EyeOff, Satellite, Settings2, X, Route, ListChecks } from 'lucide-react'
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
import type { BaseLayer } from '../../../components/Map/MapLayerSwitcher'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
@@ -205,6 +206,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
// Whether the probe below has finished — not whether it found anything.
const [providersReady, setProvidersReady] = useState(false)
const [showUploadMenu, setShowUploadMenu] = useState(false)
// The map's base, switched from the bar: the satellite imagery or the map (#2594).
const [mapBase, setMapBase] = useState<BaseLayer>('default')
const [showActionMenu, setShowActionMenu] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
// The gallery photo waiting on its delete confirm; entries have their own deleteTarget.
@@ -356,6 +359,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
layers={mapLayers}
hideLayerControl
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
// Its satellite switch sits in the bar above, beside the menu.
baseLayer={mapBase}
onOpenEntry={handleMarkerClick}
onWriteAboutPlace={writeAboutPlace}
entryTitleOf={entryTitleOf}
@@ -514,6 +519,17 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<BookOpen size={17} strokeWidth={2} />
</button>
)}
{view === 'timeline' && (
<button
type="button"
onClick={() => setMapBase((was) => (was === 'satellite' ? 'default' : 'satellite'))}
aria-pressed={mapBase === 'satellite'}
aria-label={t(mapBase === 'satellite' ? 'map.baseLayer.switchToDefault' : 'map.baseLayer.switchToSatellite')}
className={`flex h-[38px] w-[38px] items-center justify-center rounded-full shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)] ${mapBase === 'satellite' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-sheet)] text-m-ink'}`}
>
{mapBase === 'satellite' ? <MapIcon size={17} strokeWidth={2} /> : <Satellite size={17} strokeWidth={2} />}
</button>
)}
<button
type="button"
onClick={() => setShowActionMenu(true)}
@@ -1,5 +1,7 @@
import {
Check,
ChevronLeft,
ChevronRight,
Crown,
Link2,
LogOut,
@@ -11,10 +13,13 @@ import {
UserMinus,
UserPlus,
UserRound,
Users,
} from 'lucide-react';
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 {
SHARE_OPTIONS,
@@ -35,6 +40,7 @@ import {
SHEET_PRIMARY as PRIMARY,
SHEET_ROUND as ROUND,
SHEET_ROUND_PRIMARY as ROUND_PRIMARY,
INNER_CLS,
SheetSection as Section,
TileHeader,
} from './MTripSheetUi';
@@ -390,11 +396,28 @@ function GuestsSection({ m, ask }: { m: Members; ask: Ask }) {
);
}
/** The areas the sheet opens into, each a page of its own. */
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 }) {
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="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" />
</button>
);
}
/**
* The trip's settings on the phone (the Mehr sheet's "Trip settings"): who has
* access, the guests without an account, the public link, the invite link and
* the linked journal. The state is useTripShare's, the same the desktop share
* dialog runs on; only the markup and the questions are the phone's own.
* The trip's settings on the phone (the Mehr sheet's "Trip settings"). The
* first page lists the areas, like the phone's own settings, and each opens as
* a page of its own with a way back: who is on the trip and its guests, the two
* links, and the linked journal. One list rather than every card at once, so
* the sheet stays calm as areas are added. The state is useTripShare's, the same
* the desktop dialog runs on; only the markup and the questions are the phone's.
*/
export default function MTripSettingsSheet({
open,
@@ -405,7 +428,15 @@ export default function MTripSettingsSheet({
}: MTripSettingsSheetProps) {
const { t } = useTranslation();
const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged });
const journalVisible = useTripJournalSectionVisible(tripId);
const [question, setQuestion] = useState<Question | null>(null);
const [page, setPage] = useState<Page | null>(null);
// Opened again, the sheet starts on its list.
const [wasOpen, setWasOpen] = useState(open);
if (wasOpen !== open) {
setWasOpen(open);
if (open) setPage(null);
}
const words = questionWords(question, t);
const answer = () => {
@@ -416,13 +447,28 @@ export default function MTripSettingsSheet({
setQuestion(null);
};
const pageTitle: Record<Page, string> = {
people: t('trip.settings.people'),
sharing: t('trip.settings.sharing'),
journal: t('trip.journal.title'),
};
const people = m.realMembers.length + m.guests.length;
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('trip.settings.title')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<Settings2 size={19} strokeWidth={1.8} />}
title={t('trip.settings.title')}
icon={
page ? (
<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>
) : (
<Settings2 size={19} strokeWidth={1.8} />
)
}
title={page ? pageTitle[page] : t('trip.settings.title')}
sub={tripTitle}
onClose={onClose}
closeLabel={t('common.close')}
@@ -430,11 +476,35 @@ export default function MTripSettingsSheet({
</div>
<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]">
<AccessSection m={m} ask={setQuestion} />
<GuestsSection m={m} ask={setQuestion} />
{m.canManageShare && <ShareLinkSection tripId={tripId} />}
{m.canManageShare && <InviteLinkSection tripId={tripId} />}
<TripJournalLinkSection tripId={tripId} />
{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}`}>
<AreaRow
icon={<Users size={16} strokeWidth={2} />}
label={pageTitle.people}
value={m.loading ? undefined : String(people)}
onOpen={() => setPage('people')}
/>
{m.canManageShare && (
<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')} />
)}
</nav>
)}
{page === 'people' && (
<>
<AccessSection m={m} ask={setQuestion} />
<GuestsSection m={m} ask={setQuestion} />
</>
)}
{page === 'sharing' && m.canManageShare && (
<>
<ShareLinkSection tripId={tripId} />
<InviteLinkSection tripId={tripId} />
</>
)}
{page === 'journal' && <TripJournalLinkSection tripId={tripId} />}
</div>
</MSheet>
@@ -4,7 +4,7 @@ import type { LucideIcon } from 'lucide-react'
import {
Briefcase, Check, CheckCheck, ChevronDown, ChevronUp, HandHelping, Minus,
Download, FileSpreadsheet, FileText, LayoutTemplate, MoreHorizontal, Package, Pencil, Plus, Printer, RotateCcw, Save as SaveIcon,
Share2, Trash2, UserPlus, UserRound,
Share2, Trash2, UserPlus, UserRound, X,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow'
@@ -257,7 +257,9 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
Same dashed-pill style as before, just relocated. ── */}
{canEdit && (editMode || trueEmpty) && (
addingCategory ? (
<div className="mt-[10px] flex gap-2">
// One card like the lists below it: the name typed in place, the way
// out and the add on its end, so the row reads as the list it becomes.
<div className="mt-[10px] flex items-center gap-1.5 rounded-[16px] border border-[color:var(--m-rowbr)] bg-m-card py-[5px] pe-[5px] ps-[14px]">
<input
type="text"
autoFocus
@@ -268,16 +270,25 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') }
}}
placeholder={t('packing.newCategoryPlaceholder')}
className={`${FIELD_CLS} flex-1`}
aria-label={t('packing.newCategoryPlaceholder')}
className="min-w-0 flex-1 bg-transparent py-2 text-body text-m-ink outline-none placeholder:text-m-faint"
/>
<button
type="button"
onClick={() => { setAddingCategory(false); setNewCategoryName('') }}
aria-label={t('common.cancel')}
className="flex h-9 w-9 flex-none items-center justify-center rounded-full text-m-muted"
>
<X size={16} strokeWidth={2.2} />
</button>
<button
type="button"
onClick={addNewCategory}
disabled={!newCategoryName.trim()}
aria-label={t('common.add')}
className="flex h-10 w-10 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-40"
className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-m-act text-m-actfg disabled:opacity-30"
>
<Check size={15} strokeWidth={2.4} />
<Plus size={17} strokeWidth={2.4} />
</button>
</div>
) : (
@@ -165,7 +165,7 @@ function makeHook(over: Record<string, unknown> = {}): Record<string, unknown> {
incomingInvites: [],
orderedInvites: [],
routeInvite: null,
counts: { all: 1, idea: 1, want: 0, visited: 0 },
counts: { all: 1, idea: 1, want: 0, visited: 0, revisit: 0 },
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
@@ -306,7 +306,7 @@ describe('MCollections', () => {
// The union has no label chip, so the status chip is the only "all" chip.
mocks.coll = makeHook({
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false,
counts: { all: 5, idea: 2, want: 2, visited: 1 },
counts: { all: 5, idea: 2, want: 2, visited: 1, revisit: 0 },
})
render(<MCollections />)
@@ -409,7 +409,7 @@ describe('MCollections', () => {
fireEvent.click(screen.getByRole('button', { name: /collections.selectAll/ }))
expect(fn('handleSelectAll')).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.editDone' }))
expect(fn('setSelectMode')).toHaveBeenCalledWith(false)
})
@@ -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: 'common.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.editDone' }))
expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByRole('button', { name: /common.delete/ }))
@@ -708,6 +708,7 @@ describe('MCollections: category filter (#2618)', () => {
})
it('FE-MOB-COLSCR-037: without categories the chip stays away, unless a category is still set from another list', () => {
mocks.coll = makeHook()
const { unmount } = render(<MCollections />)
expect(screen.queryByRole('button', { name: /^categories.title/ })).not.toBeInTheDocument()
unmount()
@@ -728,7 +729,8 @@ describe('MCollections: category filter (#2618)', () => {
// The status chip is the first chip reading "all".
fireEvent.click(screen.getAllByRole('button', { name: 'collections.status.filterAll' })[0])
expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /collections.status.idea/ })).toBeInTheDocument()
// The status panel is open in its place (its rows name each status).
expect(screen.getAllByRole('button', { name: /collections.status.idea/ }).length).toBeGreaterThan(0)
})
})
@@ -7,7 +7,7 @@ import { usePermissionsStore } from '../../../../src/store/permissionsStore';
import { useTripStore } from '../../../../src/store/tripStore';
import { buildTrip, buildUser } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { render, screen, waitFor, within } from '../../../helpers/render';
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
// FE-MOB-TSET-001 to FE-MOB-TSET-008: the phone's trip settings, on useTripShare.
@@ -34,6 +34,11 @@ function roster(members: Row[]) {
);
}
/** The sheet opens on its list of areas; a case about one opens it first. */
async function openArea(name: 'People' | 'Sharing') {
fireEvent.click(await screen.findByRole('button', { name: new RegExp(`^${name}`) }));
}
function renderSheet(props: Partial<Parameters<typeof MTripSettingsSheet>[0]> = {}) {
const onClose = vi.fn();
const onMembersChanged = vi.fn();
@@ -69,6 +74,7 @@ afterEach(() => {
describe('MTripSettingsSheet', () => {
it('FE-MOB-TSET-001: names the trip in its header and the owner with their badges', async () => {
renderSheet();
await openArea('People');
const sheet = screen.getByRole('dialog', { name: 'Trip settings' });
expect(within(sheet).getByText('Japan 2026')).toBeInTheDocument();
expect(await within(sheet).findByText('owner')).toBeInTheDocument();
@@ -87,6 +93,7 @@ describe('MTripSettingsSheet', () => {
})
);
const { onMembersChanged } = renderSheet();
await openArea('People');
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
const question = await screen.findByRole('dialog', { name: 'Remove access' });
@@ -109,6 +116,7 @@ describe('MTripSettingsSheet', () => {
})
);
renderSheet();
await openArea('People');
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
const question = await screen.findByRole('dialog', { name: 'Make owner' });
@@ -137,6 +145,7 @@ describe('MTripSettingsSheet', () => {
})
);
renderSheet();
await openArea('People');
expect(await screen.findByText('Grandma')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Guest name'), 'Grandpa');
@@ -174,6 +183,7 @@ describe('MTripSettingsSheet', () => {
)
);
renderSheet();
await openArea('Sharing');
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
@@ -191,6 +201,7 @@ describe('MTripSettingsSheet', () => {
})
);
renderSheet();
await openArea('Sharing');
await user.click(await screen.findByRole('button', { name: 'Packing' }));
await user.click(screen.getByRole('button', { name: 'Create link' }));
@@ -210,6 +221,7 @@ describe('MTripSettingsSheet', () => {
})
);
renderSheet();
await openArea('Sharing');
await user.click(await screen.findByRole('button', { name: 'Create invite link' }));
expect(await screen.findByDisplayValue(/\/join\/join1$/)).toBeInTheDocument();
@@ -217,6 +229,15 @@ describe('MTripSettingsSheet', () => {
await waitFor(() => expect(removed).toBe(true));
});
it('FE-MOB-TSET-007b: opens on the list of areas and goes back to it from one', async () => {
renderSheet();
expect(screen.getByRole('navigation', { name: 'Trip settings' })).toBeInTheDocument();
await openArea('People');
expect(screen.queryByRole('navigation', { name: 'Trip settings' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByRole('navigation', { name: 'Trip settings' })).toBeInTheDocument();
});
it('FE-MOB-TSET-008: closes through its close button', async () => {
const user = userEvent.setup();
const { onClose } = renderSheet();
@@ -7,7 +7,7 @@ import { useTripStore } from '../../../../src/store/tripStore'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { buildTrip, buildUser } from '../../../helpers/factories'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { render, screen, waitFor } from '../../../helpers/render'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-SHOST-030 to FE-MOB-SHOST-031 (001 to 029 and 032 live in MTripSheets.test.tsx)
//
@@ -45,9 +45,11 @@ import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
const owner = buildUser({ id: 1, username: 'owner' })
const alice = buildUser({ id: 2, username: 'alice' })
function openShareSheet() {
async function openShareSheet() {
const planner = buildPlanner()
render(<MTripSheets planner={planner} shell={buildShell({ sheet: { id: 'members' } })} />)
// The sheet opens on its list of areas; the people are one tap away.
fireEvent.click(await screen.findByRole('button', { name: /^People/ }))
return { planner }
}
@@ -68,7 +70,7 @@ describe('MTripSheets share sheet', () => {
it('FE-MOB-SHOST-030: the user picked on the phone shows in the field before the invite is sent', async () => {
const user = userEvent.setup()
openShareSheet()
await openShareSheet()
const trigger = (await screen.findByText('Select user…')).closest('button') as HTMLButtonElement
await user.click(trigger)
@@ -88,7 +90,7 @@ describe('MTripSheets share sheet', () => {
return HttpResponse.json({ success: true })
}),
)
const { planner } = openShareSheet()
const { planner } = await openShareSheet()
const trigger = (await screen.findByText('Select user…')).closest('button') as HTMLButtonElement
await user.click(trigger)