diff --git a/client/src/components/Tours/activity/TourActivitySection.test.tsx b/client/src/components/Tours/activity/TourActivitySection.test.tsx index 763c6b983..178019e92 100644 --- a/client/src/components/Tours/activity/TourActivitySection.test.tsx +++ b/client/src/components/Tours/activity/TourActivitySection.test.tsx @@ -1,9 +1,9 @@ -// FE-ACT-SECTION-001 to FE-ACT-SECTION-007 +// FE-ACT-SECTION-001 to FE-ACT-SECTION-008 // ACT-01 in the tour's detail, rendered without the translation provider so // every text reads as its key: the actual tracks with their own values marked // as such beside the plan's (ACT-02), a time only where the track measured // one, the actions only for someone who may change the tour, and the map -// comparison only where a plan map is beside the detail. +// comparison only where a plan map is beside the detail and can draw it. import { act, fireEvent, render, screen } from '@testing-library/react'; import type { TourActivity, TourListItem } from '@trek/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -81,7 +81,7 @@ const show = (canEdit = true, item: TourListItem = tour, mapCompare = false) => beforeEach(() => { hook.state = state(); - useTourActivityCompareStore.getState().clear(); + useTourActivityCompareStore.setState({ comparison: null, mapAvailable: true }); }); describe('TourActivitySection (ACT-01)', () => { @@ -178,4 +178,17 @@ describe('TourActivitySection (ACT-01)', () => { show(false); expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull(); }); + + it('FE-ACT-SECTION-008: in road trip mode or with the overview open the map draws other lines, so no switch is offered and a running comparison ends', () => { + show(false, tour, true); + fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ })); + expect(useTourActivityCompareStore.getState().comparison).not.toBeNull(); + + act(() => useTourActivityCompareStore.getState().setMapAvailable(false)); + expect(useTourActivityCompareStore.getState().comparison).toBeNull(); + expect(screen.queryByRole('button', { name: /tours.activity.compare.(show|hide)/ })).toBeNull(); + + act(() => useTourActivityCompareStore.getState().setMapAvailable(true)); + expect(screen.getByRole('button', { name: /tours.activity.compare.show/ })).toHaveAttribute('aria-pressed', 'false'); + }); }); diff --git a/client/src/components/Tours/activity/TourActivitySection.tsx b/client/src/components/Tours/activity/TourActivitySection.tsx index a30830864..31e8b455a 100644 --- a/client/src/components/Tours/activity/TourActivitySection.tsx +++ b/client/src/components/Tours/activity/TourActivitySection.tsx @@ -3,6 +3,7 @@ import { CalendarDays, Clock, CloudOff, FileDown, Footprints, Plus, RefreshCw, U import { useMemo } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; +import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore'; import type { AssignmentsMap, Day } from '../../../types'; import { BOX } from '../../Planner/bookings/bookingParts'; import { SoftPill } from '../../Planner/planParts'; @@ -215,7 +216,7 @@ interface TourActivitySectionProps { plan: PlanValues; /** The plan's track colour, as the map and the table draw it. */ planColor: string; - /** Offers to compare a track on the plan map; only where one is beside the detail. */ + /** Offers to compare a track on the plan map; only where one is beside the detail and the map can draw it. */ mapCompare?: boolean; onToursChanged?: (placeIds: number[]) => Promise | void; } @@ -245,7 +246,9 @@ export default function TourActivitySection({ const state = useTourActivities({ tripId, tour, executions, canEdit, onToursChanged }); const { activities, unavailable } = state; const comparison = useActivityComparison(tour.place_id, activities); - const side: PlanSide = { plan, planColor, comparison: mapCompare ? comparison : null }; + // The plan map says whether it can draw a comparison; where it cannot, no switch is offered. + const mapAvailable = useTourActivityCompareStore((store) => store.mapAvailable); + const side: PlanSide = { plan, planColor, comparison: mapCompare && mapAvailable ? comparison : null }; if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit && !unavailable)) { return null; } diff --git a/client/src/components/Tours/activity/useActivityCompareMap.test.ts b/client/src/components/Tours/activity/useActivityCompareMap.test.ts index 307b7dbd2..7521dbd45 100644 --- a/client/src/components/Tours/activity/useActivityCompareMap.test.ts +++ b/client/src/components/Tours/activity/useActivityCompareMap.test.ts @@ -1,8 +1,9 @@ -// FE-ACT-COMPARE-MAP-001 to FE-ACT-COMPARE-MAP-005 +// FE-ACT-COMPARE-MAP-001 to FE-ACT-COMPARE-MAP-006 // ACT-02 on the plan map: while a tour is compared with its actual track, the // track leaves the map as a tour of its own and comes back as one dotted line // of the route, the plan stays on the map, and the legend names both; off, -// the map gets exactly what it would have had. +// the map gets exactly what it would have had. The map also says whether it +// can compare at all, and ends a comparison where it no longer can. import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; import { buildPlace } from '../../../../tests/helpers/factories'; @@ -41,7 +42,7 @@ const input = { }; beforeEach(() => { - useTourActivityCompareStore.getState().clear(); + useTourActivityCompareStore.setState({ comparison: null, mapAvailable: false }); }); describe('compareMapLines (ACT-02)', () => { @@ -114,4 +115,19 @@ describe('useActivityCompareMap (ACT-02)', () => { document.documentElement.style.removeProperty('--success'); } }); + + it('FE-ACT-COMPARE-MAP-006: tells the store whether it can compare, and ends a comparison where it no longer can or is gone', () => { + const { rerender, unmount } = renderHook((props: typeof input) => useActivityCompareMap(props), { initialProps: input }); + expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true); + act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 })); + + rerender({ ...input, enabled: false }); + expect(useTourActivityCompareStore.getState()).toMatchObject({ mapAvailable: false, comparison: null }); + + rerender(input); + expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true); + act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 })); + unmount(); + expect(useTourActivityCompareStore.getState()).toMatchObject({ mapAvailable: false, comparison: null }); + }); }); diff --git a/client/src/components/Tours/activity/useActivityCompareMap.ts b/client/src/components/Tours/activity/useActivityCompareMap.ts index 5cccce89c..db30a0d86 100644 --- a/client/src/components/Tours/activity/useActivityCompareMap.ts +++ b/client/src/components/Tours/activity/useActivityCompareMap.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore'; import type { Place } from '../../../types'; import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry'; @@ -80,11 +80,22 @@ export function compareMapLines

