mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(map): per-trip booking-route visibility with account default and bulk toggle
Lets a user see a booking's route on the map without manually toggling it per item, two ways: - The account setting from the previous commit sets the default for any trip that's never had its routes touched before. - A new bulk "show all / hide all" button in the day-plan toolbar flips a trip explicitly between the two connectionsVisibility modes, independent of the per-item toggle (which still edits whichever mode's id list is active, in both directions, including while the account default is on). useTripPlanner.ts resolves a trip's effective visible-connection ids from connectionsVisibility.ts + the account setting + the trip's routable reservations, and exposes it through the same visibleConnections/toggleConnection contract MapView, MapViewGL and DayPlanSidebar already had, plus allConnectionsShown/ toggleAllConnections for the new bulk control. MapView/MapViewGL consume it via the shared reservationRoutes util instead of each carrying their own copy of the filter. The bulk toggle's tooltip gets its own map.showAllConnections/ hideAllConnections i18n keys (all 22 locales) distinct from the per-item toggle's text, and matches the per-item toggle's active (solid blue) styling rather than the toolbar's generic hover tint. Manually verified end-to-end in a running dev server: the account default seeding an untouched trip, the bulk toggle flipping a trip between all-shown/all-hidden, and a single per-leg override while the trip is in all-shown mode. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
|
||||
const mapMock = vi.hoisted(() => ({
|
||||
@@ -15,6 +15,7 @@ const mapMock = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
|
||||
}))
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
@@ -42,6 +43,7 @@ vi.mock('react-leaflet', () => ({
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
useMap: () => mapMock,
|
||||
useMapEvents: () => ({}),
|
||||
}))
|
||||
@@ -349,4 +351,30 @@ describe('MapView', () => {
|
||||
expect(zoom).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-023: a routable reservation not in visibleConnectionIds draws no route', () => {
|
||||
const reservation = buildReservation({
|
||||
id: 43,
|
||||
type: 'flight',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as any)
|
||||
render(<MapView reservations={[reservation]} visibleConnectionIds={[]} />)
|
||||
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-024: a routable reservation in visibleConnectionIds draws its route', () => {
|
||||
const reservation = buildReservation({
|
||||
id: 42,
|
||||
type: 'flight',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as any)
|
||||
render(<MapView reservations={[reservation]} visibleConnectionIds={[42]} />)
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import { PluginMapMarkers } from './MapPluginMarkers'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import type { Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
@@ -478,12 +479,9 @@ export const MapView = memo(function MapView({
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
const visibleReservations = useMemo(() => {
|
||||
const set = new Set(visibleConnectionIds || [])
|
||||
// Transit journeys ride the route toggle — they are part of the computed
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter((r: Reservation) => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings,
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -928,12 +929,9 @@ export function MapViewGL({
|
||||
// `visibleConnectionIds` is driven by the per-reservation toggle in
|
||||
// DayPlanSidebar — nothing is rendered until the user enables a
|
||||
// booking's route, matching the Leaflet MapView's behaviour.
|
||||
const visibleReservations = useMemo(() => {
|
||||
const set = new Set(visibleConnectionIds || [])
|
||||
// Transit journeys ride the route toggle — they are part of the computed
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter(r => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
|
||||
|
||||
@@ -392,6 +392,53 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-106: the toolbar button shows "show all" and calls onToggleAllConnections when not all routes are shown', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const res1 = {
|
||||
...buildReservation({ id: 401, type: 'flight' }),
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
}
|
||||
const onToggleAllConnections = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], reservations: [res1 as any],
|
||||
visibleConnectionIds: [], onToggleConnection: vi.fn(),
|
||||
allConnectionsShown: false, onToggleAllConnections,
|
||||
})} />)
|
||||
await user.click(screen.getByLabelText('Show all booking routes'))
|
||||
expect(onToggleAllConnections).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-107: the toolbar button reads "hide" once allConnectionsShown is true, and calls onToggleAllConnections on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const res1 = {
|
||||
...buildReservation({ id: 401, type: 'flight' }),
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
}
|
||||
const onToggleAllConnections = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], reservations: [res1 as any],
|
||||
visibleConnectionIds: [401], onToggleConnection: vi.fn(),
|
||||
allConnectionsShown: true, onToggleAllConnections,
|
||||
})} />)
|
||||
await user.click(screen.getByLabelText('Hide all booking routes'))
|
||||
expect(onToggleAllConnections).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-108: the toolbar button is absent when the trip has no routable reservation', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [], onToggleAllConnections: vi.fn() })} />)
|
||||
expect(screen.queryByLabelText('Show all booking routes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Hide all booking routes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Day info button ─────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-018: clicking day header calls onDayDetail', async () => {
|
||||
|
||||
@@ -69,6 +69,8 @@ interface DayPlanSidebarProps {
|
||||
reservations?: Reservation[]
|
||||
visibleConnectionIds?: number[]
|
||||
onToggleConnection?: (reservationId: number) => void
|
||||
allConnectionsShown?: boolean
|
||||
onToggleAllConnections?: () => void
|
||||
externalTransportDetail?: Reservation | null
|
||||
onExternalTransportDetailHandled?: () => void
|
||||
onAddReservation: (dayId: number) => void
|
||||
@@ -122,6 +124,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
onToggleConnection,
|
||||
allConnectionsShown = false,
|
||||
onToggleAllConnections,
|
||||
externalTransportDetail,
|
||||
onExternalTransportDetailHandled,
|
||||
onAddReservation,
|
||||
@@ -1014,6 +1018,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
reservations,
|
||||
visibleConnectionIds,
|
||||
onToggleConnection,
|
||||
allConnectionsShown,
|
||||
onToggleAllConnections,
|
||||
externalTransportDetail,
|
||||
onExternalTransportDetailHandled,
|
||||
onAddReservation,
|
||||
@@ -1183,6 +1189,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
reservations,
|
||||
visibleConnectionIds,
|
||||
onToggleConnection,
|
||||
allConnectionsShown,
|
||||
onToggleAllConnections,
|
||||
externalTransportDetail,
|
||||
onExternalTransportDetailHandled,
|
||||
onAddReservation,
|
||||
@@ -1323,6 +1331,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
allConnectionsShown={allConnectionsShown}
|
||||
onToggleAllConnections={onToggleAllConnections}
|
||||
dayNotes={dayNotes}
|
||||
t={t}
|
||||
locale={locale}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, Route as RouteIcon } from 'lucide-react'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
@@ -15,6 +16,8 @@ interface DayPlanSidebarToolbarProps {
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
allConnectionsShown?: boolean
|
||||
onToggleAllConnections?: () => void
|
||||
dayNotes: Record<string, DayNote[]>
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
@@ -37,6 +40,7 @@ interface DayPlanSidebarToolbarProps {
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast, pdfHover, setPdfHover, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
@@ -295,6 +299,32 @@ export function DayPlanSidebarToolbar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onToggleAllConnections && reservations.some(isRoutableReservation) && (() => {
|
||||
const label = t(allConnectionsShown ? 'map.hideAllConnections' : 'map.showAllConnections')
|
||||
return (
|
||||
<Tooltip label={label} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAllConnections}
|
||||
aria-label={label}
|
||||
aria-pressed={allConnectionsShown}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: allConnectionsShown ? 'none' : '1px solid var(--border-primary)',
|
||||
background: allConnectionsShown ? '#3b82f6' : 'none',
|
||||
color: allConnectionsShown ? '#fff' : 'var(--text-primary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!allConnectionsShown) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!allConnectionsShown) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<RouteIcon size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user