mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(mobile): the tour editor as a tab of its own on an iPad and an unfolded foldable
On the wide and split size classes the touch shell no longer sends the tours tab back to the plan. It gets the desktop's tour editor, wired over the shell's own planner (useTripPlannerPageOf): on split the trip's tours and the editor share a pane beside the map, on wide the pane and the map take turns. A phone keeps its tours in the places browser.
This commit is contained in:
@@ -834,6 +834,12 @@
|
||||
"desktop": ["src/components/shared/Toast.tsx"],
|
||||
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tour editor workspace",
|
||||
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
|
||||
"desktop": ["src/pages/TripPlannerPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list filter",
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
|
||||
@@ -328,9 +328,11 @@ export default function MTripShell({
|
||||
|
||||
const trTab = planner.activeTab
|
||||
|
||||
// The tour editor is a tab on a large screen only; a phone, or a window narrowed
|
||||
// to one, keeps its tours in the places browser.
|
||||
useEffect(() => {
|
||||
if (trTab === 'tour-planner') planner.handleTabChange('plan')
|
||||
}, [trTab, planner.handleTabChange])
|
||||
if (trTab === 'tour-planner' && !wide) planner.handleTabChange('plan')
|
||||
}, [trTab, wide, planner.handleTabChange])
|
||||
|
||||
const setTrTab = (tabId: string) => {
|
||||
planner.handleTabChange(tabId)
|
||||
@@ -862,7 +864,8 @@ export default function MTripShell({
|
||||
not in the dock, so nothing was lit up there either, and the screen
|
||||
gave no clue which plugin was open. Same treatment as the others, from
|
||||
the tab entry the planner already builds (id, label, icon). */}
|
||||
{trTab.startsWith('plugin:') && (() => {
|
||||
{/* The tours tab on a large screen reads the same way: its own controls sit in its pane. */}
|
||||
{(trTab.startsWith('plugin:') || trTab === 'tour-planner') && (() => {
|
||||
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
|
||||
if (!tab) return null
|
||||
const Icon = tab.icon
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { MTripTabPanelProps } from '../MTripShell'
|
||||
import { lazyWithRetry } from '../../../../utils/lazyWithRetry'
|
||||
import { LazyPanel } from '../../../../pages/tripPlanner/LazyPanel'
|
||||
import { TabScroller } from './tabChrome'
|
||||
import MTransportsTab from './MTransportsTab'
|
||||
import MBookingsTab from './MBookingsTab'
|
||||
@@ -10,6 +12,9 @@ import MRoadtripTab from '../roadtrip/MRoadtripTab'
|
||||
import MJourneyDetail from '../../journey/MJourneyDetail'
|
||||
import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
||||
|
||||
// The desktop's tour editor, map stage included: loaded the first time a large screen opens the tab.
|
||||
const MTourWorkspace = lazyWithRetry(() => import('../tours/MTourWorkspace'))
|
||||
|
||||
/**
|
||||
* Routes the active non-plan trip tab to its panel. `tab` is the legacy id the
|
||||
* desktop planner uses (transports · buchungen · finanzplan · listen · dateien ·
|
||||
@@ -62,6 +67,13 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
|
||||
// switch between them must not lose it.
|
||||
case 'roadtrip':
|
||||
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
|
||||
// Only ever reached on a large screen: the shell sends a phone back to the plan.
|
||||
case 'tour-planner':
|
||||
return (
|
||||
<LazyPanel id="m-tour-workspace">
|
||||
<MTourWorkspace planner={planner} />
|
||||
</LazyPanel>
|
||||
)
|
||||
// The journal the trip links: the Journey addon's own phone screen, moved
|
||||
// below the trip's floating top controls: its bars measure from --m-safe-top.
|
||||
case 'journal':
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { List, Map as MapIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import TourGpxDropZone from '../../../../components/Tours/TourGpxDropZone';
|
||||
import TourWorkspaceRail from '../../../../components/Tours/TourWorkspaceRail';
|
||||
import { TourPlannerToursRail } from '../../../../components/Tours/planner/TourPlannerPanels';
|
||||
import { useIsSplit } from '../../../../hooks/sizeClass';
|
||||
import { useTouchDragBridge } from '../../../../hooks/useTouchDragBridge';
|
||||
import { LazyPanel } from '../../../../pages/tripPlanner/LazyPanel';
|
||||
import { TourPlannerMapStage } from '../../../../pages/tripPlanner/TourPlannerMapStage';
|
||||
import { useTripPlannerPageOf } from '../../../../pages/tripPlanner/useTripPlannerPage';
|
||||
import MIconBtn from '../../../components/MIconBtn';
|
||||
import MSegmented from '../../../components/MSegmented';
|
||||
import type { MTripTabPanelProps } from '../MTripShell';
|
||||
|
||||
/** The pane's two halves: the trip's tours, or the editor with the tour open in it. */
|
||||
type MTourPane = 'tours' | 'editor';
|
||||
|
||||
/** The editor's title for what is open in it, as the desktop rail heads it. */
|
||||
const EDITOR_TITLE = {
|
||||
neutral: 'tours.planner.title',
|
||||
'new-draft': 'tours.planner.newTitle',
|
||||
'edit-saved': 'tours.planner.editTitle',
|
||||
'view-gpx': 'tours.planner.gpxTitle',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The tours tab on a large screen (wide and split; a phone has no such tab and
|
||||
* keeps its tours in the places browser). It is the desktop's tour editor, wired
|
||||
* over the shell's own planner through useTripPlannerPageOf, so the two cannot
|
||||
* drift apart: the same rails, the same map stage, the same GPX import.
|
||||
*
|
||||
* Split: the pane beside the map, with the trip's tours and the editor as its two
|
||||
* halves. Wide: one column, the pane or the map, switched at the pane's head and
|
||||
* from the map's corner. Opening a tour (from the list, the map or an import) turns
|
||||
* the pane to the editor, where it is then shown.
|
||||
*
|
||||
* The whole tab sits below the shell's floating top controls rather than under
|
||||
* them: that row spans the window and takes every press in its band, so a map
|
||||
* running beneath it would lose its search field to it.
|
||||
*/
|
||||
export default function MTourWorkspace({ planner }: Pick<MTripTabPanelProps, 'planner'>) {
|
||||
const page = useTripPlannerPageOf(planner);
|
||||
const { tourPlanner, wandererBrowser, tourGpxImport, tourMap, locationSearch, tourDetailActions } = page;
|
||||
const { canPlaceEdit, canDayEdit } = page.permissions;
|
||||
const split = useIsSplit();
|
||||
const [pane, setPane] = useState<MTourPane>('tours');
|
||||
const [mapShown, setMapShown] = useState(false);
|
||||
// The waypoint list and the stage rail reorder by HTML5 drag, which a finger cannot start.
|
||||
useTouchDragBridge(true);
|
||||
|
||||
const mode = tourPlanner.mode.type;
|
||||
const editing = mode !== 'neutral' || wandererBrowser.open;
|
||||
// Something opened in the editor is shown there, on whichever half was up; once
|
||||
// it is closed (discarded, or back to the tours) the list comes back. Only on a
|
||||
// change: a tap on the editor half with nothing open stays where it went.
|
||||
useEffect(() => {
|
||||
setPane(editing ? 'editor' : 'tours');
|
||||
}, [editing, mode]);
|
||||
|
||||
const showPane = split || !mapShown;
|
||||
const showMap = split || mapShown;
|
||||
const { t } = planner;
|
||||
|
||||
return (
|
||||
<TourGpxDropZone gpxImport={tourGpxImport} canEdit={canPlaceEdit} className="absolute inset-0">
|
||||
<div className="absolute inset-x-0 bottom-0 top-[calc(var(--m-safe-top,12px)+50px)] flex">
|
||||
{showPane && (
|
||||
<section
|
||||
aria-label={t('tours.planner.tripTours')}
|
||||
className="flex h-full min-w-0 flex-1 flex-col split:w-[var(--trip-pane-open)] split:flex-none split:border-e split:border-[color:var(--m-rowbr)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 pb-2 pt-1">
|
||||
<MSegmented<MTourPane>
|
||||
className="min-w-0 flex-1"
|
||||
value={pane}
|
||||
onChange={setPane}
|
||||
options={[
|
||||
{ value: 'tours', label: t('tours.planner.tripTours') },
|
||||
{ value: 'editor', label: t(EDITOR_TITLE[mode]) },
|
||||
]}
|
||||
/>
|
||||
{!split && (
|
||||
<MIconBtn ariaLabel={t('mobileTrip.mapView')} onClick={() => setMapShown(true)} size={36}>
|
||||
<MapIcon size={17} strokeWidth={2} />
|
||||
</MIconBtn>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden pb-[env(safe-area-inset-bottom,0px)]">
|
||||
{pane === 'tours' ? (
|
||||
<LazyPanel id="m-tour-tours">
|
||||
<TourPlannerToursRail
|
||||
planner={tourPlanner}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
tours={planner.tours}
|
||||
days={planner.days}
|
||||
loading={planner.toursLoading}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onDeleteTour={planner.handleDeleteTour}
|
||||
gpxImport={tourGpxImport}
|
||||
onViewGpxTour={page.viewImportedTour}
|
||||
onTakenOver={tourDetailActions.onTakenOver}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<LazyPanel id="m-tour-editor">
|
||||
<TourWorkspaceRail workspace={page} canEdit={canPlaceEdit} canAssign={canDayEdit} />
|
||||
</LazyPanel>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{showMap && (
|
||||
<div className="relative h-full min-w-0 flex-1 overflow-hidden">
|
||||
<TourPlannerMapStage
|
||||
tripId={planner.tripId}
|
||||
mapTileUrl={planner.mapTileUrl}
|
||||
leftHidden={false}
|
||||
rightHidden={false}
|
||||
leftPanelPx={0}
|
||||
rightPanelPx={0}
|
||||
t={t}
|
||||
tourPlanner={tourPlanner}
|
||||
tourMap={tourMap}
|
||||
locationSearch={locationSearch}
|
||||
canPlaceEdit={canPlaceEdit}
|
||||
assistantRoutes={planner.assistantMap}
|
||||
onViewportChange={wandererBrowser.onViewportChange}
|
||||
paddleChecklist={page.paddleChecklist}
|
||||
/>
|
||||
{!split && (
|
||||
<div className="absolute start-3 top-3 z-20">
|
||||
<MIconBtn
|
||||
ariaLabel={t('mobileTrip.listView')}
|
||||
onClick={() => setMapShown(false)}
|
||||
className="backdrop-blur-[24px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
<List size={18} strokeWidth={2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
}
|
||||
@@ -135,16 +135,21 @@ test('B4A desktop page delegates Tours state and permissions to its page hook',
|
||||
const mobileFiles = mobileSourceFiles(resolve(process.cwd(), 'src/mobile'))
|
||||
for (const file of mobileFiles) {
|
||||
const mobileSource = parse(file, readFileSync(file, 'utf8'))
|
||||
// The touch shell holds its own planner: it may build the page's state over that
|
||||
// one (useTripPlannerPageOf), never a second planner through useTripPlannerPage.
|
||||
const importsDesktopHook = collect(mobileSource, ts.isImportDeclaration).some(node => {
|
||||
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return false
|
||||
const modulePath = node.moduleSpecifier.text
|
||||
const namedImport = node.importClause?.namedBindings
|
||||
return /(?:^|\/)useTripPlannerPage(?:\.tsx?)?$/.test(modulePath)
|
||||
|| (namedImport && ts.isNamedImports(namedImport)
|
||||
&& namedImport.elements.some(element => element.name.text === 'useTripPlannerPage'))
|
||||
const fromPageModule = /(?:^|\/)useTripPlannerPage(?:\.tsx?)?$/.test(node.moduleSpecifier.text)
|
||||
const clause = node.importClause
|
||||
const namedImport = clause?.namedBindings
|
||||
if (fromPageModule && (clause?.name || (namedImport && ts.isNamespaceImport(namedImport)))) return true
|
||||
return !!(namedImport && ts.isNamedImports(namedImport)
|
||||
&& namedImport.elements.some(element => (element.propertyName ?? element.name).text === 'useTripPlannerPage'))
|
||||
})
|
||||
assert.equal(importsDesktopHook, false, file)
|
||||
}
|
||||
// And the desktop's own hook is that same state over a planner of its own.
|
||||
assert.match(pageControllerText, /return useTripPlannerPageOf\(useTripPlanner\(\)\)/)
|
||||
|
||||
const permissions = initializer(pageController, 'canPlaceEdit')
|
||||
const permissionCalls = collect(pageController, node => ts.isCallExpression(node)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { placesForDays, resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
import { placesForDays, resolvePoolAssignmentId, tourPlannerTabOf } from './tripPlannerModel'
|
||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
|
||||
|
||||
describe('resolvePoolAssignmentId', () => {
|
||||
@@ -55,3 +55,19 @@ describe('placesForDays', () => {
|
||||
expect(kept.map(p => p.id)).toEqual([1, 2, 4, 5])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tourPlannerTabOf', () => {
|
||||
it('offers the editor on the desktop, as a desktop-only tab', () => {
|
||||
expect(tourPlannerTabOf(true, false, false)).toEqual({ desktopOnly: true })
|
||||
})
|
||||
|
||||
it('offers it in the touch shell of a large screen, where the rail shows it', () => {
|
||||
expect(tourPlannerTabOf(true, true, true)).toEqual({ desktopOnly: false })
|
||||
})
|
||||
|
||||
it('offers no tab on a phone, nor anywhere while the addon is off', () => {
|
||||
expect(tourPlannerTabOf(true, true, false)).toBeNull()
|
||||
expect(tourPlannerTabOf(false, false, false)).toBeNull()
|
||||
expect(tourPlannerTabOf(false, true, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,3 +56,14 @@ export function resolvePoolAssignmentId(
|
||||
.filter((a) => a.place?.id === placeId)
|
||||
return matches.length === 1 ? matches[0].id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the tour editor is a tab of the trip: always on the desktop, and in the
|
||||
* touch shell only on a large screen (an iPad, an unfolded foldable), where it is
|
||||
* offered in the rail. A phone has no such tab and keeps its tours in the places
|
||||
* browser. Null while the addon is off or on a phone.
|
||||
*/
|
||||
export function tourPlannerTabOf(toursOn: boolean, touchShell: boolean, largeScreen: boolean): { desktopOnly: boolean } | null {
|
||||
if (!toursOn || (touchShell && !largeScreen)) return null
|
||||
return { desktopOnly: !largeScreen }
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Assignment, TripMember, Day, Reservation } from '../../types'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { placesForDays } from './tripPlannerModel'
|
||||
import { placesForDays, tourPlannerTabOf } from './tripPlannerModel'
|
||||
import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import { showReservationOnMap } from '../../components/Planner/bookings/showOnMap'
|
||||
@@ -66,6 +66,7 @@ import { useTourDayChoice } from './useTourDayChoice'
|
||||
import { useBookingEdits } from './useBookingEdits'
|
||||
import { usePlannerTripView } from './usePlannerTripView'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useIsWide } from '../../hooks/sizeClass'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
|
||||
/**
|
||||
@@ -150,6 +151,8 @@ export function useTripPlanner() {
|
||||
// road-trip mode is decided on it and the assignment and place lists below are
|
||||
// decided on that. One subscriber for the whole hook.
|
||||
const isMobile = useIsPhone()
|
||||
// The touch shell on a tablet or an unfolded foldable, which has room for the tour editor.
|
||||
const largeTouch = useIsWide()
|
||||
// The phone shell has no road-trip surface at all: no rail, no drive lines, and
|
||||
// no switch to turn the mode back off. Narrowing a desktop window past the
|
||||
// phone breakpoint used to carry the flag across anyway, which took the trip
|
||||
@@ -220,9 +223,10 @@ export function useTripPlanner() {
|
||||
// them; 'plan' is never replaceable) and may pick where its own tab sits.
|
||||
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
|
||||
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
|
||||
const tourTab = tourPlannerTabOf(!!enabledAddons.tours, isMobile, largeTouch)
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
|
||||
...(enabledAddons.tours && !isMobile ? [{ id: 'tour-planner', label: t(TRIP_TAB_LABEL_KEYS['tour-planner']), icon: Mountain, desktopOnly: true }] : []),
|
||||
...(tourTab ? [{ id: 'tour-planner', label: t(TRIP_TAB_LABEL_KEYS['tour-planner']), icon: Mountain, ...tourTab }] : []),
|
||||
{ id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train },
|
||||
{ id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
|
||||
// Phone only: the desktop reaches the drive through the mode switch beside the
|
||||
@@ -267,7 +271,7 @@ export function useTripPlanner() {
|
||||
setActiveTab('plan')
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||
}
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, journal.settled, journal.openJournalId])
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, largeTouch, journal.settled, journal.openJournalId])
|
||||
|
||||
const handleTabChange = (rawTabId: string): void => {
|
||||
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
|
||||
|
||||
@@ -22,7 +22,15 @@ import { mapFocusKeyOf, offeredWaysOf, paddleLineOf } from './tourMapFocus'
|
||||
import { placeFormContext } from './placeFormContext'
|
||||
|
||||
export function useTripPlannerPage() {
|
||||
const planner = useTripPlanner()
|
||||
return useTripPlannerPageOf(useTripPlanner())
|
||||
}
|
||||
|
||||
/**
|
||||
* The planner page's state over a planner someone already holds. The touch shell
|
||||
* owns its own useTripPlanner(), and its tours workspace on a large screen wires the
|
||||
* desktop's tour editor from this, so the two cannot drift apart.
|
||||
*/
|
||||
export function useTripPlannerPageOf(planner: ReturnType<typeof useTripPlanner>) {
|
||||
const {
|
||||
activeTab,
|
||||
can, places,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MTourWorkspace from '../../../../src/mobile/screens/trip/tours/MTourWorkspace';
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip';
|
||||
import { render, screen } from '../../../helpers/render';
|
||||
import { setSizeClass } from '../../../helpers/sizeClass';
|
||||
|
||||
// FE-MOB-TOURWS-001 onwards: the tours tab on an iPad or an unfolded foldable, the
|
||||
// desktop's tour editor over the shell's planner.
|
||||
|
||||
const page = vi.hoisted(() => ({
|
||||
mode: { type: 'neutral' } as { type: string },
|
||||
wandererOpen: false,
|
||||
calls: [] as unknown[],
|
||||
}));
|
||||
const dragBridge = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../../src/pages/tripPlanner/useTripPlannerPage', () => ({
|
||||
useTripPlannerPageOf: (planner: unknown) => {
|
||||
page.calls.push(planner);
|
||||
return {
|
||||
tourPlanner: { mode: page.mode },
|
||||
wandererBrowser: { open: page.wandererOpen, onViewportChange: vi.fn() },
|
||||
tourGpxImport: {},
|
||||
tourMap: {},
|
||||
locationSearch: {},
|
||||
tourDetailActions: { onTakenOver: vi.fn() },
|
||||
viewImportedTour: vi.fn(),
|
||||
paddleChecklist: undefined,
|
||||
permissions: { canPlaceEdit: true, canDayEdit: true },
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/pages/tripPlanner/TourPlannerMapStage', () => ({
|
||||
TourPlannerMapStage: (props: { leftPanelPx: number; rightPanelPx: number; leftHidden: boolean }) => (
|
||||
<div
|
||||
data-testid="tour-map"
|
||||
data-left={props.leftPanelPx}
|
||||
data-right={props.rightPanelPx}
|
||||
data-hidden={String(props.leftHidden)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/components/Tours/TourWorkspaceRail', () => ({
|
||||
default: () => <div data-testid="tour-editor" />,
|
||||
}));
|
||||
vi.mock('../../../../src/components/Tours/planner/TourPlannerPanels', () => ({
|
||||
TourPlannerToursRail: () => <div data-testid="tour-list" />,
|
||||
}));
|
||||
vi.mock('../../../../src/components/Tours/TourGpxDropZone', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <div data-testid="gpx-drop">{children}</div>,
|
||||
}));
|
||||
vi.mock('../../../../src/hooks/useTouchDragBridge', () => ({ useTouchDragBridge: dragBridge }));
|
||||
|
||||
beforeEach(() => {
|
||||
page.mode = { type: 'neutral' };
|
||||
page.wandererOpen = false;
|
||||
page.calls = [];
|
||||
dragBridge.mockClear();
|
||||
});
|
||||
|
||||
describe('MTourWorkspace', () => {
|
||||
it('FE-MOB-TOURWS-001: split puts the trip tours beside the map, with nothing to switch between them', async () => {
|
||||
setSizeClass('split');
|
||||
const planner = buildPlanner();
|
||||
render(<MTourWorkspace planner={planner} />);
|
||||
expect(await screen.findByTestId('tour-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-map')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.mapView' })).not.toBeInTheDocument();
|
||||
// The page state is built over the shell's own planner, never a second one.
|
||||
expect(page.calls.length).toBeGreaterThan(0);
|
||||
expect(page.calls.every((seen) => seen === planner)).toBe(true);
|
||||
// The map has the stage to itself: no desktop panel covers any of it.
|
||||
const map = screen.getByTestId('tour-map');
|
||||
expect(map.dataset.left).toBe('0');
|
||||
expect(map.dataset.right).toBe('0');
|
||||
expect(map.dataset.hidden).toBe('false');
|
||||
// A finger can start the waypoint list's drag.
|
||||
expect(dragBridge).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-TOURWS-002: wide shows the pane or the map, and switches between them', async () => {
|
||||
setSizeClass('wide');
|
||||
render(<MTourWorkspace planner={buildPlanner()} />);
|
||||
expect(await screen.findByTestId('tour-list')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-map')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }));
|
||||
expect(screen.getByTestId('tour-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-list')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }));
|
||||
expect(await screen.findByTestId('tour-list')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TOURWS-003: the pane has the trip tours and the editor as its two halves', async () => {
|
||||
setSizeClass('split');
|
||||
render(<MTourWorkspace planner={buildPlanner()} />);
|
||||
expect(screen.getByRole('tab', { name: 'tours.planner.tripTours' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'tours.planner.title' }));
|
||||
expect(await screen.findByTestId('tour-editor')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TOURWS-004: a tour opened in the editor turns the pane to it, headed with what is open', async () => {
|
||||
setSizeClass('split');
|
||||
page.mode = { type: 'edit-saved' };
|
||||
render(<MTourWorkspace planner={buildPlanner()} />);
|
||||
expect(await screen.findByTestId('tour-editor')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'tours.planner.editTitle' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-TOURWS-007: closing the tour in the editor brings the trip tours back', async () => {
|
||||
setSizeClass('split');
|
||||
page.mode = { type: 'edit-saved' };
|
||||
const planner = buildPlanner();
|
||||
const { rerender } = render(<MTourWorkspace planner={planner} />);
|
||||
expect(await screen.findByTestId('tour-editor')).toBeInTheDocument();
|
||||
page.mode = { type: 'neutral' };
|
||||
rerender(<MTourWorkspace planner={planner} />);
|
||||
expect(await screen.findByTestId('tour-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TOURWS-005: browsing the wanderer trails is shown in the editor half too', async () => {
|
||||
setSizeClass('wide');
|
||||
page.wandererOpen = true;
|
||||
render(<MTourWorkspace planner={buildPlanner()} />);
|
||||
expect(await screen.findByTestId('tour-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TOURWS-006: the GPX drop zone wraps the tab, so its preview dialog is there for an import', () => {
|
||||
setSizeClass('split');
|
||||
render(<MTourWorkspace planner={buildPlanner()} />);
|
||||
expect(screen.getByTestId('gpx-drop')).toContainElement(screen.getByTestId('tour-map'));
|
||||
});
|
||||
});
|
||||
@@ -232,4 +232,23 @@ describe('MTripShell on a wide window', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.travel' }));
|
||||
expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(expect.any(Number), true);
|
||||
});
|
||||
|
||||
it('FE-MOB-SHELLW-011: a large screen keeps the tours tab and hands it to its panel', () => {
|
||||
setSizeClass('split');
|
||||
renderShell({
|
||||
activeTab: 'tour-planner',
|
||||
TRIP_TABS: [...TRIP_TABS, { id: 'tour-planner', label: 'Tours' }],
|
||||
} as Partial<TripPlanner>);
|
||||
expect(mocks.planner.handleTabChange).not.toHaveBeenCalledWith('plan');
|
||||
expect(screen.getByTestId('tab-panel')).toHaveTextContent('tour-planner');
|
||||
expect(
|
||||
within(screen.getByRole('navigation', { name: 'Trip sections' })).getByRole('button', { name: 'Tours' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SHELLW-012: folded to a phone the tours tab goes back to the plan, where the places browser keeps the tours', () => {
|
||||
setSizeClass('compact');
|
||||
renderShell({ activeTab: 'tour-planner' } as Partial<TripPlanner>);
|
||||
expect(mocks.planner.handleTabChange).toHaveBeenCalledWith('plan');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user