mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
refactor(client): the plan map and its floating controls move out of the planner page
The plan tab's MapView with the route alternatives bar, the overview and trail stack, the POI and compass cluster, the phone compass and the phone POI portal move to pages/tripPlanner/PlanMapStage.tsx. It returns the same children as a fragment in the same place, so the DOM and the portal order stay as they were. Its props are picked from the planner hook's own types. The four physical sides that moved are map geometry and carry an rtl-lint-disable marker on their line, so the page's rtl baseline drops from 17 to 13. Two moved comments lose a dash.
This commit is contained in:
@@ -105,7 +105,7 @@
|
||||
"pages/JourneyPublicPage.tsx": 3,
|
||||
"pages/LoginPage.tsx": 23,
|
||||
"pages/sharedTrip/SharedMap.tsx": 4,
|
||||
"pages/TripPlannerPage.tsx": 17,
|
||||
"pages/TripPlannerPage.tsx": 13,
|
||||
"pages/VacayPage.tsx": 1,
|
||||
"styles/collections.css": 1,
|
||||
"styles/studio.css": 7,
|
||||
|
||||
@@ -4,9 +4,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview'
|
||||
import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
|
||||
import type { CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
|
||||
@@ -53,13 +51,13 @@ import type { Accommodation, TripMember, Day, Place, Reservation } from '../type
|
||||
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage'
|
||||
import {
|
||||
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
|
||||
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal, RoadtripAlternativesBar,
|
||||
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
|
||||
TourPlannerRail, TourPlannerToursRail, TransportModal,
|
||||
} from './tripPlanner/plannerLazy'
|
||||
import { LazyPanel } from './tripPlanner/LazyPanel'
|
||||
import { ListsContainer } from './tripPlanner/ListsContainer'
|
||||
import { PlanMapStage } from './tripPlanner/PlanMapStage'
|
||||
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
|
||||
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
|
||||
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
|
||||
import PanelResizeHandle from '../components/Planner/PanelResizeHandle'
|
||||
|
||||
@@ -266,164 +264,24 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
{activeTab === 'plan' && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
dawarichTrack={dawarichTrail.track}
|
||||
dawarichHiddenDates={dawarichHiddenDates}
|
||||
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
||||
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
|
||||
routeWalking={roadtripActive || overviewActive ? undefined : routeWalking}
|
||||
followSelection={!mapLocked || isMobile}
|
||||
onToggleFollow={isMobile ? undefined : toggleMapLocked}
|
||||
routeVias={roadtripActive ? roadtripMapVias : routeVias}
|
||||
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
|
||||
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
|
||||
showTransitRoutes={transitRoutesShown}
|
||||
// The route toggle belongs to one day, so the map needs that day to
|
||||
// know which automated transports may ride it (#2019).
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
routeSegments={roadtripActive ? roadtripRoutes.segments : overviewActive ? tripOverview.segments : routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
selectedPlace={selectedPlace}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
onMapContextMenu={handleMapContextMenu}
|
||||
// No center/zoom: the map frames itself on the trip's places at mount, and
|
||||
// falls back to the world view when the trip has none.
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={fitKey}
|
||||
dayOrderMap={dayOrderMap}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
hasInspector={!!selectedPlace}
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
showReservationStats={true}
|
||||
// In road trip mode the rides that seam the drive are drawn as their own arcs
|
||||
// beside the roads, on top of what the reader switched on under Days.
|
||||
visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
|
||||
// The desktop plan shows the booking's detail; the narrow layout keeps the day
|
||||
// list's transport view.
|
||||
onReservationClick={(rid) => {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (!r) return
|
||||
if (isMobile) setMapTransportDetail(r)
|
||||
else openBookingDetail(r)
|
||||
}}
|
||||
/* In road trip mode the corridor's `visible` (not `search.results`: the map is
|
||||
the picture of that very list, and filtering the list while seventy pins stay
|
||||
on the map no longer answers "which of these") plus whatever the category
|
||||
pill found in view. Outside it, only the pill's hits. */
|
||||
pois={mapPois}
|
||||
onPoiClick={handlePoiClick}
|
||||
// Only while road trip mode is on: outside it there is no drive to drop onto.
|
||||
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
|
||||
// Clicking the drawn route puts a via there; only in road trip mode, where
|
||||
// the route is the thing being worked on.
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
onHighlightAlternative={setHighlightedAlternative}
|
||||
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
|
||||
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
|
||||
onViewportChange={tourMap.captureViewport}
|
||||
onMapReady={setGlMap}
|
||||
<PlanMapStage
|
||||
tripId={tripId} trip={trip} can={can} days={days} reservations={reservations} isMobile={isMobile}
|
||||
selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedPlace={selectedPlace} showDayDetail={showDayDetail} handleSelectDay={handleSelectDay}
|
||||
mapPlaces={mapPlaces} dayPlaces={dayPlaces} dayOrderMap={dayOrderMap} mapTileUrl={mapTileUrl} fitKey={fitKey} mapLocked={mapLocked} toggleMapLocked={toggleMapLocked} mapFocusPoints={mapFocusPoints}
|
||||
route={route} routeWalking={routeWalking} routeSegments={routeSegments} routeVias={routeVias} transitRoutesShown={transitRoutesShown}
|
||||
handleMarkerClick={handleMarkerClick} handleMapClick={handleMapClick} handleMapContextMenu={handleMapContextMenu} handlePoiClick={handlePoiClick}
|
||||
visibleConnections={visibleConnections} setMapTransportDetail={setMapTransportDetail} openBookingDetail={openBookingDetail}
|
||||
roadtripActive={roadtripActive} roadtripMapPlaces={roadtripMapPlaces} roadtripMapLines={roadtripMapLines} roadtripLineColors={roadtripLineColors} roadtripMapVias={roadtripMapVias}
|
||||
roadtripRoutes={roadtripRoutes} roadtripConnections={roadtripConnections} roadtripVias={roadtripVias}
|
||||
dayBoundaryControls={dayBoundaryControls} dropPoiOnRoute={dropPoiOnRoute} addRoadtripVia={addRoadtripVia} moveRoadtripVia={moveRoadtripVia} removeRoadtripVia={removeRoadtripVia}
|
||||
routeAlternatives={routeAlternatives} alternativeOverlays={alternativeOverlays} highlightedAlternative={highlightedAlternative} chooseRouteAlternative={chooseRouteAlternative} setHighlightedAlternative={setHighlightedAlternative}
|
||||
overviewActive={overviewActive} tripOverview={tripOverview} overviewShown={overviewShown} toggleOverview={toggleOverview}
|
||||
dawarichEnabled={dawarichEnabled} dawarichTrail={dawarichTrail} dawarichHiddenDates={dawarichHiddenDates} dawarichTrailShown={dawarichTrailShown} toggleDawarichTrail={toggleDawarichTrail}
|
||||
mobileSidebarOpen={mobileSidebarOpen} showPlaceForm={showPlaceForm} showMembersModal={showMembersModal} showReservationModal={showReservationModal}
|
||||
poi={poi} tourMap={tourMap} leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} mapInsetLeft={mapInsetLeft} mapInsetRight={mapInsetRight}
|
||||
glMap={glMap} setGlMap={setGlMap} poiPillEnabled={poiPillEnabled} mapPois={mapPois} distanceUnit={distanceUnit}
|
||||
/>
|
||||
|
||||
{/* Over the map rather than in a dialog: the answer to "which of these" is the
|
||||
roads drawn behind it, so covering them to ask would hide the point. */}
|
||||
{routeAlternatives.open && (
|
||||
<div style={{ position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
|
||||
<LazyPanel id="roadtrip-alternatives">
|
||||
<RoadtripAlternativesBar
|
||||
open={routeAlternatives.open}
|
||||
overlays={alternativeOverlays}
|
||||
onChoose={chooseRouteAlternative}
|
||||
onClose={routeAlternatives.close}
|
||||
onHighlight={setHighlightedAlternative}
|
||||
/>
|
||||
</LazyPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom-RIGHT. Not the top corridor between the panels, which is already
|
||||
contested by the POI bar and the collapse tabs (#2247); and not the
|
||||
bottom-left corner, where Leaflet's base-layer switcher sits at
|
||||
z-index 1000 and would cover this. The right corner is free on both
|
||||
renderers — the locate button that lives there is phone-only. */}
|
||||
{(!roadtripActive || dawarichEnabled) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26,
|
||||
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
|
||||
}}>
|
||||
{!roadtripActive && overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={tripOverview}
|
||||
unit={distanceUnit}
|
||||
selectedDayId={selectedDayId}
|
||||
onSelectDay={handleSelectDay}
|
||||
/>
|
||||
)}
|
||||
{!roadtripActive && (
|
||||
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
|
||||
)}
|
||||
{/* Stays in road-trip mode, unlike the overview: the route that was
|
||||
actually driven is the thing you most want beside the planned
|
||||
one. It is drawn, never applied — correcting the plan from the
|
||||
recording is a different feature and deliberately not this one. */}
|
||||
{dawarichEnabled && (
|
||||
<DawarichTrailPill
|
||||
active={dawarichTrailShown}
|
||||
status={dawarichTrail.status}
|
||||
onToggle={toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
// Centred on the corridor the panels leave, not on the viewport: at
|
||||
// 860px the viewport centre sits under the Places panel, where this
|
||||
// cluster covered both collapse tabs and Add Place/Activity (#2247).
|
||||
left: `calc(${mapInsetLeft}px + (100% - ${mapInsetLeft}px - ${mapInsetRight}px) / 2)`,
|
||||
// No wider than that corridor either: once plugins add categories the pill
|
||||
// scrolls inside it instead of running on under a panel.
|
||||
maxWidth: `calc(100% - ${mapInsetLeft}px - ${mapInsetRight}px - 24px)`,
|
||||
transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8,
|
||||
}}>
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
)}
|
||||
{glMap && <MapCompassPill map={glMap} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: the compass/reset-orientation control lives centre-top on its own
|
||||
(the desktop cluster above is hidden below md), between the edge Plan/Places tabs. */}
|
||||
{glMap && (
|
||||
<div className="flex md:hidden" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<MapCompassPill map={glMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile POI search controls live in a portal like the Plan/Places
|
||||
buttons so map touch handlers cannot swallow the tap targets. */}
|
||||
{poiPillEnabled && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && createPortal(
|
||||
<div data-testid="mobile-poi-category-pill" className="flex md:hidden" style={{ position: 'fixed', insetInline: 12, bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', justifyContent: 'center', zIndex: 100, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
{/* The panel's tab: a flap on its edge while open, a raised accent tile once it is tucked away. */}
|
||||
<Tooltip label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} placement="right">
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// FE-PAGE-PLANNER-MAPSTAGE-001 to FE-PAGE-PLANNER-MAPSTAGE-010
|
||||
//
|
||||
// The plan tab's map and its floating controls, rendered on their own. The map
|
||||
// and the pills are stubs that record their props, so each mode the stage
|
||||
// switches between (day route, road trip, trip overview) is read off directly.
|
||||
import React from 'react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { buildTrip, buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
import { PlanMapStage, type PlanMapStageProps } from './PlanMapStage'
|
||||
|
||||
type Recorded = Record<string, unknown> & Record<`on${string}`, (...args: unknown[]) => unknown>
|
||||
const captured = vi.hoisted(() => ({} as Record<string, Record<string, unknown>>))
|
||||
const stub = vi.hoisted(() => (name: string, testId: string) => (props: Record<string, unknown>) => {
|
||||
captured[name] = props
|
||||
return React.createElement('div', { 'data-testid': testId })
|
||||
})
|
||||
vi.mock('../../components/Map/MapViewAuto', () => ({ MapViewAuto: stub('map', 'map-view') }))
|
||||
vi.mock('../../components/Map/MapCompassPill', () => ({ MapCompassPill: stub('compass', 'compass-pill') }))
|
||||
vi.mock('../../components/Map/PoiCategoryPill', () => ({ default: stub('poiPill', 'poi-pill') }))
|
||||
vi.mock('../../components/Map/TripRouteOverview', () => ({
|
||||
TripRouteOverviewPill: stub('overviewPill', 'overview-pill'),
|
||||
TripRouteOverviewPanel: stub('overviewPanel', 'overview-panel'),
|
||||
}))
|
||||
vi.mock('../../components/Map/DawarichTrailPill', () => ({ DawarichTrailPill: stub('dawarichPill', 'dawarich-pill') }))
|
||||
vi.mock('../../components/Roadtrip/RoadtripAlternativesBar', () => ({ default: stub('alternativesBar', 'alternatives-bar') }))
|
||||
|
||||
function recorded(name: string): Recorded {
|
||||
return captured[name] as Recorded
|
||||
}
|
||||
|
||||
const trip = buildTrip({ id: 12 })
|
||||
const place = buildPlace({ id: 4 })
|
||||
const flight = buildReservation({ id: 70, trip_id: 12 })
|
||||
|
||||
// The fixture holds what the stage reads; the hook's full types do not matter here.
|
||||
function stageProps(overrides: Partial<Record<keyof PlanMapStageProps, unknown>> = {}): PlanMapStageProps {
|
||||
const base: Partial<Record<keyof PlanMapStageProps, unknown>> = {
|
||||
tripId: 12, trip, can: () => true, days: [], reservations: [flight], isMobile: false,
|
||||
selectedDayId: 3, selectedPlaceId: null, selectedPlace: null, showDayDetail: null, handleSelectDay: vi.fn(),
|
||||
mapPlaces: ['day places'], dayPlaces: [], dayOrderMap: {}, mapTileUrl: 'tiles', fitKey: 1,
|
||||
mapLocked: false, toggleMapLocked: vi.fn(), mapFocusPoints: ['day focus'],
|
||||
route: ['day route'], routeWalking: ['walking'], routeSegments: ['day segments'], routeVias: ['day vias'], transitRoutesShown: true,
|
||||
handleMarkerClick: vi.fn(), handleMapClick: vi.fn(), handleMapContextMenu: vi.fn(), handlePoiClick: vi.fn(),
|
||||
visibleConnections: ['day connections'], setMapTransportDetail: vi.fn(), openBookingDetail: vi.fn(),
|
||||
roadtripActive: false, roadtripMapPlaces: ['stops'], roadtripMapLines: ['drive'], roadtripLineColors: ['red'],
|
||||
roadtripMapVias: ['drive vias'], roadtripRoutes: { accessLines: ['access'], segments: ['drive segments'] },
|
||||
roadtripConnections: ['rides'], roadtripVias: { byDay: { 3: [] } },
|
||||
dayBoundaryControls: ['boundaries'], dropPoiOnRoute: vi.fn(), addRoadtripVia: vi.fn(), moveRoadtripVia: vi.fn(), removeRoadtripVia: vi.fn(),
|
||||
routeAlternatives: { open: null, close: vi.fn() }, alternativeOverlays: ['overlays'], highlightedAlternative: null,
|
||||
chooseRouteAlternative: vi.fn(), setHighlightedAlternative: vi.fn(),
|
||||
overviewActive: false, overviewShown: false, toggleOverview: vi.fn(),
|
||||
tripOverview: { lines: ['overview'], lineColors: ['blue'], segments: ['overview segments'], focusPoints: ['overview focus'] },
|
||||
dawarichEnabled: false, dawarichTrail: { track: ['recorded'], status: 'idle' }, dawarichHiddenDates: ['2025-06-02'],
|
||||
dawarichTrailShown: false, toggleDawarichTrail: vi.fn(),
|
||||
mobileSidebarOpen: null, showPlaceForm: false, showMembersModal: false, showReservationModal: false,
|
||||
poi: { categories: { core: [], plugin: [] }, active: [], toggle: vi.fn(), loadingKeys: [], errorKeys: [], moved: false, searchArea: vi.fn() },
|
||||
tourMap: { captureViewport: vi.fn() },
|
||||
leftPanelPx: 320, rightPanelPx: 0, mapInsetLeft: 330, mapInsetRight: 0,
|
||||
glMap: null, setGlMap: vi.fn(), poiPillEnabled: true, mapPois: ['pois'], distanceUnit: 'metric',
|
||||
}
|
||||
return { ...base, ...overrides } as unknown as PlanMapStageProps
|
||||
}
|
||||
|
||||
function renderStage(overrides: Partial<Record<keyof PlanMapStageProps, unknown>> = {}) {
|
||||
const props = stageProps(overrides)
|
||||
return { props, ...render(<PlanMapStage {...props} />) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(captured)) delete captured[key]
|
||||
})
|
||||
|
||||
describe('PlanMapStage', () => {
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-001: on an ordinary day the map draws that day', () => {
|
||||
renderStage()
|
||||
const map = recorded('map')
|
||||
|
||||
expect(map).toMatchObject({
|
||||
places: ['day places'], route: ['day route'], routeColors: undefined, routeWalking: ['walking'],
|
||||
routeSegments: ['day segments'], routeVias: ['day vias'], focusPoints: ['day focus'],
|
||||
visibleConnectionIds: ['day connections'], dayBoundaryControls: undefined, accessLines: undefined,
|
||||
roadtripVias: undefined, onPoiDropOnRoute: undefined, onRouteClick: undefined, clusterLoosely: false,
|
||||
followSelection: true, leftWidth: 320, rightWidth: 0, hasInspector: false, hasDayDetail: false,
|
||||
dawarichTrack: ['recorded'], dawarichHiddenDates: ['2025-06-02'], pois: ['pois'], tileUrl: 'tiles',
|
||||
})
|
||||
expect(screen.getByTestId('overview-pill')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('overview-panel')).toBeNull()
|
||||
expect(screen.queryByTestId('dawarich-pill')).toBeNull()
|
||||
expect(screen.queryByTestId('alternatives-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-002: in road trip mode the map draws the drive and edits it', () => {
|
||||
const { props } = renderStage({ roadtripActive: true })
|
||||
const map = recorded('map')
|
||||
|
||||
expect(map).toMatchObject({
|
||||
places: ['stops'], route: ['drive'], routeColors: ['red'], routeWalking: undefined,
|
||||
routeSegments: ['drive segments'], routeVias: ['drive vias'], dayBoundaryControls: ['boundaries'],
|
||||
accessLines: ['access'], visibleConnectionIds: ['rides'], roadtripVias: { 3: [] }, clusterLoosely: true,
|
||||
})
|
||||
expect(map.onPoiDropOnRoute).toBe(props.dropPoiOnRoute)
|
||||
expect(map.onRouteClick).toBe(props.addRoadtripVia)
|
||||
expect(map.onMoveVia).toBe(props.moveRoadtripVia)
|
||||
expect(map.onRemoveVia).toBe(props.removeRoadtripVia)
|
||||
// The overview belongs to the plan, so the whole stack goes while it is the only thing in it.
|
||||
expect(screen.queryByTestId('overview-pill')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-003: without the day right the drive cannot be edited from the map', () => {
|
||||
const can = vi.fn((action: string) => action !== 'day_edit')
|
||||
renderStage({ roadtripActive: true, can })
|
||||
const map = recorded('map')
|
||||
|
||||
expect(can).toHaveBeenCalledWith('day_edit', trip)
|
||||
expect(map.onRouteClick).toBeUndefined()
|
||||
expect(map.onMoveVia).toBeUndefined()
|
||||
expect(map.onRemoveVia).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-004: the trip overview replaces the day route and opens its panel', () => {
|
||||
const { props } = renderStage({ overviewActive: true, overviewShown: true })
|
||||
const map = recorded('map')
|
||||
|
||||
expect(map).toMatchObject({
|
||||
route: ['overview'], routeColors: ['blue'], routeSegments: ['overview segments'],
|
||||
focusPoints: ['overview focus'], routeWalking: undefined,
|
||||
})
|
||||
expect(recorded('overviewPanel')).toMatchObject({ overview: props.tripOverview, unit: 'metric', selectedDayId: 3 })
|
||||
recorded('overviewPanel').onSelectDay(5)
|
||||
expect(props.handleSelectDay).toHaveBeenCalledWith(5)
|
||||
expect(recorded('overviewPill')).toMatchObject({ active: true, onToggle: props.toggleOverview })
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-005: the recorded trail stays beside the drive in road trip mode', () => {
|
||||
const { props } = renderStage({ roadtripActive: true, dawarichEnabled: true, dawarichTrailShown: true, mapInsetRight: 290 })
|
||||
|
||||
expect(recorded('dawarichPill')).toMatchObject({ active: true, status: 'idle', onToggle: props.toggleDawarichTrail })
|
||||
expect(screen.queryByTestId('overview-pill')).toBeNull()
|
||||
const stack = screen.getByTestId('dawarich-pill').parentElement as HTMLElement
|
||||
expect(stack.style.right).toBe('304px')
|
||||
expect(stack.style.left).toBe('')
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-006: an open choice of routes shows the alternatives bar over the map', async () => {
|
||||
const open = { dayId: 3 }
|
||||
const { props } = renderStage({ routeAlternatives: { open, close: vi.fn() } })
|
||||
|
||||
expect(await screen.findByTestId('alternatives-bar')).toBeInTheDocument()
|
||||
expect(recorded('alternativesBar')).toMatchObject({
|
||||
open,
|
||||
overlays: ['overlays'],
|
||||
onChoose: props.chooseRouteAlternative,
|
||||
onClose: (props.routeAlternatives as { close: unknown }).close,
|
||||
onHighlight: props.setHighlightedAlternative,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-007: a booking clicked on the map opens where the layout shows bookings', () => {
|
||||
const desktop = renderStage()
|
||||
recorded('map').onReservationClick(999)
|
||||
expect(desktop.props.openBookingDetail).not.toHaveBeenCalled()
|
||||
recorded('map').onReservationClick(70)
|
||||
expect(desktop.props.openBookingDetail).toHaveBeenCalledWith(flight)
|
||||
expect(desktop.props.setMapTransportDetail).not.toHaveBeenCalled()
|
||||
desktop.unmount()
|
||||
|
||||
const phone = renderStage({ isMobile: true, mapLocked: true })
|
||||
expect(recorded('map')).toMatchObject({ followSelection: true, onToggleFollow: undefined })
|
||||
recorded('map').onReservationClick(70)
|
||||
expect(phone.props.setMapTransportDetail).toHaveBeenCalledWith(flight)
|
||||
expect(phone.props.openBookingDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-008: the compass appears once the map hands itself over', () => {
|
||||
const { props, rerender } = renderStage({ poiPillEnabled: false })
|
||||
expect(screen.queryByTestId('compass-pill')).toBeNull()
|
||||
expect(screen.queryByTestId('poi-pill')).toBeNull()
|
||||
expect(recorded('map').onMapReady).toBe(props.setGlMap)
|
||||
|
||||
const glMap = { getBearing: () => 0 }
|
||||
rerender(<PlanMapStage {...stageProps({ poiPillEnabled: false, glMap })} />)
|
||||
// The desktop cluster and the phone's own centre-top copy.
|
||||
expect(screen.getAllByTestId('compass-pill')).toHaveLength(2)
|
||||
expect(recorded('compass').map).toBe(glMap)
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-009: the phone POI pill steps aside for drawers and dialogs', () => {
|
||||
const { rerender } = renderStage()
|
||||
const portal = screen.getByTestId('mobile-poi-category-pill')
|
||||
expect(portal.parentElement).toBe(document.body)
|
||||
expect(portal).toContainElement(screen.getAllByTestId('poi-pill')[1])
|
||||
|
||||
for (const cover of [{ mobileSidebarOpen: 'left' }, { showPlaceForm: true }, { showMembersModal: true }, { showReservationModal: true }]) {
|
||||
rerender(<PlanMapStage {...stageProps(cover)} />)
|
||||
expect(screen.queryByTestId('mobile-poi-category-pill')).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PAGE-PLANNER-MAPSTAGE-010: the top cluster is centred on the corridor between the panels', () => {
|
||||
const { props, container } = renderStage({ mapInsetLeft: 330, mapInsetRight: 290, selectedPlace: place, showDayDetail: { id: 3 } })
|
||||
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
|
||||
|
||||
// As the browser normalizes it: half the corridor past the left inset, and no wider than the corridor.
|
||||
expect(cluster.style.left).toBe('calc(330px + 0.5 * (100% - 330px - 290px))')
|
||||
expect(cluster.style.maxWidth).toBe('calc(100% - 644px)')
|
||||
expect(recorded('map')).toMatchObject({ hasInspector: true, hasDayDetail: false, selectedPlace: place })
|
||||
const poi = props.poi as unknown as Record<string, unknown>
|
||||
expect(recorded('poiPill')).toMatchObject({
|
||||
categories: poi.categories, active: poi.active, onToggle: poi.toggle,
|
||||
loadingKeys: poi.loadingKeys, errorKeys: poi.errorKeys, moved: false, onSearchArea: poi.searchArea,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Dispatch, ReactElement, SetStateAction } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../../components/Map/MapCompassPill'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../components/Map/TripRouteOverview'
|
||||
import { DawarichTrailPill } from '../../components/Map/DawarichTrailPill'
|
||||
import PoiCategoryPill from '../../components/Map/PoiCategoryPill'
|
||||
import type { Poi } from '../../components/Map/poiCategories'
|
||||
import type { Settings } from '../../types'
|
||||
import { RoadtripAlternativesBar } from './plannerLazy'
|
||||
import { LazyPanel } from './LazyPanel'
|
||||
import type { useTripPlanner } from './useTripPlanner'
|
||||
import type { useTripPlannerPage } from './useTripPlannerPage'
|
||||
|
||||
type Planner = ReturnType<typeof useTripPlanner>
|
||||
type PageState = ReturnType<typeof useTripPlannerPage>
|
||||
|
||||
// Picked per concern so the key lists stay short and typed by the hook that owns them.
|
||||
type StageTrip = Pick<Planner, 'tripId' | 'trip' | 'can' | 'days' | 'reservations' | 'isMobile' | 'selectedDayId' | 'selectedPlaceId' | 'selectedPlace' | 'showDayDetail' | 'handleSelectDay'>
|
||||
type StageMap = Pick<Planner,
|
||||
| 'mapPlaces' | 'dayPlaces' | 'dayOrderMap' | 'mapTileUrl' | 'fitKey' | 'mapLocked' | 'toggleMapLocked' | 'mapFocusPoints'
|
||||
| 'route' | 'routeWalking' | 'routeSegments' | 'routeVias' | 'transitRoutesShown'
|
||||
| 'handleMarkerClick' | 'handleMapClick' | 'handleMapContextMenu' | 'handlePoiClick'
|
||||
| 'visibleConnections' | 'setMapTransportDetail' | 'openBookingDetail'
|
||||
>
|
||||
type StageRoadtrip = Pick<Planner,
|
||||
| 'roadtripActive' | 'roadtripMapPlaces' | 'roadtripMapLines' | 'roadtripLineColors' | 'roadtripMapVias' | 'roadtripRoutes' | 'roadtripConnections' | 'roadtripVias'
|
||||
| 'dayBoundaryControls' | 'dropPoiOnRoute' | 'addRoadtripVia' | 'moveRoadtripVia' | 'removeRoadtripVia'
|
||||
>
|
||||
type StageAlternatives = Pick<Planner, 'routeAlternatives' | 'alternativeOverlays' | 'highlightedAlternative' | 'chooseRouteAlternative' | 'setHighlightedAlternative'>
|
||||
type StageOverlays = Pick<Planner,
|
||||
| 'overviewActive' | 'tripOverview' | 'overviewShown' | 'toggleOverview'
|
||||
| 'dawarichEnabled' | 'dawarichTrail' | 'dawarichHiddenDates' | 'dawarichTrailShown' | 'toggleDawarichTrail'
|
||||
| 'mobileSidebarOpen' | 'showPlaceForm' | 'showMembersModal' | 'showReservationModal'
|
||||
>
|
||||
|
||||
/** What the page works out itself: the panel geometry, the compass map and the POI settings. */
|
||||
interface StagePageLocals {
|
||||
leftPanelPx: number
|
||||
rightPanelPx: number
|
||||
mapInsetLeft: number
|
||||
mapInsetRight: number
|
||||
glMap: CompassMap | null
|
||||
setGlMap: Dispatch<SetStateAction<CompassMap | null>>
|
||||
poiPillEnabled: boolean
|
||||
mapPois: Poi[]
|
||||
distanceUnit: Settings['distance_unit']
|
||||
}
|
||||
|
||||
export interface PlanMapStageProps
|
||||
extends StageTrip, StageMap, StageRoadtrip, StageAlternatives, StageOverlays, Pick<PageState, 'poi' | 'tourMap'>, StagePageLocals {}
|
||||
|
||||
/**
|
||||
* The plan tab's map and the controls floating over it. It returns a fragment on
|
||||
* purpose: the children sit straight in the plan container, ahead of the side
|
||||
* panels, so their stacking and the order of the portals in document.body stay
|
||||
* exactly what they were when this lived in the page.
|
||||
*/
|
||||
export function PlanMapStage({
|
||||
tripId, trip, can, days, reservations, isMobile,
|
||||
selectedDayId, selectedPlaceId, selectedPlace, showDayDetail, handleSelectDay,
|
||||
mapPlaces, dayPlaces, dayOrderMap, mapTileUrl, fitKey, mapLocked, toggleMapLocked, mapFocusPoints,
|
||||
route, routeWalking, routeSegments, routeVias, transitRoutesShown,
|
||||
handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
|
||||
visibleConnections, setMapTransportDetail, openBookingDetail,
|
||||
roadtripActive, roadtripMapPlaces, roadtripMapLines, roadtripLineColors, roadtripMapVias,
|
||||
roadtripRoutes, roadtripConnections, roadtripVias,
|
||||
dayBoundaryControls, dropPoiOnRoute, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, alternativeOverlays, highlightedAlternative, chooseRouteAlternative, setHighlightedAlternative,
|
||||
overviewActive, tripOverview, overviewShown, toggleOverview,
|
||||
dawarichEnabled, dawarichTrail, dawarichHiddenDates, dawarichTrailShown, toggleDawarichTrail,
|
||||
mobileSidebarOpen, showPlaceForm, showMembersModal, showReservationModal,
|
||||
poi, tourMap, leftPanelPx, rightPanelPx, mapInsetLeft, mapInsetRight,
|
||||
glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit,
|
||||
}: Readonly<PlanMapStageProps>): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
dawarichTrack={dawarichTrail.track}
|
||||
dawarichHiddenDates={dawarichHiddenDates}
|
||||
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
||||
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
|
||||
routeWalking={roadtripActive || overviewActive ? undefined : routeWalking}
|
||||
followSelection={!mapLocked || isMobile}
|
||||
onToggleFollow={isMobile ? undefined : toggleMapLocked}
|
||||
routeVias={roadtripActive ? roadtripMapVias : routeVias}
|
||||
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
|
||||
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
|
||||
showTransitRoutes={transitRoutesShown}
|
||||
// The route toggle belongs to one day, so the map needs that day to
|
||||
// know which automated transports may ride it (#2019).
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
routeSegments={roadtripActive ? roadtripRoutes.segments : overviewActive ? tripOverview.segments : routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
selectedPlace={selectedPlace}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
onMapContextMenu={handleMapContextMenu}
|
||||
// No center/zoom: the map frames itself on the trip's places at mount, and
|
||||
// falls back to the world view when the trip has none.
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={fitKey}
|
||||
dayOrderMap={dayOrderMap}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
hasInspector={!!selectedPlace}
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
showReservationStats={true}
|
||||
// In road trip mode the rides that seam the drive are drawn as their own arcs
|
||||
// beside the roads, on top of what the reader switched on under Days.
|
||||
visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
|
||||
// The desktop plan shows the booking's detail; the narrow layout keeps the day
|
||||
// list's transport view.
|
||||
onReservationClick={(rid) => {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (!r) return
|
||||
if (isMobile) setMapTransportDetail(r)
|
||||
else openBookingDetail(r)
|
||||
}}
|
||||
/* In road trip mode the corridor's `visible` (not `search.results`: the map is
|
||||
the picture of that very list, and filtering the list while seventy pins stay
|
||||
on the map no longer answers "which of these") plus whatever the category
|
||||
pill found in view. Outside it, only the pill's hits. */
|
||||
pois={mapPois}
|
||||
onPoiClick={handlePoiClick}
|
||||
// Only while road trip mode is on: outside it there is no drive to drop onto.
|
||||
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
|
||||
// Clicking the drawn route puts a via there; only in road trip mode, where
|
||||
// the route is the thing being worked on.
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
onHighlightAlternative={setHighlightedAlternative}
|
||||
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
|
||||
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
|
||||
onViewportChange={tourMap.captureViewport}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{/* Over the map rather than in a dialog: the answer to "which of these" is the
|
||||
roads drawn behind it, so covering them to ask would hide the point. */}
|
||||
{routeAlternatives.open && (
|
||||
<div style={{ position: 'absolute', bottom: 18, left: '50%', /* rtl-lint-disable: centred on the map */ transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
|
||||
<LazyPanel id="roadtrip-alternatives">
|
||||
<RoadtripAlternativesBar
|
||||
open={routeAlternatives.open}
|
||||
overlays={alternativeOverlays}
|
||||
onChoose={chooseRouteAlternative}
|
||||
onClose={routeAlternatives.close}
|
||||
onHighlight={setHighlightedAlternative}
|
||||
/>
|
||||
</LazyPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom-RIGHT. Not the top corridor between the panels, which is already
|
||||
contested by the POI bar and the collapse tabs (#2247); and not the
|
||||
bottom-left corner, where Leaflet's base-layer switcher sits at
|
||||
z-index 1000 and would cover this. The right corner is free on both
|
||||
renderers: the locate button that lives there is phone-only. */}
|
||||
{(!roadtripActive || dawarichEnabled) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26, // rtl-lint-disable: measured from the right panel's inset
|
||||
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
|
||||
}}>
|
||||
{!roadtripActive && overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={tripOverview}
|
||||
unit={distanceUnit}
|
||||
selectedDayId={selectedDayId}
|
||||
onSelectDay={handleSelectDay}
|
||||
/>
|
||||
)}
|
||||
{!roadtripActive && (
|
||||
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
|
||||
)}
|
||||
{/* Stays in road-trip mode, unlike the overview: the route that was
|
||||
actually driven is the thing you most want beside the planned
|
||||
one. It is drawn, never applied. Correcting the plan from the
|
||||
recording is a different feature and deliberately not this one. */}
|
||||
{dawarichEnabled && (
|
||||
<DawarichTrailPill
|
||||
active={dawarichTrailShown}
|
||||
status={dawarichTrail.status}
|
||||
onToggle={toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
// Centred on the corridor the panels leave, not on the viewport: at
|
||||
// 860px the viewport centre sits under the Places panel, where this
|
||||
// cluster covered both collapse tabs and Add Place/Activity (#2247).
|
||||
left: `calc(${mapInsetLeft}px + (100% - ${mapInsetLeft}px - ${mapInsetRight}px) / 2)`, // rtl-lint-disable: centred between the measured panel insets
|
||||
// No wider than that corridor either: once plugins add categories the pill
|
||||
// scrolls inside it instead of running on under a panel.
|
||||
maxWidth: `calc(100% - ${mapInsetLeft}px - ${mapInsetRight}px - 24px)`,
|
||||
transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8,
|
||||
}}>
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
)}
|
||||
{glMap && <MapCompassPill map={glMap} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: the compass/reset-orientation control lives centre-top on its own
|
||||
(the desktop cluster above is hidden below md), between the edge Plan/Places tabs. */}
|
||||
{glMap && (
|
||||
<div className="flex md:hidden" style={{ position: 'absolute', top: 14, left: '50%', /* rtl-lint-disable: centred on the map */ transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<MapCompassPill map={glMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile POI search controls live in a portal like the Plan/Places
|
||||
buttons so map touch handlers cannot swallow the tap targets. */}
|
||||
{poiPillEnabled && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && createPortal(
|
||||
<div data-testid="mobile-poi-category-pill" className="flex md:hidden" style={{ position: 'fixed', insetInline: 12, bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', justifyContent: 'center', zIndex: 100, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user