( }; } -/** ACT-02 on the plan map: `compareMapLines` with the comparison of the store and the theme's colours. */ +/** + * ACT-02 on the plan map: `compareMapLines` with the comparison of the store + * and the theme's colours. The map also tells the store whether it can + * compare at all, so the detail offers the switch only where pressing it + * draws something; turning to the road trip or the overview, or leaving the + * map, ends a running comparison. + */ export function useActivityCompareMap

(input: CompareMapInput

) { const comparison = useTourActivityCompareStore((state) => state.comparison); const paint = useMapPaint(ACTUAL_PAINT); const { enabled, places, mapPlaces, route, routeWalking, selectedPlaceId } = input; + useEffect(() => { + const { setMapAvailable } = useTourActivityCompareStore.getState(); + setMapAvailable(enabled); + return () => setMapAvailable(false); + }, [enabled]); return useMemo( () => compareMapLines({ enabled, places, mapPlaces, route, routeWalking, selectedPlaceId }, comparison, paint), [comparison, enabled, mapPlaces, paint, places, route, routeWalking, selectedPlaceId] diff --git a/client/src/pages/tripPlanner/PlanMapStage.test.tsx b/client/src/pages/tripPlanner/PlanMapStage.test.tsx index 9ad321310..0cd8629cf 100644 --- a/client/src/pages/tripPlanner/PlanMapStage.test.tsx +++ b/client/src/pages/tripPlanner/PlanMapStage.test.tsx @@ -69,7 +69,7 @@ function renderStage(overrides: Partial beforeEach(() => { for (const key of Object.keys(captured)) delete captured[key] - useTourActivityCompareStore.getState().clear() + useTourActivityCompareStore.setState({ comparison: null, mapAvailable: false }) }) describe('PlanMapStage', () => { @@ -222,10 +222,18 @@ describe('PlanMapStage', () => { expect(recorded('map')).toMatchObject({ places: [plan], route: [[[47, 11], [47.01, 11]]], routeWalking: [true] }) expect(screen.getByTestId('activity-compare-legend')).toHaveTextContent('Watch export') + expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true) - // The road trip and the trip overview draw other lines; nothing is compared there. + // The road trip and the trip overview draw other lines; nothing is compared + // there, the detail is told so and the comparison ends. rerender() expect(recorded('map')).toMatchObject({ route: ['overview'] }) expect(screen.queryByTestId('activity-compare-legend')).toBeNull() + expect(useTourActivityCompareStore.getState()).toMatchObject({ mapAvailable: false, comparison: null }) + + rerender() + expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true) + rerender() + expect(useTourActivityCompareStore.getState().mapAvailable).toBe(false) }) }) diff --git a/client/src/store/tourActivityCompareStore.ts b/client/src/store/tourActivityCompareStore.ts index e44c3a32d..5d89de3a3 100644 --- a/client/src/store/tourActivityCompareStore.ts +++ b/client/src/store/tourActivityCompareStore.ts @@ -15,12 +15,23 @@ interface ActivityComparison { interface TourActivityCompareState { comparison: ActivityComparison | null; + /** + * Whether the plan map can draw a comparison right now. The map says so + * itself: in road trip mode, with the trip overview open or on the phone it + * draws other lines, and the detail then offers no switch that would do + * nothing. + */ + mapAvailable: boolean; show: (comparison: ActivityComparison | null) => void; clear: () => void; + /** Set by the plan map; a map that can no longer compare ends the comparison too. */ + setMapAvailable: (available: boolean) => void; } export const useTourActivityCompareStore = create((set) => ({ comparison: null, + mapAvailable: false, show: (comparison) => set({ comparison }), clear: () => set({ comparison: null }), + setMapAvailable: (available) => set(available ? { mapAvailable: true } : { mapAvailable: false, comparison: null }), })); diff --git a/client/tests/helpers/storeRegistry.ts b/client/tests/helpers/storeRegistry.ts index 70c6400e9..f6bebdb96 100644 --- a/client/tests/helpers/storeRegistry.ts +++ b/client/tests/helpers/storeRegistry.ts @@ -60,7 +60,7 @@ export const UNRESET_STORES: Record = { useStudioStore: NOT_YET, useSystemNoticeStore: NOT_YET, useTourActivityCompareStore: - 'the actual track compared on the map; the tests that read it clear it in their own beforeEach', + 'the actual track compared on the map and whether the map can draw it; the tests that read it reset both in their own beforeEach', useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach', useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()', useTourPoiStore: 'the places found along a tour; the tests that read it clear it in their own beforeEach',