From 779506163091fde059191eedce0c400532bbe521 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 14 Oct 2026 10:00:08 +0200 Subject: [PATCH] refactor(tours): move the planner additions out of the map, sidebar and page files that grew past their size baseline --- client/scripts/i18n-unused-baseline.json | 2 +- client/scripts/size-baseline.json | 9 +- client/src/components/Map/MapView.tsx | 72 +-- .../Map/MapViewGL.plannerWaypoints.test.tsx | 480 ++++++++++++++++++ client/src/components/Map/MapViewGL.test.tsx | 181 ------- client/src/components/Map/MapViewGL.tsx | 66 +-- .../components/Map/PlannerWaypointMarker.tsx | 84 +++ .../Map/usePlannerWaypointMarkersGL.ts | 115 +++++ .../Planner/DayPlanPlaceSubline.tsx | 108 ++++ .../Planner/DayPlanSidebar.test.tsx | 52 -- .../Planner/DayPlanSidebar.tours.test.tsx | 244 +++++++++ .../src/components/Planner/DayPlanSidebar.tsx | 42 +- .../TripPlannerPage.tours.wiring.test.tsx | 250 +++++++++ client/src/pages/TripPlannerPage.tsx | 51 +- .../pages/TripPlannerPage.wiring.fixture.ts | 330 ++++++++++++ .../src/pages/TripPlannerPage.wiring.test.tsx | 340 +------------ .../pages/tripPlanner/TourPlannerMapStage.tsx | 107 ++++ .../pages/tripPlanner/useTripPlannerPage.ts | 6 + 18 files changed, 1763 insertions(+), 776 deletions(-) create mode 100644 client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx create mode 100644 client/src/components/Map/PlannerWaypointMarker.tsx create mode 100644 client/src/components/Map/usePlannerWaypointMarkersGL.ts create mode 100644 client/src/components/Planner/DayPlanPlaceSubline.tsx create mode 100644 client/src/components/Planner/DayPlanSidebar.tours.test.tsx create mode 100644 client/src/pages/TripPlannerPage.tours.wiring.test.tsx create mode 100644 client/src/pages/TripPlannerPage.wiring.fixture.ts create mode 100644 client/src/pages/tripPlanner/TourPlannerMapStage.tsx diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index cdcb629d9..b5db1317a 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -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, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index cc1779105..61a231e3d 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -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, diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 41362a9ea..73707f53c 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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: `${index + 1}`, - }) -} - -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(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 ( - - {index + 1} - - ) +/** 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} /> ))} diff --git a/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx b/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx new file mode 100644 index 000000000..91f89b511 --- /dev/null +++ b/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx @@ -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 }>, + }; + 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, 'gl'>) { + return ; +} + +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( + + ); + 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( + + ); + await act(async () => {}); + expect(glMap.fitBounds).not.toHaveBeenCalled(); + expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions); + + rerender( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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(); + }); +}); diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 359e3c094..d05411e1c 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -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 = {}) { } 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( - - ); - 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( - - ); - await act(async () => {}); - expect(glMap.fitBounds).not.toHaveBeenCalled(); - expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions); - - rerender( - - ); - 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( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - 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 }) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 2c9ef77f1..b55a5035e 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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([]) const plannerWaypointPinsRef = useRef([]) - const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove }) - useLayoutEffect(() => { - plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove } - }, [onPlannerWaypointClick, onPlannerWaypointMove]) - const plannerWaypointsDraggable = !!onPlannerWaypointMove const profileFocusPinRef = useRef(null) /** The drive-time pills on the offered routes; same treatment. */ const altLabelsRef = useRef([]) @@ -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() diff --git a/client/src/components/Map/PlannerWaypointMarker.tsx b/client/src/components/Map/PlannerWaypointMarker.tsx new file mode 100644 index 000000000..922dd5af5 --- /dev/null +++ b/client/src/components/Map/PlannerWaypointMarker.tsx @@ -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: `${index + 1}`, + }); +} + +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(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 ( + + + {index + 1} + + + ); +} diff --git a/client/src/components/Map/usePlannerWaypointMarkersGL.ts b/client/src/components/Map/usePlannerWaypointMarkersGL.ts new file mode 100644 index 000000000..741a1287e --- /dev/null +++ b/client/src/components/Map/usePlannerWaypointMarkersGL.ts @@ -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 { + 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; + 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({ + mapRef, + mapReady, + pinsRef, + attachPin, + pinCss, + plannerWaypoints, + selectedPlannerWaypointId, + onPlannerWaypointClick, + onPlannerWaypointMove, +}: PlannerWaypointMarkersOptions): 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, + ]); +} diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx new file mode 100644 index 000000000..8c111f795 --- /dev/null +++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx @@ -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 && ( + + + {t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} + + + )} + {tourTimes.breakMinutes != null && ( + + + {t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)} + + + )} + {tourTimes.plannedTotalMinutes != null && ( + + + {t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)} + {manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} + + + )} + + ); +} + +// Description line; tour stops get two clamped lines instead of one. +function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) { + return ( +
+ {text} +
+ ); +} + +// 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 ( +
+ {place.place_time && ( + + {formatTime(place.place_time, locale, timeFormat)} + {place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''} + + )} + {dayRowTour ? : tourTimes && } + {description && } +
+ ); +} diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index c5d02b697..54e6bc0b0 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -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() - 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() - 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() - 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() - 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() - 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 }] }) diff --git a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx new file mode 100644 index 000000000..f8d872eae --- /dev/null +++ b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx @@ -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, + dayNotes: {} as Record, + 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(); + 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 }) => ( + + ), +})); + +// 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()), + 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(); + 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( + + ); + 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(); + 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(); + 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(); + expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index cc031aa96..518486da6 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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 )} - {(dayRowTour || place.place_time || (tourTimes && (tourTimes.walkingMinutes != null || tourTimes.breakMinutes != null || tourTimes.plannedTotalMinutes != null)) || place.description || place.address || cat?.name) && ( -
- {place.place_time && ( - - {formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''} - - )} - {dayRowTour && } - {!dayRowTour && tourTimes?.walkingMinutes != null && ( - - - {t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} - - - )} - {!dayRowTour && tourTimes?.breakMinutes != null && ( - - - {t('tours.planner.breaksShort')}: {formatPlannedTourDuration(tourTimes.breakMinutes)} - - - )} - {!dayRowTour && tourTimes?.plannedTotalMinutes != null && ( - - - {t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} - - - )} - {(place.description || place.address || cat?.name) && ( -
- {place.description || place.address || cat?.name || ''} -
- )} -
- )} + {assignment.notes && ( // Day-specific note on this stop (#2163) — one muted // caption line so the timeline shows the note exists diff --git a/client/src/pages/TripPlannerPage.tours.wiring.test.tsx b/client/src/pages/TripPlannerPage.tours.wiring.test.tsx new file mode 100644 index 000000000..e052a5711 --- /dev/null +++ b/client/src/pages/TripPlannerPage.tours.wiring.test.tsx @@ -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; +const captured: Record = {}; +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; + +/** The last-rendered props of a stub, typed loosely so callbacks can be invoked. */ +function props(name: string): Record { + return (captured[name] ?? {}) as Record; +} + +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(); +} + +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(); + 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(); + }); +}); diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index f555373bb..d0b3b125a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 && (
- 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. */} -
-
- -
-