mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(tours): move the planner additions out of the map, sidebar and page files that grew past their size baseline
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
"collection.ts": 35,
|
||||
"common.ts": 7,
|
||||
"dashboard.ts": 65,
|
||||
"dawarich.ts": 16,
|
||||
"dawarich.ts": 15,
|
||||
"dayplan.ts": 18,
|
||||
"docsync.ts": 25,
|
||||
"files.ts": 4,
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1461,
|
||||
"src/components/Map/MapView.tsx": 1453,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2425,
|
||||
"src/components/Map/MapViewGL.tsx": 2414,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2978,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2973,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,8 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1168,
|
||||
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
|
||||
"src/pages/TripPlannerPage.tsx": 1153,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
@@ -6,7 +6,7 @@ import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -758,70 +758,9 @@ const MemoMarker = memo(function MemoMarker({
|
||||
)
|
||||
})
|
||||
|
||||
function plannerWaypointIcon(index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
})
|
||||
}
|
||||
|
||||
function PlannerWaypointMarker({
|
||||
point,
|
||||
index,
|
||||
selected,
|
||||
onClick,
|
||||
onMove,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number };
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
onMove?: (id: string, lat: number, lng: number) => boolean;
|
||||
}) {
|
||||
const map = useMap()
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null)
|
||||
const handlersRef = useRef({ onClick, onMove })
|
||||
useLayoutEffect(() => { handlersRef.current = { onClick, onMove } }, [onClick, onMove])
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng])
|
||||
const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected])
|
||||
const eventHandlers = useMemo(() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
handlersRef.current.onClick?.(point.id)
|
||||
},
|
||||
}), [point.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!marker || !onMove) return
|
||||
const element = marker.getElement()
|
||||
if (!element) return
|
||||
return wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top])
|
||||
return { lat: at.lat, lng: at.lng }
|
||||
},
|
||||
setPosition: ({ lat, lng }) => { marker.setLatLng([lat, lng]) },
|
||||
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
|
||||
})
|
||||
}, [map, marker, onMove, point.id, point.lat, point.lng])
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={setMarker}
|
||||
position={position}
|
||||
icon={icon}
|
||||
zIndexOffset={selected ? 1200 : 1100}
|
||||
eventHandlers={eventHandlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
/** The look of a planner control point, drawn by PlannerWaypointMarker and kept beside the route blue. */
|
||||
function plannerWaypointPinCss(size: number, selected: boolean): string {
|
||||
return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui`
|
||||
}
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
@@ -1372,6 +1311,7 @@ export const MapView = memo(function MapView({
|
||||
selected={point.id === selectedPlannerWaypointId}
|
||||
onClick={onPlannerWaypointClick}
|
||||
onMove={onPlannerWaypointMove}
|
||||
pinCss={plannerWaypointPinCss}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
// The planner-waypoint and location-search camera cases of MapViewGL, with the same
|
||||
// map doubles as MapViewGL.test.tsx.
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
// reachable from tests via dispatchEvent.
|
||||
const glCanvasContainer = vi.hoisted(() => document.createElement('div'));
|
||||
const glMap = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
loaded: vi.fn().mockReturnValue(true),
|
||||
fitBounds: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
getZoom: vi.fn().mockReturnValue(10),
|
||||
getCenter: vi.fn(() => ({ lng: 0, lat: 0 })),
|
||||
addControl: vi.fn(),
|
||||
removeControl: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
getSource: vi.fn().mockReturnValue(null),
|
||||
addLayer: vi.fn(),
|
||||
// Every overlay that adds a layer takes it away again on cleanup; a real
|
||||
// MapLibre/Mapbox map has both halves, and a double with only the adding half
|
||||
// fails the teardown rather than the behaviour under test.
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
|
||||
// The MapLibre place pins position themselves with this, so a mock without it
|
||||
// never exercises the path the real map takes.
|
||||
project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })),
|
||||
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
|
||||
easeTo: vi.fn(),
|
||||
getCanvas: vi.fn(() => document.createElement('canvas')),
|
||||
getBearing: vi.fn(() => 0),
|
||||
setTerrain: vi.fn(),
|
||||
setConfigProperty: vi.fn(),
|
||||
queryTerrainElevation: vi.fn((): number | null => null),
|
||||
}));
|
||||
|
||||
const glBounds = vi.hoisted(() => {
|
||||
const state = {
|
||||
instances: [] as Array<{ extend: ReturnType<typeof vi.fn> }>,
|
||||
};
|
||||
return {
|
||||
get instances() {
|
||||
return state.instances;
|
||||
},
|
||||
clear: () => {
|
||||
state.instances = [];
|
||||
},
|
||||
create: () => {
|
||||
const bounds = {
|
||||
extend: vi.fn(() => bounds),
|
||||
};
|
||||
state.instances.push(bounds);
|
||||
return bounds;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Markers and popups are created through the mocked GL constructors, so the
|
||||
// tests can reach the very instances the component built (their DOM element,
|
||||
// the coordinates they were placed at, the popup content they were given).
|
||||
interface FakeMarker {
|
||||
element: HTMLElement;
|
||||
lngLat: number[] | null;
|
||||
setLngLat: (ll: number[]) => FakeMarker;
|
||||
getLngLat: () => { lng: number; lat: number };
|
||||
addTo: (map: unknown) => FakeMarker;
|
||||
remove: () => void;
|
||||
getElement: () => HTMLElement;
|
||||
}
|
||||
|
||||
interface FakePopup {
|
||||
setLngLat: (ll: unknown) => FakePopup;
|
||||
setHTML: (html: string) => FakePopup;
|
||||
setText: (text: string) => FakePopup;
|
||||
setDOMContent: (node: HTMLElement) => FakePopup;
|
||||
addTo: (map: unknown) => FakePopup;
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
const glMarkers = vi.hoisted(() => {
|
||||
const created: FakeMarker[] = [];
|
||||
return {
|
||||
get created() {
|
||||
return created;
|
||||
},
|
||||
clear: () => {
|
||||
created.length = 0;
|
||||
},
|
||||
make: (opts?: { element?: HTMLElement }): FakeMarker => {
|
||||
const element = opts?.element ?? document.createElement('div');
|
||||
const marker: FakeMarker = {
|
||||
element,
|
||||
lngLat: null,
|
||||
setLngLat: vi.fn((ll: number[]) => {
|
||||
marker.lngLat = ll;
|
||||
return marker;
|
||||
}),
|
||||
getLngLat: vi.fn(() => ({ lng: Number(marker.lngLat?.[0] ?? 0), lat: Number(marker.lngLat?.[1] ?? 0) })),
|
||||
addTo: vi.fn((_map: unknown) => marker),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => element),
|
||||
};
|
||||
created.push(marker);
|
||||
return marker;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const glPopup = vi.hoisted((): FakePopup => {
|
||||
const popup: FakePopup = {
|
||||
setLngLat: vi.fn((_ll: unknown) => popup),
|
||||
setHTML: vi.fn((_html: string) => popup),
|
||||
setText: vi.fn((_text: string) => popup),
|
||||
setDOMContent: vi.fn((_node: HTMLElement) => popup),
|
||||
addTo: vi.fn((_map: unknown) => popup),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
return popup;
|
||||
});
|
||||
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
vi.mock('./mapboxSetup', () => ({
|
||||
isStandardFamily: vi.fn(() => false),
|
||||
supportsCustom3d: vi.fn(() => false),
|
||||
wantsTerrain: vi.fn(() => false),
|
||||
addCustom3dBuildings: vi.fn(),
|
||||
addTerrainAndSky: vi.fn(),
|
||||
}));
|
||||
|
||||
const locationMarker = vi.hoisted(() => ({ update: vi.fn(), destroy: vi.fn() }));
|
||||
|
||||
vi.mock('./locationMarkerMapbox', () => ({
|
||||
attachLocationMarker: vi.fn(() => locationMarker),
|
||||
}));
|
||||
|
||||
const reservationOverlay = vi.hoisted(() => ({
|
||||
update: vi.fn((_reservations: unknown, _opts: unknown, _routes: unknown) => {}),
|
||||
destroy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./reservationsMapbox', () => ({
|
||||
ReservationMapboxOverlay: vi.fn(function () {
|
||||
return reservationOverlay;
|
||||
}),
|
||||
}));
|
||||
|
||||
const geoStub = vi.hoisted(() => ({
|
||||
position: null as GeoPosition | null,
|
||||
mode: 'off' as TrackingMode,
|
||||
error: null as string | null,
|
||||
errorCode: null as string | null,
|
||||
cycleMode: vi.fn(async () => {}),
|
||||
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: vi.fn(() => geoStub),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import * as photoService from '../../services/photoService';
|
||||
import { MapViewGL as MapViewGLWithEngine } from './MapViewGL';
|
||||
import * as mapboxSetup from './mapboxSetup';
|
||||
|
||||
// The engine is a prop, not a module import (see MapViewGL.test.tsx). This shim makes
|
||||
// the same choice glLazy.tsx makes in production.
|
||||
function MapViewGL(props: Omit<React.ComponentProps<typeof MapViewGLWithEngine>, 'gl'>) {
|
||||
return <MapViewGLWithEngine {...props} gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl} />;
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: 1 },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
glMap.on.mockImplementation(() => glMap);
|
||||
glMap.off.mockImplementation(() => glMap);
|
||||
glMap.once.mockImplementation(() => glMap);
|
||||
glMap.loaded.mockReturnValue(true);
|
||||
glMap.getSource.mockReturnValue(null);
|
||||
glMap.getLayer.mockReturnValue(null);
|
||||
glMap.queryRenderedFeatures.mockReturnValue([]);
|
||||
glMap.querySourceFeatures.mockReturnValue([]);
|
||||
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'));
|
||||
glMap.getBearing.mockReturnValue(0);
|
||||
glMap.queryTerrainElevation.mockReturnValue(null);
|
||||
// A busy style, a refused source and a custom layer list are each set by one test
|
||||
// for the case it covers. Put back here, or every test after it would silently run
|
||||
// against a map that never settles.
|
||||
glMap.isStyleLoaded.mockReturnValue(true);
|
||||
glMap.getStyle.mockReturnValue({ layers: [] });
|
||||
glMap.addSource.mockImplementation(() => undefined);
|
||||
glMarkers.clear();
|
||||
// clearAllMocks() wipes call history but keeps implementations, so anything a
|
||||
// test overrides with mockReturnValue has to be put back here.
|
||||
vi.mocked(mapboxSetup.isStandardFamily).mockReturnValue(false);
|
||||
vi.mocked(mapboxSetup.supportsCustom3d).mockReturnValue(false);
|
||||
vi.mocked(mapboxSetup.wantsTerrain).mockReturnValue(false);
|
||||
vi.mocked(photoService.getCached).mockReturnValue(undefined);
|
||||
vi.mocked(photoService.isLoading).mockReturnValue(false);
|
||||
vi.mocked(photoService.onThumbReady).mockReturnValue(() => {});
|
||||
vi.mocked(photoService.getAllThumbs).mockReturnValue({});
|
||||
geoStub.position = null;
|
||||
geoStub.mode = 'off';
|
||||
geoStub.error = null;
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
map_provider: 'mapbox-gl',
|
||||
mapbox_access_token: 'pk.test_token',
|
||||
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
|
||||
mapbox_3d_enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
glBounds.clear();
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('MapViewGL', () => {
|
||||
function loadOnAttach() {
|
||||
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
|
||||
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)();
|
||||
return glMap;
|
||||
});
|
||||
}
|
||||
|
||||
it('honors a location-search camera intent and preserves it through later route updates', async () => {
|
||||
loadOnAttach();
|
||||
const searchPoint: [number, number] = [35.0116, 135.7681];
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
|
||||
const mapConstructions = vi.mocked(maplibregl.Map).mock.calls.length;
|
||||
glMap.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={[[searchPoint, [35.02, 135.77]]]}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint, [35.02, 135.77]]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions);
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[
|
||||
[46.8, 9.5],
|
||||
[47.1, 10.1],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
|
||||
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
|
||||
async (provider) => {
|
||||
loadOnAttach();
|
||||
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }));
|
||||
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 });
|
||||
glMap.getContainer.mockReturnValue(glCanvasContainer);
|
||||
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const };
|
||||
const waypoints = [waypoint];
|
||||
const onMove = vi.fn(() => true);
|
||||
const onSelect = vi.fn();
|
||||
const onMapClick = vi.fn();
|
||||
const view = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={null}
|
||||
glProvider={provider}
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
glMap.fitBounds.mockClear();
|
||||
const pin =
|
||||
provider === 'maplibre-gl'
|
||||
? (glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement)
|
||||
: (glMarkers.created.find((marker) => marker.element.getAttribute('aria-label') === 'Waypoint 1')
|
||||
?.element as HTMLButtonElement);
|
||||
const startTransform = pin.style.transform;
|
||||
const libraryMarker =
|
||||
provider === 'mapbox-gl' ? glMarkers.created.find((marker) => marker.element === pin) : undefined;
|
||||
const startLngLat = libraryMarker?.lngLat;
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40));
|
||||
});
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
|
||||
expect(onMove).not.toHaveBeenCalled();
|
||||
expect(waypoints[0]).toEqual(waypoint);
|
||||
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
);
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', 30, 40));
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onMove).toHaveBeenCalledOnce();
|
||||
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[48.4, 11.3]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
);
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled();
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
|
||||
loadOnAttach();
|
||||
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const };
|
||||
const onMove = vi.fn(() => true);
|
||||
const onSelect = vi.fn();
|
||||
const onMapClick = vi.fn();
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[waypoint]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement;
|
||||
const initialTransform = pin.style.transform;
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40));
|
||||
pin.dispatchEvent(pointer('pointercancel', 30, 40));
|
||||
});
|
||||
expect(pin.style.transform).toBe(initialTransform);
|
||||
expect(onMove).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
pin.dispatchEvent(pointer('pointerup', 10, 10));
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id);
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,6 @@ const glMap = vi.hoisted(() => ({
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
@@ -247,17 +246,6 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
} as any
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: 1 },
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
glMap.on.mockImplementation(() => glMap)
|
||||
glMap.off.mockImplementation(() => glMap)
|
||||
@@ -943,55 +931,6 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.easeTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a location-search camera intent and preserves it through later route updates', async () => {
|
||||
loadOnAttach();
|
||||
const searchPoint: [number, number] = [35.0116, 135.7681];
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
|
||||
const mapConstructions = vi.mocked(maplibregl.Map).mock.calls.length;
|
||||
glMap.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={[[searchPoint, [35.02, 135.77]]]}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint, [35.02, 135.77]]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions);
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[
|
||||
[46.8, 9.5],
|
||||
[47.1, 10.1],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => {
|
||||
loadOnAttach()
|
||||
const { rerender } = render(
|
||||
@@ -1043,126 +982,6 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)('RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera', async (provider) => {
|
||||
loadOnAttach()
|
||||
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }))
|
||||
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 })
|
||||
glMap.getContainer.mockReturnValue(glCanvasContainer)
|
||||
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const }
|
||||
const waypoints = [waypoint]
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={null}
|
||||
glProvider={provider}
|
||||
/>
|
||||
)
|
||||
await act(async () => {})
|
||||
glMap.fitBounds.mockClear()
|
||||
const pin = provider === 'maplibre-gl'
|
||||
? glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement
|
||||
: glMarkers.created.find(marker => marker.element.getAttribute('aria-label') === 'Waypoint 1')?.element as HTMLButtonElement
|
||||
const startTransform = pin.style.transform
|
||||
const libraryMarker = provider === 'mapbox-gl' ? glMarkers.created.find(marker => marker.element === pin) : undefined
|
||||
const startLngLat = libraryMarker?.lngLat
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10))
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40))
|
||||
})
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform)
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
expect(waypoints[0]).toEqual(waypoint)
|
||||
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
)
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform)
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat)
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', 30, 40))
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
expect(onMove).toHaveBeenCalledOnce()
|
||||
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[48.4, 11.3]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
)
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled()
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled()
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
|
||||
loadOnAttach()
|
||||
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const }
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[waypoint]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
)
|
||||
await act(async () => {})
|
||||
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement
|
||||
const initialTransform = pin.style.transform
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10))
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40))
|
||||
pin.dispatchEvent(pointer('pointercancel', 30, 40))
|
||||
})
|
||||
expect(pin.style.transform).toBe(initialTransform)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10))
|
||||
pin.dispatchEvent(pointer('pointerup', 10, 10))
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
|
||||
loadOnAttach()
|
||||
const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
|
||||
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
@@ -569,6 +569,11 @@ export function brandLogoMarkup(_brandWikidata?: string | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** The look of a planner control point, drawn by usePlannerWaypointMarkersGL. */
|
||||
function plannerWaypointPinCss(size: number, selected: boolean): string {
|
||||
return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui`
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
@@ -710,11 +715,6 @@ export function MapViewGL({
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
|
||||
useLayoutEffect(() => {
|
||||
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
|
||||
}, [onPlannerWaypointClick, onPlannerWaypointMove])
|
||||
const plannerWaypointsDraggable = !!onPlannerWaypointMove
|
||||
const profileFocusPinRef = useRef<PlacePin | null>(null)
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([])
|
||||
@@ -746,50 +746,14 @@ export function MapViewGL({
|
||||
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
|
||||
*/
|
||||
const viaCleanupRef = useRef<(() => void)[]>([])
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
|
||||
plannerWaypointPinsRef.current = []
|
||||
const dragCleanups: (() => void)[] = []
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId
|
||||
const size = selected ? 30 : 26
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
el.textContent = String(index + 1)
|
||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
|
||||
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
|
||||
const swallow = (event: Event) => event.stopPropagation()
|
||||
const select = (event: Event) => { event.stopPropagation(); plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id) }
|
||||
el.addEventListener('pointerdown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('click', select)
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)
|
||||
plannerWaypointPinsRef.current.push(pin)
|
||||
if (plannerWaypointsDraggable) {
|
||||
el.style.cursor = 'grab'
|
||||
dragCleanups.push(wirePlannerWaypointDrag({
|
||||
element: el,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([clientX - rect.left, clientY - rect.top])
|
||||
return { lat: at.lat, lng: at.lng }
|
||||
},
|
||||
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
|
||||
onCommit: ({ lat, lng }) => plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
dragCleanups.forEach(cleanup => cleanup())
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
|
||||
plannerWaypointPinsRef.current = []
|
||||
}
|
||||
}, [gl, mapReady, plannerWaypoints, plannerWaypointsDraggable, selectedPlannerWaypointId])
|
||||
const attachPlannerWaypointPin = useCallback(
|
||||
(el: HTMLElement, lng: number, lat: number) => attachPin(mapRef.current, gl, pinLayerRef.current, el, lng, lat),
|
||||
[gl],
|
||||
)
|
||||
usePlannerWaypointMarkersGL({
|
||||
mapRef, mapReady, pinsRef: plannerWaypointPinsRef, attachPin: attachPlannerWaypointPin, pinCss: plannerWaypointPinCss,
|
||||
plannerWaypoints, selectedPlannerWaypointId, onPlannerWaypointClick, onPlannerWaypointMove,
|
||||
})
|
||||
|
||||
useEffect(() => () => {
|
||||
profileFocusPinRef.current?.remove()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
export type PinCss = (size: number, selected: boolean) => string;
|
||||
|
||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26;
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
export default function PlannerWaypointMarker({
|
||||
point,
|
||||
index,
|
||||
selected,
|
||||
onClick,
|
||||
onMove,
|
||||
pinCss,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number };
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
onMove?: (id: string, lat: number, lng: number) => boolean;
|
||||
pinCss: PinCss;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null);
|
||||
const handlersRef = useRef({ onClick, onMove });
|
||||
useLayoutEffect(() => {
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
event.originalEvent.stopPropagation();
|
||||
handlersRef.current.onClick?.(point.id);
|
||||
},
|
||||
}),
|
||||
[point.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!marker || !onMove) return;
|
||||
const element = marker.getElement();
|
||||
if (!element) return;
|
||||
return wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
|
||||
return { lat: at.lat, lng: at.lng };
|
||||
},
|
||||
setPosition: ({ lat, lng }) => {
|
||||
marker.setLatLng([lat, lng]);
|
||||
},
|
||||
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
|
||||
});
|
||||
}, [map, marker, onMove, point.id, point.lat, point.lng]);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={setMarker}
|
||||
position={position}
|
||||
icon={icon}
|
||||
zIndexOffset={selected ? 1200 : 1100}
|
||||
eventHandlers={eventHandlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]}>
|
||||
{index + 1}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
interface PlannerWaypointMap {
|
||||
getContainer: () => HTMLElement;
|
||||
unproject: (point: [number, number]) => { lat: number; lng: number };
|
||||
}
|
||||
|
||||
/** The slice of a hand-positioned pin the waypoint markers drive. */
|
||||
interface PlannerWaypointPin {
|
||||
remove: () => void;
|
||||
setLngLat: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
interface PlannerWaypointMarkersOptions<Pin extends PlannerWaypointPin> {
|
||||
mapRef: { readonly current: PlannerWaypointMap | null };
|
||||
mapReady: boolean;
|
||||
/** The pins live in the caller's ref so its render-clock reposition can reach them. */
|
||||
pinsRef: { current: Pin[] };
|
||||
/** Puts a waypoint element on the map at the given position. */
|
||||
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
pinCss: (size: number, selected: boolean) => string;
|
||||
plannerWaypoints: NonNullable<MapViewProps['plannerWaypoints']>;
|
||||
selectedPlannerWaypointId: string | null;
|
||||
onPlannerWaypointClick: MapViewProps['onPlannerWaypointClick'];
|
||||
onPlannerWaypointMove: MapViewProps['onPlannerWaypointMove'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbered control points of a list-first route editor, drawn as pins on the GL map.
|
||||
*
|
||||
* The handlers are read through a ref so a parent re-rendering with fresh callbacks does
|
||||
* not tear the pins down mid-drag; only a change in whether they can be dragged does.
|
||||
*/
|
||||
export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
mapRef,
|
||||
mapReady,
|
||||
pinsRef,
|
||||
attachPin,
|
||||
pinCss,
|
||||
plannerWaypoints,
|
||||
selectedPlannerWaypointId,
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
}: PlannerWaypointMarkersOptions<Pin>): void {
|
||||
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove });
|
||||
useLayoutEffect(() => {
|
||||
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove };
|
||||
}, [onPlannerWaypointClick, onPlannerWaypointMove]);
|
||||
const plannerWaypointsDraggable = !!onPlannerWaypointMove;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
pinsRef.current.forEach((pin) => pin.remove());
|
||||
pinsRef.current = [];
|
||||
const dragCleanups: (() => void)[] = [];
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId;
|
||||
const size = selected ? 30 : 26;
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
const swallow = (event: Event) => event.stopPropagation();
|
||||
const select = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id);
|
||||
};
|
||||
el.addEventListener('pointerdown', swallow);
|
||||
el.addEventListener('touchstart', swallow, { passive: true });
|
||||
el.addEventListener('click', select);
|
||||
const pin = attachPin(el, point.lng, point.lat);
|
||||
pinsRef.current.push(pin);
|
||||
if (plannerWaypointsDraggable) {
|
||||
el.style.cursor = 'grab';
|
||||
dragCleanups.push(
|
||||
wirePlannerWaypointDrag({
|
||||
element: el,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
|
||||
return { lat: at.lat, lng: at.lng };
|
||||
},
|
||||
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
|
||||
onCommit: ({ lat, lng }) =>
|
||||
plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
dragCleanups.forEach((cleanup) => cleanup());
|
||||
pinsRef.current.forEach((pin) => pin.remove());
|
||||
pinsRef.current = [];
|
||||
};
|
||||
}, [
|
||||
attachPin,
|
||||
mapReady,
|
||||
mapRef,
|
||||
pinCss,
|
||||
pinsRef,
|
||||
plannerWaypoints,
|
||||
plannerWaypointsDraggable,
|
||||
selectedPlannerWaypointId,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import TourDayRowFacts from '../Tours/TourDayRowFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
||||
|
||||
interface DayPlanPlaceSublineProps {
|
||||
place: Place;
|
||||
cat: Category | undefined;
|
||||
tourTimes: DayRowTourTimes | undefined;
|
||||
isTourPlace: boolean;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
||||
if (!tourTimes) return false;
|
||||
return tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null;
|
||||
}
|
||||
|
||||
// Walking, break and planned-total pills of a tour stop that has no tour row of its own.
|
||||
function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
const { t } = useTranslation();
|
||||
const manual = tourTimes.manuallyOverridden;
|
||||
return (
|
||||
<>
|
||||
{tourTimes.walkingMinutes != null && (
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-walking-time"
|
||||
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.breakMinutes != null && (
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-breaks"
|
||||
aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.plannedTotalMinutes != null && (
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-planned-duration"
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
>
|
||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
|
||||
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Description line; tour stops get two clamped lines instead of one.
|
||||
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={isTourPlace ? 'trip-plan-tour-description' : undefined}
|
||||
className="collab-note-md min-w-0 flex-1 text-content-faint"
|
||||
style={{
|
||||
...fs(10.5),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: isTourPlace ? 'normal' : 'nowrap',
|
||||
lineHeight: 1.3,
|
||||
maxHeight: isTourPlace ? '2.6em' : '1.3em',
|
||||
...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}),
|
||||
}}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Second line of a place row in the day plan: time, tour facts or planned
|
||||
// tour durations, then the clamped description.
|
||||
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
|
||||
const { locale } = useTranslation();
|
||||
const dayRowTour = tourTimes?.tour;
|
||||
const description = place.description || place.address || cat?.name;
|
||||
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
|
||||
return (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{place.place_time && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}
|
||||
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour ? <TourDayRowFacts tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1534,58 +1534,6 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onDeletePlace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
|
||||
const description = 'A long description of the ridge route, the lake overlook, and the return path.'
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place })
|
||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false }
|
||||
const props = makeDefaultProps({ days: [day], places: [place], tourPlaceIds: new Set([place.id]), plannedTourDurations: new Map([[place.id, times]]), assignments: { '10': [assignment] } })
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] })
|
||||
const view = render(<DayPlanSidebar {...props} />)
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement
|
||||
expect(tourPreview).toHaveTextContent(description)
|
||||
expect(tourPreview.style.display).toBe('-webkit-box')
|
||||
expect(tourPreview.style.webkitLineClamp).toBe('2')
|
||||
expect(tourPreview.style.maxHeight).toBe('2.6em')
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h')
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min')
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min')
|
||||
|
||||
view.rerender(<DayPlanSidebar {...{ ...props, plannedTourDurations: new Map([[place.id, { ...times, plannedTotalMinutes: 125, manuallyOverridden: true }]]) }} />)
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override')
|
||||
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] })
|
||||
view.rerender(<DayPlanSidebar {...props} />)
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-planned-duration"]')).toBeNull()
|
||||
expect(screen.getByText(description).closest('.collab-note-md')).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' })
|
||||
})
|
||||
|
||||
it('RC-01: uses facet type and compact facts only for Tours, preserving Tours-off Place presentation', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description: 'Above the lake', tour_place_id: 45 })
|
||||
const day = buildDay({ id: 10, title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place })
|
||||
const tour = {
|
||||
place_id: 45, name: place.name, tour_type: 'hike' as const, distance: 4, elevation_gain: 100, elevation_loss: 80,
|
||||
duration: 53, break_additional_minutes: 10, planned_duration_minutes: null, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, max_hiking_difficulty: 2, planned: true, caution: false,
|
||||
}
|
||||
const props = makeDefaultProps({ days: [day], places: [place], tourPlaceIds: new Set([45]), assignments: { '10': [assignment] },
|
||||
plannedTourDurations: new Map([[45, { tour, walkingMinutes: 53, breakMinutes: 10, plannedTotalMinutes: 63, manuallyOverridden: false }]]) })
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] })
|
||||
const view = render(<DayPlanSidebar {...props} />)
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy()
|
||||
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4')
|
||||
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100')
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min')
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min')
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] })
|
||||
view.rerender(<DayPlanSidebar {...props} />)
|
||||
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TRIP-PLAN preserves legacy route-backed Place Delete when Tours is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] })
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
noteUi: {} as Record<string, unknown>,
|
||||
dayNotes: {} as Record<string, unknown[]>,
|
||||
setNoteUi: vi.fn(),
|
||||
noteInputRef: { current: null } as { current: null },
|
||||
openAddNote: vi.fn(),
|
||||
openEditNote: vi.fn(),
|
||||
cancelNote: vi.fn(),
|
||||
saveNote: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
moveNote: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
assignmentsApi: {
|
||||
reorder: vi.fn().mockResolvedValue({}),
|
||||
remove: vi.fn().mockResolvedValue({}),
|
||||
updateTime: vi.fn().mockResolvedValue({}),
|
||||
updateTransport: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
reservationsApi: {
|
||||
list: vi.fn().mockResolvedValue({ reservations: [] }),
|
||||
updatePositions: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
daysApi: {
|
||||
...actual.daysApi,
|
||||
updateTransport: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
vi.mock('../Map/RouteCalculator', () => ({
|
||||
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
walkingText: '25 min',
|
||||
})),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
beforeAll(() => {
|
||||
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIO;
|
||||
});
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useDayNotes', () => ({
|
||||
useDayNotes: () => mockDayNotesState,
|
||||
}));
|
||||
|
||||
vi.mock('../Weather/WeatherWidget', () => ({
|
||||
default: (props: { locationName?: string | null }) => (
|
||||
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
|
||||
),
|
||||
}));
|
||||
|
||||
// A stable toast object so tests can assert on the messages the sidebar raises.
|
||||
const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
|
||||
vi.mock('../shared/Toast', () => ({
|
||||
useToast: () => mockToast,
|
||||
}));
|
||||
|
||||
vi.mock('../../store/permissionsStore', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../store/permissionsStore')>()),
|
||||
useCanDo: () => () => true,
|
||||
}));
|
||||
|
||||
const trip = buildTrip({ id: 1, currency: 'EUR' });
|
||||
|
||||
function makeDefaultProps(overrides = {}) {
|
||||
return {
|
||||
tripId: 1,
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
categories: [],
|
||||
assignments: {},
|
||||
selectedDayId: null,
|
||||
selectedPlaceId: null,
|
||||
selectedAssignmentId: null,
|
||||
onSelectDay: vi.fn(),
|
||||
onPlaceClick: vi.fn(),
|
||||
onDayDetail: vi.fn(),
|
||||
accommodations: [],
|
||||
onReorder: vi.fn(),
|
||||
onUpdateDayTitle: vi.fn(),
|
||||
onRouteCalculated: vi.fn(),
|
||||
onAssignToDay: vi.fn(),
|
||||
onRemoveAssignment: vi.fn(),
|
||||
onEditPlace: vi.fn(),
|
||||
onDeletePlace: vi.fn(),
|
||||
reservations: [],
|
||||
onAddReservation: vi.fn(),
|
||||
onNavigateToFiles: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
clearExchangeRateCache();
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])));
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as never);
|
||||
});
|
||||
|
||||
describe('DayPlanSidebar', () => {
|
||||
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
|
||||
const description = 'A long description of the ridge route, the lake overlook, and the return path.';
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
tourPlaceIds: new Set([place.id]),
|
||||
plannedTourDurations: new Map([[place.id, times]]),
|
||||
assignments: { '10': [assignment] },
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
|
||||
expect(tourPreview).toHaveTextContent(description);
|
||||
expect(tourPreview.style.display).toBe('-webkit-box');
|
||||
expect(tourPreview.style.webkitLineClamp).toBe('2');
|
||||
expect(tourPreview.style.maxHeight).toBe('2.6em');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
|
||||
|
||||
view.rerender(
|
||||
<DayPlanSidebar
|
||||
{...{
|
||||
...props,
|
||||
plannedTourDurations: new Map([[place.id, { ...times, plannedTotalMinutes: 125, manuallyOverridden: true }]]),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
||||
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-planned-duration"]')).toBeNull();
|
||||
expect(screen.getByText(description).closest('.collab-note-md')).toHaveStyle({
|
||||
whiteSpace: 'nowrap',
|
||||
maxHeight: '1.3em',
|
||||
});
|
||||
});
|
||||
|
||||
it('RC-01: uses facet type and compact facts only for Tours, preserving Tours-off Place presentation', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description: 'Above the lake', tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const tour = {
|
||||
place_id: 45,
|
||||
name: place.name,
|
||||
tour_type: 'hike' as const,
|
||||
distance: 4,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 80,
|
||||
duration: 53,
|
||||
break_additional_minutes: 10,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
tourPlaceIds: new Set([45]),
|
||||
assignments: { '10': [assignment] },
|
||||
plannedTourDurations: new Map([
|
||||
[45, { tour, walkingMinutes: 53, breakMinutes: 10, plannedTotalMinutes: 63, manuallyOverridden: false }],
|
||||
]),
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
|
||||
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -80,9 +80,10 @@ import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation'
|
||||
import type { TourPlannedTimes } from '../Tours/tourPresentation'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import TourDayRowFacts, { TourDayRowIcon } from '../Tours/TourDayRowFacts'
|
||||
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
|
||||
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
|
||||
|
||||
export interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
@@ -2034,42 +2035,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{(dayRowTour || place.place_time || (tourTimes && (tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null)) || place.description || place.address || cat?.name) && (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{place.place_time && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour && <TourDayRowFacts tour={dayRowTour} />}
|
||||
{!dayRowTour && tourTimes?.walkingMinutes != null && (
|
||||
<TimePill>
|
||||
<span data-testid="trip-plan-tour-walking-time" aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
|
||||
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{!dayRowTour && tourTimes?.breakMinutes != null && (
|
||||
<TimePill>
|
||||
<span data-testid="trip-plan-tour-breaks" aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
|
||||
{t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{!dayRowTour && tourTimes?.plannedTotalMinutes != null && (
|
||||
<TimePill>
|
||||
<span data-testid="trip-plan-tour-planned-duration" aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
|
||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div data-testid={isTourPlace ? 'trip-plan-tour-description' : undefined} className="collab-note-md min-w-0 flex-1 text-content-faint" style={{ ...fs(10.5), overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: isTourPlace ? 'normal' : 'nowrap', lineHeight: 1.3, maxHeight: isTourPlace ? '2.6em' : '1.3em', ...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}) }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// The TOUR-PLANNER location search, wired through the planner page.
|
||||
//
|
||||
// The same harness as TripPlannerPage.wiring.test.tsx: the hook is replaced by
|
||||
// the shared fixture (TripPlannerPage.wiring.fixture.ts) and every child the
|
||||
// page mounts is a stub that records its props, so the map's focus and the
|
||||
// planner callbacks can be read and driven directly.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import React from 'react';
|
||||
import { buildUser } from '../../tests/helpers/factories';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { act, render, screen, waitFor } from '../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { assignmentsApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTripStore } from '../store/tripStore';
|
||||
import TripPlannerPage from './TripPlannerPage';
|
||||
import { baseState, type HookState } from './TripPlannerPage.wiring.fixture';
|
||||
|
||||
// ── Component stubs ───────────────────────────────────────────────────────────
|
||||
// Each stub records the props it was rendered with so the page's inline
|
||||
// callbacks can be invoked from the test.
|
||||
type Props = Record<string, unknown>;
|
||||
const captured: Record<string, Props> = {};
|
||||
const plannerActions = vi.hoisted(() => ({
|
||||
viewGpxTour: vi.fn(),
|
||||
addWaypoint: vi.fn(),
|
||||
setWaypointPosition: vi.fn(() => true),
|
||||
openTour: vi.fn(),
|
||||
startNewTour: vi.fn(),
|
||||
forgetDeletedTour: vi.fn((_placeId: number) => undefined),
|
||||
saveOutcome: null as unknown,
|
||||
mapFocusKey: 0,
|
||||
newDraftGeneration: 0,
|
||||
mode: { type: 'neutral' } as unknown,
|
||||
isSaving: false,
|
||||
waypoints: [] as unknown,
|
||||
routeProfileFocus: null as unknown,
|
||||
readOnlyGpxTour: null as unknown,
|
||||
readOnlyGpxAnalysis: null as unknown,
|
||||
}));
|
||||
|
||||
function stub(name: string, testId?: string) {
|
||||
return (props: Props) => {
|
||||
captured[name] = props;
|
||||
return testId ? React.createElement('div', { 'data-testid': testId }) : null;
|
||||
};
|
||||
}
|
||||
|
||||
type AnyProp = ((...args: unknown[]) => unknown) & Record<string, unknown>;
|
||||
|
||||
/** The last-rendered props of a stub, typed loosely so callbacks can be invoked. */
|
||||
function props(name: string): Record<string, AnyProp> {
|
||||
return (captured[name] ?? {}) as Record<string, AnyProp>;
|
||||
}
|
||||
|
||||
vi.mock('../components/Map/MapViewAuto', () => ({ MapViewAuto: stub('map', 'map-view') }));
|
||||
vi.mock('../components/Map/MapCompassPill', () => ({ MapCompassPill: stub('compass', 'compass-pill') }));
|
||||
const poiCategories = vi.hoisted(() => ({ core: [], plugin: [] }));
|
||||
vi.mock('../components/Map/PoiCategoryPill', () => ({ default: stub('poiPill', 'poi-pill') }));
|
||||
vi.mock('../components/Map/usePoiExplore', () => ({
|
||||
usePoiExplore: () => ({
|
||||
categories: poiCategories,
|
||||
active: [],
|
||||
pois: [],
|
||||
loadingKeys: [],
|
||||
errorKeys: [],
|
||||
moved: false,
|
||||
toggle: vi.fn(),
|
||||
searchArea: vi.fn(),
|
||||
onViewportChange: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Layout/Navbar', () => ({ default: stub('navbar', 'navbar') }));
|
||||
vi.mock('../components/shared/SlidingTabs', () => ({ default: stub('tabs', 'sliding-tabs'), TabLabel: () => null }));
|
||||
vi.mock('../components/shared/TripLoadingSplash', () => ({
|
||||
default: ({ title }: { title?: string }) =>
|
||||
React.createElement('div', { 'data-testid': 'splash', role: 'status' }, title ?? 'TREK'),
|
||||
}));
|
||||
vi.mock('../components/shared/ConfirmDialog', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('../components/Planner/DayPlanSidebar', () => ({ default: stub('dayPlan', 'day-plan-sidebar') }));
|
||||
vi.mock('../components/Planner/PlacesSidebar', () => ({ default: stub('places', 'places-sidebar') }));
|
||||
vi.mock('../components/Planner/PlaceInspector', () => ({ default: stub('inspector', 'place-inspector') }));
|
||||
vi.mock('../components/Planner/DayDetailPanel', () => ({ default: stub('dayDetail', 'day-detail-panel') }));
|
||||
vi.mock('../components/Planner/PlaceFormModal', () => ({ default: stub('placeForm') }));
|
||||
vi.mock('../components/Planner/ReservationModal', () => ({ ReservationModal: stub('reservationModal') }));
|
||||
vi.mock('../components/Planner/TransportModal', () => ({ TransportModal: stub('transportModal', 'transport-modal') }));
|
||||
vi.mock('../components/Planner/TransitJourneyModal', () => ({ default: stub('transitModal', 'transit-modal') }));
|
||||
vi.mock('../components/Planner/bookings/BookingDetailHost', () => ({
|
||||
BookingDetailPopup: stub('bookingDetail', 'booking-detail'),
|
||||
}));
|
||||
vi.mock('../components/Planner/BookingImportModal', () => ({ default: stub('bookingImport') }));
|
||||
vi.mock('../components/Planner/AirTrailImportModal', () => ({ default: stub('airtrailImport') }));
|
||||
vi.mock('../components/Planner/ReservationsPanel', () => ({
|
||||
default: stub('reservationsPanel', 'reservations-panel'),
|
||||
}));
|
||||
vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') }));
|
||||
vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') }));
|
||||
vi.mock('../components/Tours/planner/useTourPlanner', () => ({
|
||||
useTourPlanner: () => ({
|
||||
route: null,
|
||||
waypoints: plannerActions.waypoints,
|
||||
routeProfileFocus: plannerActions.routeProfileFocus,
|
||||
name: '',
|
||||
startNewTour: plannerActions.startNewTour,
|
||||
newTourConfirmationOpen: false,
|
||||
cancelNewTour: vi.fn(),
|
||||
elevationProfileExpanded: true,
|
||||
toggleElevationProfile: vi.fn(),
|
||||
newDraftGeneration: plannerActions.newDraftGeneration,
|
||||
mapFocusKey: plannerActions.mapFocusKey,
|
||||
mode: plannerActions.mode,
|
||||
isSaving: plannerActions.isSaving,
|
||||
selectedWaypointId: null,
|
||||
editingPlaceId: null,
|
||||
saveOutcome: plannerActions.saveOutcome,
|
||||
hasUnsavedChanges: false,
|
||||
setSelectedWaypointId: vi.fn(),
|
||||
mapBaseLayer: 'map',
|
||||
setMapBaseLayer: vi.fn(),
|
||||
openTour: plannerActions.openTour,
|
||||
viewGpxTour: plannerActions.viewGpxTour,
|
||||
addWaypoint: plannerActions.addWaypoint,
|
||||
setWaypointPosition: plannerActions.setWaypointPosition,
|
||||
closeGpxTour: vi.fn(),
|
||||
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
|
||||
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
|
||||
discard: vi.fn(),
|
||||
forgetDeletedTour: plannerActions.forgetDeletedTour,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }));
|
||||
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }));
|
||||
vi.mock('../components/Packing/PackingListPanel', () => ({ default: stub('packingPanel', 'packing-list-panel') }));
|
||||
vi.mock('../components/Packing/ApplyTemplateButton', () => ({ default: stub('applyTemplate', 'apply-template') }));
|
||||
vi.mock('../components/Packing/PackingExportMenu', () => ({ default: stub('exportMenu', 'export-menu') }));
|
||||
vi.mock('../components/Todo/TodoListPanel', () => ({ default: stub('todoPanel', 'todo-list-panel') }));
|
||||
vi.mock('../components/Files/FileManager', () => ({ default: stub('fileManager', 'file-manager') }));
|
||||
vi.mock('../components/Budget/CostsPanel', () => ({
|
||||
default: stub('costsPanel', 'costs-panel'),
|
||||
ExpenseModal: stub('expenseModal', 'expense-modal'),
|
||||
}));
|
||||
vi.mock('../components/Collab/CollabPanel', () => ({ default: stub('collabPanel', 'collab-panel') }));
|
||||
vi.mock('../components/Plugins/PluginFrame', () => ({ default: stub('pluginFrame', 'plugin-frame') }));
|
||||
|
||||
// ── useTripPlanner fixture ────────────────────────────────────────────────────
|
||||
|
||||
let hookState: HookState;
|
||||
|
||||
vi.mock('./tripPlanner/useTripPlanner', () => ({
|
||||
useTripPlanner: () => hookState,
|
||||
}));
|
||||
|
||||
function renderPage(overrides: HookState = {}) {
|
||||
hookState = { ...hookState, ...overrides };
|
||||
return render(<TripPlannerPage />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
for (const key of Object.keys(captured)) delete captured[key];
|
||||
plannerActions.viewGpxTour.mockReset();
|
||||
plannerActions.addWaypoint.mockReset();
|
||||
plannerActions.setWaypointPosition.mockReset().mockReturnValue(true);
|
||||
plannerActions.openTour.mockReset();
|
||||
plannerActions.startNewTour.mockReset();
|
||||
plannerActions.forgetDeletedTour.mockReset();
|
||||
plannerActions.saveOutcome = null;
|
||||
plannerActions.mapFocusKey = 0;
|
||||
plannerActions.newDraftGeneration = 0;
|
||||
plannerActions.mode = { type: 'neutral' };
|
||||
plannerActions.isSaving = false;
|
||||
plannerActions.waypoints = [];
|
||||
plannerActions.routeProfileFocus = null;
|
||||
plannerActions.readOnlyGpxTour = null;
|
||||
plannerActions.readOnlyGpxAnalysis = null;
|
||||
hookState = baseState();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser({ id: 5 }) });
|
||||
useTripStore.setState({ loadBudgetItems: vi.fn(async () => undefined) } as never);
|
||||
});
|
||||
|
||||
describe('TripPlannerPage — shell', () => {
|
||||
it('shows location search only in TOUR-PLANNER and focuses a result without writing planner data', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createAssignment = vi.spyOn(assignmentsApi, 'create');
|
||||
server.use(
|
||||
http.post('/api/maps/search', async ({ request }) => {
|
||||
const { query } = (await request.json()) as { query: string };
|
||||
expect(query).toBe('Kyoto');
|
||||
return HttpResponse.json({
|
||||
places: [{ name: 'Kyoto', address: 'Japan', lat: 35.0116, lng: 135.7681, osm_id: 'kyoto' }],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
places: [],
|
||||
});
|
||||
const input = screen.getByRole('combobox', { name: 'reservations.searchLocation' });
|
||||
const mapElement = screen.getByTestId('map-view');
|
||||
|
||||
await user.type(input, 'Kyoto');
|
||||
await screen.findByRole('option', { name: /Kyoto/ });
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
await waitFor(() => expect(props('map').focusPoints).toEqual([[35.0116, 135.7681]]));
|
||||
expect(props('map').focusKey).toBe('tour:0:search:1');
|
||||
expect(props('map').places).toEqual([]);
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
expect(props('map').onPlannerWaypointMove).toBeUndefined();
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
|
||||
expect(plannerActions.openTour).not.toHaveBeenCalled();
|
||||
expect(plannerActions.startNewTour).not.toHaveBeenCalled();
|
||||
|
||||
plannerActions.mode = { type: 'new-draft' };
|
||||
plannerActions.mapFocusKey = 1;
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('map').focusPoints).toEqual([]);
|
||||
expect(props('map').focusKey).toBe('tour:1');
|
||||
expect(props('map').onPlannerWaypointMove).toBe(plannerActions.setWaypointPosition);
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } });
|
||||
});
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77);
|
||||
act(() => {
|
||||
props('map').onPlannerWaypointMove('via-1', 35.03, 135.78);
|
||||
});
|
||||
expect(plannerActions.setWaypointPosition).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.setWaypointPosition).toHaveBeenCalledWith('via-1', 35.03, 135.78);
|
||||
expect(createAssignment).not.toHaveBeenCalled();
|
||||
expect(plannerActions.openTour).not.toHaveBeenCalled();
|
||||
createAssignment.mockRestore();
|
||||
|
||||
view.unmount();
|
||||
renderPage({ activeTab: 'plan', showPlaceForm: true });
|
||||
expect(screen.queryByRole('combobox', { name: 'reservations.searchLocation' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,12 @@ import React, { useState, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import type { CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import LocationSelect from '../components/Planner/LocationSelect';
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
|
||||
import PlacesToursModeSwitch from '../components/Tours/PlacesToursModeSwitch'
|
||||
import ToursSidebar from '../components/Tours/ToursSidebar'
|
||||
import { tourPlannedTimes as projectTourPlannedTimes } from '../components/Tours/tourPresentation'
|
||||
import TourDetailDialog from '../components/Tours/TourDetailDialog'
|
||||
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -57,6 +54,7 @@ import {
|
||||
import { LazyPanel } from './tripPlanner/LazyPanel'
|
||||
import { ListsContainer } from './tripPlanner/ListsContainer'
|
||||
import { PlanMapStage } from './tripPlanner/PlanMapStage'
|
||||
import { TourPlannerMapStage } from './tripPlanner/TourPlannerMapStage'
|
||||
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
|
||||
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
|
||||
import PanelResizeHandle from '../components/Planner/PanelResizeHandle'
|
||||
@@ -149,8 +147,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tourDetails,
|
||||
tourMap,
|
||||
locationSearch,
|
||||
plannedTourDurations,
|
||||
} = pageState
|
||||
const plannedTourDurations = new globalThis.Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))
|
||||
// Tablets run this very layout but cannot start an HTML5 drag with a finger,
|
||||
// so a long press stands in for one (#1616). Only where the pointer is
|
||||
// coarse — a hybrid laptop loads drag-drop-touch instead.
|
||||
@@ -640,48 +638,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
{activeTab === 'tour-planner' && enabledAddons.tours && !isMobile && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
places={[]}
|
||||
route={tourMap.route}
|
||||
followSelection={false}
|
||||
onMapClick={canPlaceEdit && !tourPlanner.isSaving && (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
|
||||
? ({ latlng }: { latlng: { lat: number; lng: number } }) => tourPlanner.addWaypoint(latlng.lat, latlng.lng)
|
||||
: undefined}
|
||||
tileUrl={mapTileUrl}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
focusPoints={tourMap.focusPoints}
|
||||
focusKey={tourMap.focusKey}
|
||||
plannerWaypoints={tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.waypoints : []}
|
||||
selectedPlannerWaypointId={tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.selectedWaypointId : null}
|
||||
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
|
||||
onPlannerWaypointMove={canPlaceEdit && !tourPlanner.isSaving && (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
|
||||
? tourPlanner.setWaypointPosition
|
||||
: undefined}
|
||||
routeProfileFocus={tourPlanner.routeProfileFocus}
|
||||
viewBaseLayer={tourPlanner.mapBaseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
<TourPlannerMapStage
|
||||
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
|
||||
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
|
||||
/>
|
||||
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
|
||||
<div style={{ position: 'absolute', top: 12, insetInline: 0, display: 'flex', justifyContent: 'center', pointerEvents: 'none', zIndex: 19 }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateX(${((leftHidden ? 56 : leftPanelPx) - (rightHidden ? 56 : rightPanelPx)) / 2}px)`,
|
||||
width: `min(360px, calc(100vw - ${(leftHidden ? 56 : leftPanelPx) + (rightHidden ? 56 : rightPanelPx) + 32}px))`,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<LocationSelect
|
||||
value={locationSearch.value}
|
||||
onChange={locationSearch.onChange}
|
||||
placeholder={t('reservations.searchLocation')}
|
||||
ariaLabel={t('reservations.searchLocation')}
|
||||
showSearchStatus
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
|
||||
<div style={{
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// The useTripPlanner fixture the TripPlannerPage wiring tests render against:
|
||||
// every field the page reads, set to the plain Plan-tab state, so a test only
|
||||
// overrides what it is about. Shared by TripPlannerPage.wiring.test.tsx and
|
||||
// TripPlannerPage.tours.wiring.test.tsx.
|
||||
import { vi } from 'vitest';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../tests/helpers/factories';
|
||||
import type { Day, Place } from '../types';
|
||||
|
||||
export const trip = buildTrip({ id: 42, title: 'Kyoto', currency: 'JPY' });
|
||||
export const day: Day = buildDay({ id: 7, trip_id: 42 });
|
||||
export const place: Place = buildPlace({ id: 1, name: 'Kiyomizu', lat: 34.9, lng: 135.7 });
|
||||
|
||||
export type HookState = Record<string, unknown>;
|
||||
|
||||
/** The plain Plan-tab state of every field the page reads from useTripPlanner. */
|
||||
export function baseState(): HookState {
|
||||
return { ...tripDataState(), ...roadtripState(), ...layoutState(), ...dialogState(), ...mapState() };
|
||||
}
|
||||
|
||||
/** The trip, its data and the rights and addons around it. */
|
||||
function tripDataState(): HookState {
|
||||
return {
|
||||
tripId: 42,
|
||||
navigate: vi.fn(),
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
t: (key: string) => key,
|
||||
language: 'en',
|
||||
placesPhotosEnabled: true,
|
||||
trip,
|
||||
days: [day],
|
||||
places: [place],
|
||||
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 0 })] },
|
||||
packingItems: [],
|
||||
todoItems: [],
|
||||
categories: [],
|
||||
reservations: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
selectedDayId: 7,
|
||||
isLoading: false,
|
||||
tripActions: {
|
||||
addFile: vi.fn(async () => undefined),
|
||||
deleteFile: vi.fn(async () => undefined),
|
||||
loadFiles: vi.fn(async () => undefined),
|
||||
updatePlace: vi.fn(async () => undefined),
|
||||
uploadPlaceImage: vi.fn(async () => undefined),
|
||||
ratePlace: vi.fn(async () => undefined),
|
||||
updateReservation: vi.fn(async () => undefined),
|
||||
updateTrip: vi.fn(async () => undefined),
|
||||
setSelectedDay: vi.fn(() => undefined),
|
||||
addCategory: vi.fn(async () => undefined),
|
||||
},
|
||||
can: vi.fn(() => true),
|
||||
canUploadFiles: true,
|
||||
pushUndo: vi.fn(() => undefined),
|
||||
undo: vi.fn(async () => undefined),
|
||||
canUndo: false,
|
||||
lastActionLabel: null,
|
||||
handleUndo: vi.fn(async () => undefined),
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true },
|
||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||
tripAccommodations: [],
|
||||
setTripAccommodations: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function roadtripState(): HookState {
|
||||
return {
|
||||
// Road trip mode off, which is the state every wiring test reads: the page keeps
|
||||
// its ordinary map, sidebar and route wiring, and the road-trip branches stay unmounted.
|
||||
// The shapes still have to be here, because the page reaches into routeAlternatives.open,
|
||||
// roadtripRoutes.lines and roadtripVias.byDay before it ever checks whether the mode is on.
|
||||
roadtripMode: false,
|
||||
toggleRoadtripMode: vi.fn(),
|
||||
roadtripActive: false,
|
||||
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
|
||||
roadtripCorridor: {
|
||||
visible: [],
|
||||
day: null,
|
||||
dayId: '',
|
||||
setDayId: vi.fn(),
|
||||
categories: [],
|
||||
toggleCategory: vi.fn(),
|
||||
widthKm: 5,
|
||||
setWidthKm: vi.fn(),
|
||||
search: vi.fn(),
|
||||
nameFilter: '',
|
||||
setNameFilter: vi.fn(),
|
||||
insertIndexFor: vi.fn(),
|
||||
},
|
||||
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
|
||||
askRefuel: vi.fn(),
|
||||
acceptRefuel: vi.fn(),
|
||||
followTrack: {
|
||||
dayId: null,
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
tracks: [],
|
||||
busy: false,
|
||||
round: 0,
|
||||
error: null,
|
||||
outcome: null,
|
||||
apply: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
viaCount: 0,
|
||||
available: false,
|
||||
},
|
||||
// The recorded-route overlay (#2279). Off and empty: the page reads
|
||||
// `dawarichTrail.track` unconditionally, so the fixture has to carry the
|
||||
// shape the hook returns even when the addon is not in play.
|
||||
dawarichEnabled: false,
|
||||
dawarichTrailShown: false,
|
||||
toggleDawarichTrail: vi.fn(),
|
||||
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
|
||||
roadtripViaCounts: {},
|
||||
stopDraft: null,
|
||||
setStopDraft: vi.fn(),
|
||||
saveStopDraft: vi.fn(async () => undefined),
|
||||
stopDraftToForm: vi.fn(),
|
||||
stopDraftDuplicate: null,
|
||||
reorderRoadtripStop: vi.fn(async () => undefined),
|
||||
roadtripVias: { byDay: {}, refresh: vi.fn(async () => undefined) },
|
||||
addRoadtripVia: vi.fn(async () => undefined),
|
||||
moveRoadtripVia: vi.fn(async () => undefined),
|
||||
removeRoadtripVia: vi.fn(async () => undefined),
|
||||
routeAlternatives: { open: null, close: vi.fn(), loading: false, ask: vi.fn(async () => undefined) },
|
||||
askRouteAlternatives: vi.fn(async () => undefined),
|
||||
chooseRouteAlternative: vi.fn(async () => undefined),
|
||||
alternativeOverlays: [],
|
||||
alternativeFocusPoints: [],
|
||||
stayDraft: null,
|
||||
setStayDraft: vi.fn(),
|
||||
setRoadtripStay: vi.fn(async () => undefined),
|
||||
highlightedAlternative: null,
|
||||
setHighlightedAlternative: vi.fn(),
|
||||
moveRoadtripStopToDay: vi.fn(async () => undefined),
|
||||
dropPoiOnRoute: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tabs, side panels and the selection. */
|
||||
function layoutState(): HookState {
|
||||
return {
|
||||
allowedFileTypes: 'pdf',
|
||||
tripMembers: [],
|
||||
setTripMembers: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
loadAccommodations: vi.fn(),
|
||||
TRANSPORT_TYPES: new Set(['flight', 'train']),
|
||||
TRIP_TABS: [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'transports', label: 'Transports' },
|
||||
{ id: 'buchungen', label: 'Bookings', shortLabel: 'Book' },
|
||||
{ id: 'listen', label: 'Lists' },
|
||||
{ id: 'finanzplan', label: 'Costs' },
|
||||
{ id: 'dateien', label: 'Files' },
|
||||
{ id: 'collab', label: 'Collab' },
|
||||
],
|
||||
activeTab: 'plan',
|
||||
setActiveTab: vi.fn(),
|
||||
handleTabChange: vi.fn(),
|
||||
leftWidth: 320,
|
||||
rightWidth: 320,
|
||||
leftCollapsed: false,
|
||||
rightCollapsed: false,
|
||||
setLeftCollapsed: vi.fn(),
|
||||
setRightCollapsed: vi.fn(),
|
||||
leftHidden: false,
|
||||
rightHidden: false,
|
||||
toggleLeft: vi.fn(),
|
||||
toggleRight: vi.fn(),
|
||||
narrowPanels: false,
|
||||
startResizeLeft: vi.fn(),
|
||||
startResizeRight: vi.fn(),
|
||||
nudgeLeft: vi.fn(),
|
||||
nudgeRight: vi.fn(),
|
||||
resizeMin: 200,
|
||||
resizeMax: 520,
|
||||
selectedPlaceId: null,
|
||||
selectedAssignmentId: null,
|
||||
setSelectedPlaceId: vi.fn(),
|
||||
selectAssignment: vi.fn(),
|
||||
showDayDetail: null,
|
||||
setShowDayDetail: vi.fn(),
|
||||
dayDetailCollapsed: false,
|
||||
setDayDetailCollapsed: vi.fn(),
|
||||
showPlaceForm: false,
|
||||
setShowPlaceForm: vi.fn(),
|
||||
setPlaceFormDayId: vi.fn(),
|
||||
editingPlace: null,
|
||||
setEditingPlace: vi.fn(),
|
||||
prefillCoords: null,
|
||||
setPrefillCoords: vi.fn(),
|
||||
editingAssignmentId: null,
|
||||
setEditingAssignmentId: vi.fn(),
|
||||
serviceStopMode: null,
|
||||
setServiceStopForm: vi.fn(),
|
||||
openManualRoadtripStop: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The modals, the booking flows and the delete confirmations. */
|
||||
function dialogState(): HookState {
|
||||
return {
|
||||
showTripForm: false,
|
||||
setShowTripForm: vi.fn(),
|
||||
showMembersModal: false,
|
||||
setShowMembersModal: vi.fn(),
|
||||
showReservationModal: false,
|
||||
setShowReservationModal: vi.fn(),
|
||||
editingReservation: null,
|
||||
setEditingReservation: vi.fn(),
|
||||
showBookingImport: false,
|
||||
setShowBookingImport: vi.fn(),
|
||||
bookingImportKind: 'bookings' as const,
|
||||
setBookingImportKind: vi.fn(),
|
||||
bookingImportAvailable: true,
|
||||
airTrailAvailable: true,
|
||||
showAirTrailImport: false,
|
||||
setShowAirTrailImport: vi.fn(),
|
||||
bookingForAssignmentId: null,
|
||||
setBookingForAssignmentId: vi.fn(),
|
||||
showTransportModal: false,
|
||||
setShowTransportModal: vi.fn(),
|
||||
editingTransport: null,
|
||||
setEditingTransport: vi.fn(),
|
||||
transportModalDayId: null,
|
||||
setTransportModalDayId: vi.fn(),
|
||||
transportModalAutomated: false,
|
||||
setTransportModalAutomated: vi.fn(),
|
||||
transitPrefill: null,
|
||||
setTransitPrefill: vi.fn(),
|
||||
transitJourney: null,
|
||||
setTransitJourney: vi.fn(),
|
||||
openTransportEditor: vi.fn(),
|
||||
changeTransitRoute: vi.fn(),
|
||||
bookingDetail: null,
|
||||
openBookingDetail: vi.fn(),
|
||||
openBookingFromDayList: vi.fn(),
|
||||
closeBookingDetail: vi.fn(),
|
||||
bookingDetailEditor: undefined,
|
||||
bookingDetailChangeRoute: undefined,
|
||||
showBookingOnMap: vi.fn(),
|
||||
isBookingOnMap: vi.fn(() => false),
|
||||
reservationPrefill: null,
|
||||
transportPrefill: null,
|
||||
importReviewActive: false,
|
||||
advanceImportReview: vi.fn(),
|
||||
routeShown: false,
|
||||
setRouteShown: vi.fn(),
|
||||
transitRoutesShown: false,
|
||||
routeProfile: 'driving',
|
||||
setRouteProfile: vi.fn(),
|
||||
routeVias: [],
|
||||
fitKey: 0,
|
||||
setFitKey: vi.fn(),
|
||||
mobileSidebarOpen: null,
|
||||
setMobileSidebarOpen: vi.fn(),
|
||||
mobilePlanScrollTopRef: { current: 0 },
|
||||
mobilePlacesScrollTopRef: { current: 0 },
|
||||
deletePlaceId: null,
|
||||
setDeletePlaceId: vi.fn(),
|
||||
deletePlaceIds: null,
|
||||
setDeletePlaceIds: vi.fn(),
|
||||
stayRelease: null,
|
||||
setStayRelease: vi.fn(),
|
||||
confirmStayRelease: vi.fn(async () => undefined),
|
||||
visibleConnections: [],
|
||||
toggleConnection: vi.fn(),
|
||||
allConnectionsShown: false,
|
||||
toggleAllConnections: vi.fn(),
|
||||
mapTransportDetail: null,
|
||||
setMapTransportDetail: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The map, the route and the hook handlers, plus Tours. */
|
||||
function mapState(): HookState {
|
||||
return {
|
||||
isMobile: false,
|
||||
isTouch: false,
|
||||
expandedDayIds: null,
|
||||
setExpandedDayIds: vi.fn(),
|
||||
mapPlaces: [place],
|
||||
route: null,
|
||||
routeSegments: [],
|
||||
routeInfo: null,
|
||||
setRoute: vi.fn(),
|
||||
setRouteInfo: vi.fn(),
|
||||
updateRouteForDay: vi.fn(),
|
||||
handleSelectDay: vi.fn(),
|
||||
handlePlaceClick: vi.fn(),
|
||||
handleMarkerClick: vi.fn(),
|
||||
handleMapClick: vi.fn(),
|
||||
handleMapContextMenu: vi.fn(),
|
||||
openAddPlaceFromPoi: vi.fn(),
|
||||
handleSavePlace: vi.fn(async () => undefined),
|
||||
openPlaceEditor: vi.fn(),
|
||||
handleDeletePlace: vi.fn(),
|
||||
handleDeleteTour: vi.fn(),
|
||||
confirmDeletePlace: vi.fn(async () => undefined),
|
||||
confirmDeletePlaces: vi.fn(async () => undefined),
|
||||
confirmChangeCategory: vi.fn(async () => undefined),
|
||||
handleAssignToDay: vi.fn(async () => undefined),
|
||||
handleRemoveAssignment: vi.fn(async () => undefined),
|
||||
handleReorder: vi.fn(),
|
||||
handleReorderDays: vi.fn(),
|
||||
handleAddDay: vi.fn(),
|
||||
handleUpdateDayTitle: vi.fn(async () => undefined),
|
||||
handleSaveReservation: vi.fn(async () => ({ id: 1 })),
|
||||
handleSaveTransport: vi.fn(async () => ({ id: 2 })),
|
||||
handleDeleteReservation: vi.fn(async () => undefined),
|
||||
selectedPlace: null,
|
||||
dayOrderMap: {},
|
||||
dayPlaces: [],
|
||||
toursEnabled: false,
|
||||
toursMode: true,
|
||||
setToursMode: vi.fn(),
|
||||
tours: [],
|
||||
toursLoading: false,
|
||||
tourDataReady: true,
|
||||
tourPlaceIds: new Set<number>(),
|
||||
reloadTourPlaceIds: vi.fn(async () => undefined),
|
||||
upsertTour: vi.fn(),
|
||||
selectedTour: null,
|
||||
mapTileUrl: 'https://tiles/{z}/{x}/{y}.png',
|
||||
fontStyle: { fontFamily: 'var(--font-system)' },
|
||||
splashDone: true,
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,9 @@
|
||||
// Here the hook is replaced by a controllable fixture so every branch of the
|
||||
// layout — panels, portals, tabs, modals — and every inline callback the page
|
||||
// hands its children can be driven directly.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import React from 'react';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildDay,
|
||||
buildPackingItem,
|
||||
buildPlace,
|
||||
buildReservation,
|
||||
@@ -18,16 +15,16 @@ import {
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../tests/helpers/factories';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { assignmentsApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTripStore } from '../store/tripStore';
|
||||
import type { Day, Place, Reservation, Settings } from '../types';
|
||||
import type { Reservation, Settings } from '../types';
|
||||
import { analyzeRouteGeometry } from '../utils/routeGeometry';
|
||||
import TripPlannerPage from './TripPlannerPage';
|
||||
import { baseState, day, place, trip, type HookState } from './TripPlannerPage.wiring.fixture';
|
||||
|
||||
// ── Component stubs ───────────────────────────────────────────────────────────
|
||||
// Each stub records the props it was rendered with so the page's inline
|
||||
@@ -165,277 +162,10 @@ vi.mock('../components/Budget/CostsPanel', () => ({
|
||||
vi.mock('../components/Collab/CollabPanel', () => ({ default: stub('collabPanel', 'collab-panel') }))
|
||||
vi.mock('../components/Plugins/PluginFrame', () => ({ default: stub('pluginFrame', 'plugin-frame') }))
|
||||
|
||||
// ── useTripPlanner fixture ────────────────────────────────────────────────────
|
||||
// ── useTripPlanner fixture (TripPlannerPage.wiring.fixture.ts) ───────────────────
|
||||
|
||||
const trip = buildTrip({ id: 42, title: 'Kyoto', currency: 'JPY' })
|
||||
const day: Day = buildDay({ id: 7, trip_id: 42 })
|
||||
const place: Place = buildPlace({ id: 1, name: 'Kiyomizu', lat: 34.9, lng: 135.7 })
|
||||
|
||||
type HookState = Record<string, unknown>
|
||||
let hookState: HookState
|
||||
|
||||
function baseState(): HookState {
|
||||
return {
|
||||
tripId: 42,
|
||||
navigate: vi.fn(),
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
t: (key: string) => key,
|
||||
language: 'en',
|
||||
placesPhotosEnabled: true,
|
||||
trip,
|
||||
days: [day],
|
||||
places: [place],
|
||||
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 0 })] },
|
||||
packingItems: [],
|
||||
todoItems: [],
|
||||
categories: [],
|
||||
reservations: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
selectedDayId: 7,
|
||||
isLoading: false,
|
||||
tripActions: {
|
||||
addFile: vi.fn(async () => undefined),
|
||||
deleteFile: vi.fn(async () => undefined),
|
||||
loadFiles: vi.fn(async () => undefined),
|
||||
updatePlace: vi.fn(async () => undefined),
|
||||
uploadPlaceImage: vi.fn(async () => undefined),
|
||||
ratePlace: vi.fn(async () => undefined),
|
||||
updateReservation: vi.fn(async () => undefined),
|
||||
updateTrip: vi.fn(async () => undefined),
|
||||
setSelectedDay: vi.fn(() => undefined),
|
||||
addCategory: vi.fn(async () => undefined),
|
||||
},
|
||||
can: vi.fn(() => true),
|
||||
canUploadFiles: true,
|
||||
pushUndo: vi.fn(() => undefined),
|
||||
undo: vi.fn(async () => undefined),
|
||||
canUndo: false,
|
||||
lastActionLabel: null,
|
||||
handleUndo: vi.fn(async () => undefined),
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true },
|
||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||
tripAccommodations: [],
|
||||
setTripAccommodations: vi.fn(),
|
||||
// Road trip mode off, which is the state every assertion below reads: the page keeps
|
||||
// its ordinary map, sidebar and route wiring, and the road-trip branches stay unmounted.
|
||||
// The shapes still have to be here, because the page reaches into routeAlternatives.open,
|
||||
// roadtripRoutes.lines and roadtripVias.byDay before it ever checks whether the mode is on.
|
||||
roadtripMode: false,
|
||||
toggleRoadtripMode: vi.fn(),
|
||||
roadtripActive: false,
|
||||
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
|
||||
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
|
||||
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
|
||||
askRefuel: vi.fn(),
|
||||
acceptRefuel: vi.fn(),
|
||||
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
|
||||
// The recorded-route overlay (#2279). Off and empty: the page reads
|
||||
// `dawarichTrail.track` unconditionally, so the fixture has to carry the
|
||||
// shape the hook returns even when the addon is not in play.
|
||||
dawarichEnabled: false,
|
||||
dawarichTrailShown: false,
|
||||
toggleDawarichTrail: vi.fn(),
|
||||
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
|
||||
roadtripViaCounts: {},
|
||||
stopDraft: null,
|
||||
setStopDraft: vi.fn(),
|
||||
saveStopDraft: vi.fn(async () => undefined),
|
||||
stopDraftToForm: vi.fn(),
|
||||
stopDraftDuplicate: null,
|
||||
reorderRoadtripStop: vi.fn(async () => undefined),
|
||||
roadtripVias: { byDay: {}, refresh: vi.fn(async () => undefined) },
|
||||
addRoadtripVia: vi.fn(async () => undefined),
|
||||
moveRoadtripVia: vi.fn(async () => undefined),
|
||||
removeRoadtripVia: vi.fn(async () => undefined),
|
||||
routeAlternatives: { open: null, close: vi.fn(), loading: false, ask: vi.fn(async () => undefined) },
|
||||
askRouteAlternatives: vi.fn(async () => undefined),
|
||||
chooseRouteAlternative: vi.fn(async () => undefined),
|
||||
alternativeOverlays: [],
|
||||
alternativeFocusPoints: [],
|
||||
stayDraft: null,
|
||||
setStayDraft: vi.fn(),
|
||||
setRoadtripStay: vi.fn(async () => undefined),
|
||||
highlightedAlternative: null,
|
||||
setHighlightedAlternative: vi.fn(),
|
||||
moveRoadtripStopToDay: vi.fn(async () => undefined),
|
||||
dropPoiOnRoute: vi.fn(async () => undefined),
|
||||
allowedFileTypes: 'pdf',
|
||||
tripMembers: [],
|
||||
setTripMembers: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
loadAccommodations: vi.fn(),
|
||||
TRANSPORT_TYPES: new Set(['flight', 'train']),
|
||||
TRIP_TABS: [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'transports', label: 'Transports' },
|
||||
{ id: 'buchungen', label: 'Bookings', shortLabel: 'Book' },
|
||||
{ id: 'listen', label: 'Lists' },
|
||||
{ id: 'finanzplan', label: 'Costs' },
|
||||
{ id: 'dateien', label: 'Files' },
|
||||
{ id: 'collab', label: 'Collab' },
|
||||
],
|
||||
activeTab: 'plan',
|
||||
setActiveTab: vi.fn(),
|
||||
handleTabChange: vi.fn(),
|
||||
leftWidth: 320,
|
||||
rightWidth: 320,
|
||||
leftCollapsed: false,
|
||||
rightCollapsed: false,
|
||||
setLeftCollapsed: vi.fn(),
|
||||
setRightCollapsed: vi.fn(),
|
||||
leftHidden: false,
|
||||
rightHidden: false,
|
||||
toggleLeft: vi.fn(),
|
||||
toggleRight: vi.fn(),
|
||||
narrowPanels: false,
|
||||
startResizeLeft: vi.fn(),
|
||||
startResizeRight: vi.fn(),
|
||||
nudgeLeft: vi.fn(),
|
||||
nudgeRight: vi.fn(),
|
||||
resizeMin: 200,
|
||||
resizeMax: 520,
|
||||
selectedPlaceId: null,
|
||||
selectedAssignmentId: null,
|
||||
setSelectedPlaceId: vi.fn(),
|
||||
selectAssignment: vi.fn(),
|
||||
showDayDetail: null,
|
||||
setShowDayDetail: vi.fn(),
|
||||
dayDetailCollapsed: false,
|
||||
setDayDetailCollapsed: vi.fn(),
|
||||
showPlaceForm: false,
|
||||
setShowPlaceForm: vi.fn(),
|
||||
setPlaceFormDayId: vi.fn(),
|
||||
editingPlace: null,
|
||||
setEditingPlace: vi.fn(),
|
||||
prefillCoords: null,
|
||||
setPrefillCoords: vi.fn(),
|
||||
editingAssignmentId: null,
|
||||
setEditingAssignmentId: vi.fn(),
|
||||
serviceStopMode: null,
|
||||
setServiceStopForm: vi.fn(),
|
||||
openManualRoadtripStop: vi.fn(),
|
||||
showTripForm: false,
|
||||
setShowTripForm: vi.fn(),
|
||||
showMembersModal: false,
|
||||
setShowMembersModal: vi.fn(),
|
||||
showReservationModal: false,
|
||||
setShowReservationModal: vi.fn(),
|
||||
editingReservation: null,
|
||||
setEditingReservation: vi.fn(),
|
||||
showBookingImport: false,
|
||||
setShowBookingImport: vi.fn(),
|
||||
bookingImportKind: 'bookings' as const,
|
||||
setBookingImportKind: vi.fn(),
|
||||
bookingImportAvailable: true,
|
||||
airTrailAvailable: true,
|
||||
showAirTrailImport: false,
|
||||
setShowAirTrailImport: vi.fn(),
|
||||
bookingForAssignmentId: null,
|
||||
setBookingForAssignmentId: vi.fn(),
|
||||
showTransportModal: false,
|
||||
setShowTransportModal: vi.fn(),
|
||||
editingTransport: null,
|
||||
setEditingTransport: vi.fn(),
|
||||
transportModalDayId: null,
|
||||
setTransportModalDayId: vi.fn(),
|
||||
transportModalAutomated: false,
|
||||
setTransportModalAutomated: vi.fn(),
|
||||
transitPrefill: null,
|
||||
setTransitPrefill: vi.fn(),
|
||||
transitJourney: null,
|
||||
setTransitJourney: vi.fn(),
|
||||
openTransportEditor: vi.fn(),
|
||||
changeTransitRoute: vi.fn(),
|
||||
bookingDetail: null,
|
||||
openBookingDetail: vi.fn(),
|
||||
openBookingFromDayList: vi.fn(),
|
||||
closeBookingDetail: vi.fn(),
|
||||
bookingDetailEditor: undefined,
|
||||
bookingDetailChangeRoute: undefined,
|
||||
showBookingOnMap: vi.fn(),
|
||||
isBookingOnMap: vi.fn(() => false),
|
||||
reservationPrefill: null,
|
||||
transportPrefill: null,
|
||||
importReviewActive: false,
|
||||
advanceImportReview: vi.fn(),
|
||||
routeShown: false,
|
||||
setRouteShown: vi.fn(),
|
||||
transitRoutesShown: false,
|
||||
routeProfile: 'driving',
|
||||
setRouteProfile: vi.fn(),
|
||||
routeVias: [],
|
||||
fitKey: 0,
|
||||
setFitKey: vi.fn(),
|
||||
mobileSidebarOpen: null,
|
||||
setMobileSidebarOpen: vi.fn(),
|
||||
mobilePlanScrollTopRef: { current: 0 },
|
||||
mobilePlacesScrollTopRef: { current: 0 },
|
||||
deletePlaceId: null,
|
||||
setDeletePlaceId: vi.fn(),
|
||||
deletePlaceIds: null,
|
||||
setDeletePlaceIds: vi.fn(),
|
||||
stayRelease: null,
|
||||
setStayRelease: vi.fn(),
|
||||
confirmStayRelease: vi.fn(async () => undefined),
|
||||
visibleConnections: [],
|
||||
toggleConnection: vi.fn(),
|
||||
allConnectionsShown: false,
|
||||
toggleAllConnections: vi.fn(),
|
||||
mapTransportDetail: null,
|
||||
setMapTransportDetail: vi.fn(),
|
||||
isMobile: false,
|
||||
isTouch: false,
|
||||
expandedDayIds: null,
|
||||
setExpandedDayIds: vi.fn(),
|
||||
mapPlaces: [place],
|
||||
route: null,
|
||||
routeSegments: [],
|
||||
routeInfo: null,
|
||||
setRoute: vi.fn(),
|
||||
setRouteInfo: vi.fn(),
|
||||
updateRouteForDay: vi.fn(),
|
||||
handleSelectDay: vi.fn(),
|
||||
handlePlaceClick: vi.fn(),
|
||||
handleMarkerClick: vi.fn(),
|
||||
handleMapClick: vi.fn(),
|
||||
handleMapContextMenu: vi.fn(),
|
||||
openAddPlaceFromPoi: vi.fn(),
|
||||
handleSavePlace: vi.fn(async () => undefined),
|
||||
openPlaceEditor: vi.fn(),
|
||||
handleDeletePlace: vi.fn(),
|
||||
handleDeleteTour: vi.fn(),
|
||||
confirmDeletePlace: vi.fn(async () => undefined),
|
||||
confirmDeletePlaces: vi.fn(async () => undefined),
|
||||
confirmChangeCategory: vi.fn(async () => undefined),
|
||||
handleAssignToDay: vi.fn(async () => undefined),
|
||||
handleRemoveAssignment: vi.fn(async () => undefined),
|
||||
handleReorder: vi.fn(),
|
||||
handleReorderDays: vi.fn(),
|
||||
handleAddDay: vi.fn(),
|
||||
handleUpdateDayTitle: vi.fn(async () => undefined),
|
||||
handleSaveReservation: vi.fn(async () => ({ id: 1 })),
|
||||
handleSaveTransport: vi.fn(async () => ({ id: 2 })),
|
||||
handleDeleteReservation: vi.fn(async () => undefined),
|
||||
selectedPlace: null,
|
||||
dayOrderMap: {},
|
||||
dayPlaces: [],
|
||||
toursEnabled: false,
|
||||
toursMode: true,
|
||||
setToursMode: vi.fn(),
|
||||
tours: [],
|
||||
toursLoading: false,
|
||||
tourDataReady: true,
|
||||
tourPlaceIds: new Set<number>(),
|
||||
reloadTourPlaceIds: vi.fn(async () => undefined),
|
||||
upsertTour: vi.fn(),
|
||||
selectedTour: null,
|
||||
mapTileUrl: 'https://tiles/{z}/{x}/{y}.png',
|
||||
fontStyle: { fontFamily: 'var(--font-system)' },
|
||||
splashDone: true,
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('./tripPlanner/useTripPlanner', () => ({
|
||||
useTripPlanner: () => hookState,
|
||||
}))
|
||||
@@ -577,70 +307,6 @@ describe('TripPlannerPage — shell', () => {
|
||||
expect(plannerActions.openTour).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows location search only in TOUR-PLANNER and focuses a result without writing planner data', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createAssignment = vi.spyOn(assignmentsApi, 'create');
|
||||
server.use(
|
||||
http.post('/api/maps/search', async ({ request }) => {
|
||||
const { query } = (await request.json()) as { query: string };
|
||||
expect(query).toBe('Kyoto');
|
||||
return HttpResponse.json({
|
||||
places: [{ name: 'Kyoto', address: 'Japan', lat: 35.0116, lng: 135.7681, osm_id: 'kyoto' }],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const view = renderPage({
|
||||
activeTab: 'tour-planner',
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
|
||||
toursEnabled: true,
|
||||
places: [],
|
||||
});
|
||||
const input = screen.getByRole('combobox', { name: 'reservations.searchLocation' });
|
||||
const mapElement = screen.getByTestId('map-view');
|
||||
|
||||
await user.type(input, 'Kyoto');
|
||||
await screen.findByRole('option', { name: /Kyoto/ });
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
await waitFor(() => expect(props('map').focusPoints).toEqual([[35.0116, 135.7681]]));
|
||||
expect(props('map').focusKey).toBe('tour:0:search:1');
|
||||
expect(props('map').places).toEqual([]);
|
||||
expect(props('map').plannerWaypoints).toEqual([]);
|
||||
expect(props('map').onPlannerWaypointMove).toBeUndefined();
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
|
||||
expect(plannerActions.openTour).not.toHaveBeenCalled();
|
||||
expect(plannerActions.startNewTour).not.toHaveBeenCalled();
|
||||
|
||||
plannerActions.mode = { type: 'new-draft' };
|
||||
plannerActions.mapFocusKey = 1;
|
||||
view.rerender(<TripPlannerPage />);
|
||||
expect(props('map').focusPoints).toEqual([]);
|
||||
expect(props('map').focusKey).toBe('tour:1');
|
||||
expect(props('map').onPlannerWaypointMove).toBe(plannerActions.setWaypointPosition);
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } });
|
||||
});
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77);
|
||||
act(() => {
|
||||
props('map').onPlannerWaypointMove('via-1', 35.03, 135.78);
|
||||
});
|
||||
expect(plannerActions.setWaypointPosition).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.setWaypointPosition).toHaveBeenCalledWith('via-1', 35.03, 135.78);
|
||||
expect(createAssignment).not.toHaveBeenCalled();
|
||||
expect(plannerActions.openTour).not.toHaveBeenCalled();
|
||||
createAssignment.mockRestore();
|
||||
|
||||
view.unmount();
|
||||
renderPage({ activeTab: 'plan', showPlaceForm: true });
|
||||
expect(screen.queryByRole('combobox', { name: 'reservations.searchLocation' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves the visible TOUR-PLANNER camera when the Plan a Tour button starts an empty draft', async () => {
|
||||
const place = buildPlace({ id: 81, lat: 35.01, lng: 135.76 })
|
||||
mockStartNewTourTransition()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import type { useTripPlanner } from './useTripPlanner';
|
||||
import type { useTripPlannerPage } from './useTripPlannerPage';
|
||||
|
||||
type Planner = ReturnType<typeof useTripPlanner>;
|
||||
type PageState = ReturnType<typeof useTripPlannerPage>;
|
||||
|
||||
export interface TourPlannerMapStageProps
|
||||
extends
|
||||
Pick<Planner, 'tripId' | 'mapTileUrl' | 'leftHidden' | 'rightHidden' | 't'>,
|
||||
Pick<PageState, 'tourPlanner' | 'tourMap' | 'locationSearch'> {
|
||||
canPlaceEdit: boolean;
|
||||
leftPanelPx: number;
|
||||
rightPanelPx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour planner tab's map and the location search floating over it. It returns a
|
||||
* fragment so both sit straight in the tab container, ahead of the side panels, with
|
||||
* the stacking they had when this lived in the page.
|
||||
*/
|
||||
export function TourPlannerMapStage({
|
||||
tripId,
|
||||
mapTileUrl,
|
||||
leftHidden,
|
||||
rightHidden,
|
||||
t,
|
||||
tourPlanner,
|
||||
tourMap,
|
||||
locationSearch,
|
||||
canPlaceEdit,
|
||||
leftPanelPx,
|
||||
rightPanelPx,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
places={[]}
|
||||
route={tourMap.route}
|
||||
followSelection={false}
|
||||
onMapClick={
|
||||
canPlaceEdit &&
|
||||
!tourPlanner.isSaving &&
|
||||
(tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
|
||||
? ({ latlng }: { latlng: { lat: number; lng: number } }) => tourPlanner.addWaypoint(latlng.lat, latlng.lng)
|
||||
: undefined
|
||||
}
|
||||
tileUrl={mapTileUrl}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
focusPoints={tourMap.focusPoints}
|
||||
focusKey={tourMap.focusKey}
|
||||
plannerWaypoints={
|
||||
tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.waypoints : []
|
||||
}
|
||||
selectedPlannerWaypointId={
|
||||
tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'
|
||||
? tourPlanner.selectedWaypointId
|
||||
: null
|
||||
}
|
||||
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
|
||||
onPlannerWaypointMove={
|
||||
canPlaceEdit &&
|
||||
!tourPlanner.isSaving &&
|
||||
(tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
|
||||
? tourPlanner.setWaypointPosition
|
||||
: undefined
|
||||
}
|
||||
routeProfileFocus={tourPlanner.routeProfileFocus}
|
||||
viewBaseLayer={tourPlanner.mapBaseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
/>
|
||||
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
insetInline: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 19,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateX(${((leftHidden ? 56 : leftPanelPx) - (rightHidden ? 56 : rightPanelPx)) / 2}px)`,
|
||||
width: `min(360px, calc(100vw - ${(leftHidden ? 56 : leftPanelPx) + (rightHidden ? 56 : rightPanelPx) + 32}px))`,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<LocationSelect
|
||||
value={locationSearch.value}
|
||||
onChange={locationSearch.onChange}
|
||||
placeholder={t('reservations.searchLocation')}
|
||||
ariaLabel={t('reservations.searchLocation')}
|
||||
showSearchStatus
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { TourListItem } from '@trek/shared'
|
||||
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
|
||||
import { placeActions } from '../../components/Planner/usePlaceActions'
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
|
||||
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
|
||||
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
|
||||
import { useMapLocationSearch } from './useMapLocationSearch'
|
||||
import { useTripPlanner } from './useTripPlanner'
|
||||
@@ -19,6 +20,7 @@ export function useTripPlannerPage() {
|
||||
setSelectedPlaceId,
|
||||
t,
|
||||
toast,
|
||||
tours,
|
||||
trip,
|
||||
tripActions,
|
||||
tripId,
|
||||
@@ -101,6 +103,9 @@ export function useTripPlannerPage() {
|
||||
reportViewport(bounds)
|
||||
}, [reportViewport])
|
||||
|
||||
// Each tour's planned times keyed by its place, for the day plan's tour rows.
|
||||
const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))
|
||||
|
||||
const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => {
|
||||
tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null
|
||||
setSelectedPlaceId(tour?.place_id ?? null)
|
||||
@@ -116,5 +121,6 @@ export function useTripPlannerPage() {
|
||||
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
|
||||
tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport },
|
||||
locationSearch,
|
||||
plannedTourDurations,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user