From 5f4fb5dae70427dca90665b1a9df09c0d7c7dd85 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 09:58:33 +0100 Subject: [PATCH] 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. --- client/scripts/feature-pairs.json | 6 + client/src/mobile/screens/trip/MTripShell.tsx | 9 +- .../screens/trip/tabs/MTripTabPanel.tsx | 12 ++ .../screens/trip/tours/MTourWorkspace.tsx | 147 ++++++++++++++++++ .../src/pages/tripPlanner/b4a-wiring.test.ts | 15 +- .../tripPlanner/tripPlannerModel.test.ts | 18 ++- .../src/pages/tripPlanner/tripPlannerModel.ts | 11 ++ .../src/pages/tripPlanner/useTripPlanner.ts | 10 +- .../pages/tripPlanner/useTripPlannerPage.ts | 10 +- .../unit/mobile/trip/MTourWorkspace.test.tsx | 138 ++++++++++++++++ .../unit/mobile/trip/MTripShell.wide.test.tsx | 19 +++ 11 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 client/src/mobile/screens/trip/tours/MTourWorkspace.tsx create mode 100644 client/tests/unit/mobile/trip/MTourWorkspace.test.tsx diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 52ee10ab7..2030e4c34 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -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"], diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 15bc756c2..d8b7dfe1d 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -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 diff --git a/client/src/mobile/screens/trip/tabs/MTripTabPanel.tsx b/client/src/mobile/screens/trip/tabs/MTripTabPanel.tsx index e557b00c1..c82ea02f7 100644 --- a/client/src/mobile/screens/trip/tabs/MTripTabPanel.tsx +++ b/client/src/mobile/screens/trip/tabs/MTripTabPanel.tsx @@ -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 + // Only ever reached on a large screen: the shell sends a phone back to the plan. + case 'tour-planner': + return ( + + + + ) // 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': diff --git a/client/src/mobile/screens/trip/tours/MTourWorkspace.tsx b/client/src/mobile/screens/trip/tours/MTourWorkspace.tsx new file mode 100644 index 000000000..54b12b0ec --- /dev/null +++ b/client/src/mobile/screens/trip/tours/MTourWorkspace.tsx @@ -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) { + const page = useTripPlannerPageOf(planner); + const { tourPlanner, wandererBrowser, tourGpxImport, tourMap, locationSearch, tourDetailActions } = page; + const { canPlaceEdit, canDayEdit } = page.permissions; + const split = useIsSplit(); + const [pane, setPane] = useState('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 ( + +
+ {showPane && ( +
+
+ + 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 && ( + setMapShown(true)} size={36}> + + + )} +
+
+ {pane === 'tours' ? ( + + + + ) : ( + + + + )} +
+
+ )} + {showMap && ( +
+ + {!split && ( +
+ setMapShown(false)} + className="backdrop-blur-[24px] backdrop-saturate-[1.7]" + > + + +
+ )} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/tripPlanner/b4a-wiring.test.ts b/client/src/pages/tripPlanner/b4a-wiring.test.ts index 298f1163d..67bad6a30 100644 --- a/client/src/pages/tripPlanner/b4a-wiring.test.ts +++ b/client/src/pages/tripPlanner/b4a-wiring.test.ts @@ -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) diff --git a/client/src/pages/tripPlanner/tripPlannerModel.test.ts b/client/src/pages/tripPlanner/tripPlannerModel.test.ts index 7bebe088b..dc79678a5 100644 --- a/client/src/pages/tripPlanner/tripPlannerModel.test.ts +++ b/client/src/pages/tripPlanner/tripPlannerModel.test.ts @@ -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() + }) +}) diff --git a/client/src/pages/tripPlanner/tripPlannerModel.ts b/client/src/pages/tripPlanner/tripPlannerModel.ts index 6f49ab4ea..cb3fd13a4 100644 --- a/client/src/pages/tripPlanner/tripPlannerModel.ts +++ b/client/src/pages/tripPlanner/tripPlannerModel.ts @@ -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 } +} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 44aa6554d..88d5f54a2 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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 diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 77b53cdab..47e21a1d1 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -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) { const { activeTab, can, places, diff --git a/client/tests/unit/mobile/trip/MTourWorkspace.test.tsx b/client/tests/unit/mobile/trip/MTourWorkspace.test.tsx new file mode 100644 index 000000000..4cacb56a8 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTourWorkspace.test.tsx @@ -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 }) => ( +
+ ), +})); +vi.mock('../../../../src/components/Tours/TourWorkspaceRail', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/components/Tours/planner/TourPlannerPanels', () => ({ + TourPlannerToursRail: () =>
, +})); +vi.mock('../../../../src/components/Tours/TourGpxDropZone', () => ({ + default: ({ children }: { children: ReactNode }) =>
{children}
, +})); +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(); + 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(); + 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(); + 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(); + 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(); + expect(await screen.findByTestId('tour-editor')).toBeInTheDocument(); + page.mode = { type: 'neutral' }; + rerender(); + 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(); + 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(); + expect(screen.getByTestId('gpx-drop')).toContainElement(screen.getByTestId('tour-map')); + }); +}); diff --git a/client/tests/unit/mobile/trip/MTripShell.wide.test.tsx b/client/tests/unit/mobile/trip/MTripShell.wide.test.tsx index 2a5eb85cf..bcf71f795 100644 --- a/client/tests/unit/mobile/trip/MTripShell.wide.test.tsx +++ b/client/tests/unit/mobile/trip/MTripShell.wide.test.tsx @@ -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); + 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); + expect(mocks.planner.handleTabChange).toHaveBeenCalledWith('plan'); + }); });