diff --git a/client/src/components/Tours/planner/WinterActivityField.test.tsx b/client/src/components/Tours/planner/WinterActivityField.test.tsx index b061182b3..0d147af3c 100644 --- a/client/src/components/Tours/planner/WinterActivityField.test.tsx +++ b/client/src/components/Tours/planner/WinterActivityField.test.tsx @@ -4,26 +4,51 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { WinterActivityField } from './WinterActivityField'; -// FE-TOUR-WINTER-FIELD-001 to FE-TOUR-WINTER-FIELD-004 +// FE-TOUR-WINTER-FIELD-001 to FE-TOUR-WINTER-FIELD-006 const toggle = () => screen.getByRole('button', { name: 'tours.winter.title' }); const option = (activity: WinterActivity) => screen.getByRole('button', { name: `tours.winter.activity.${activity}` }); -/** The field over a planner that keeps what it is given, as the real one does. */ +/** + * The field over a planner that keeps what it is given and the label the + * switch brings back, as the real one does. Reset and open stand for the + * planner starting a new tour and opening a saved one. + */ function Stateful({ initial = null }: { initial?: WinterActivity | null }) { - const [winterActivity, setWinterActivity] = useState(initial); + const [winterActivity, setActivity] = useState(initial); + const [winterActivityChoice, setChoice] = useState(initial ?? 'winter_hike'); + const setWinterActivity = (value: WinterActivity | null) => { + setActivity(value); + if (value) setChoice(value); + }; + const load = (value: WinterActivity | null) => { + setActivity(value); + setChoice(value ?? 'winter_hike'); + }; return ( <> - + {winterActivity ?? 'none'} + + ); } +const field = (winterActivity: WinterActivity | null, setWinterActivity = vi.fn(), choice?: WinterActivity) => ({ + winterActivity, + setWinterActivity, + winterActivityChoice: choice ?? winterActivity ?? 'winter_hike', +}); + describe('WinterActivityField (HIK-01)', () => { it('FE-TOUR-WINTER-FIELD-001: is off by default, its choice dimmed and out of reach until it is switched on', () => { const setWinterActivity = vi.fn(); - render(); + render(); expect(toggle()).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByTestId('tour-winter-kind')).toHaveClass('opacity-50'); expect(option('winter_hike')).toBeDisabled(); @@ -39,7 +64,7 @@ describe('WinterActivityField (HIK-01)', () => { it('FE-TOUR-WINTER-FIELD-002: switched on, it names the label chosen and takes the other one', () => { const setWinterActivity = vi.fn(); - render(); + render(); expect(toggle()).toHaveAttribute('aria-pressed', 'true'); expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true'); expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'false'); @@ -61,10 +86,32 @@ describe('WinterActivityField (HIK-01)', () => { it('FE-TOUR-WINTER-FIELD-004: changes nothing while the tour is locked', () => { const setWinterActivity = vi.fn(); - render(); + render(); fireEvent.click(toggle()); fireEvent.click(option('snowshoe')); expect(setWinterActivity).not.toHaveBeenCalled(); expect(option('snowshoe')).toBeDisabled(); }); + + it('FE-TOUR-WINTER-FIELD-005: keeps no choice of its own, so the next tour switches on as winter hiking', () => { + render(); + fireEvent.click(toggle()); + fireEvent.click(screen.getByRole('button', { name: 'reset' })); + expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(toggle()); + expect(screen.getByTestId('saved')).toHaveTextContent('winter_hike'); + fireEvent.click(screen.getByRole('button', { name: 'open snowshoe' })); + fireEvent.click(toggle()); + fireEvent.click(screen.getByRole('button', { name: 'reset' })); + fireEvent.click(toggle()); + expect(screen.getByTestId('saved')).toHaveTextContent('winter_hike'); + }); + + it('FE-TOUR-WINTER-FIELD-006: switched on, it brings back the label the planner kept for the tour', () => { + const setWinterActivity = vi.fn(); + render(); + expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(toggle()); + expect(setWinterActivity).toHaveBeenCalledWith('snowshoe'); + }); }); diff --git a/client/src/components/Tours/planner/WinterActivityField.tsx b/client/src/components/Tours/planner/WinterActivityField.tsx index 7b3281f3c..1feff2915 100644 --- a/client/src/components/Tours/planner/WinterActivityField.tsx +++ b/client/src/components/Tours/planner/WinterActivityField.tsx @@ -1,6 +1,5 @@ import { WINTER_ACTIVITIES, type WinterActivity } from '@trek/shared'; import { Snowflake } from 'lucide-react'; -import { useState } from 'react'; import { useTranslation } from '../../../i18n'; import { BOX } from '../../Planner/bookings/bookingParts'; import ToggleSwitch from '../../Settings/ToggleSwitch'; @@ -15,32 +14,27 @@ import type { TourPlannerController } from './useTourPlanner'; * details, so a hike asks for nothing more. Switched on, it is winter hiking * until snowshoeing is picked; switched off, the label goes and the route and * the days stay. The choice stays in sight but dimmed while the switch is off, - * and comes back as it was when it is switched on again. The info dot says it - * is a label only: it plans and times nothing differently. + * and comes back as it was when it is switched on again. The planner keeps + * that choice with the tour, so a tour saved, closed or opened after a + * snowshoe hike starts again from winter hiking. The info dot says it is a + * label only: it plans and times nothing differently. */ export function WinterActivityField({ planner, locked, }: { - planner: Pick; + planner: Pick; locked: boolean; }) { const { t } = useTranslation(); const current = planner.winterActivity ?? null; - const [lastChoice, setLastChoice] = useState(current ?? 'winter_hike'); const on = current !== null; const toggle = () => { if (locked) return; - if (current === null) { - planner.setWinterActivity(lastChoice); - return; - } - setLastChoice(current); - planner.setWinterActivity(null); + planner.setWinterActivity(on ? null : planner.winterActivityChoice); }; const choose = (next: WinterActivity) => { if (locked || !on) return; - setLastChoice(next); planner.setWinterActivity(next); }; return ( @@ -68,7 +62,7 @@ export function WinterActivityField({ className={`m-0 min-w-0 border-0 border-t border-solid border-edge-faint px-3 py-2 transition-opacity ${on ? '' : 'opacity-50'}`} > - value={current ?? lastChoice} + value={current ?? planner.winterActivityChoice} onChange={choose} label={t('tours.winter.kind')} fill diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index f6612207a..36f112a3e 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -128,6 +128,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const [hikingPreferences, setHikingPreferencesState] = useState(restored?.hikingPreferences ?? null) const [bikeProfile, setBikeProfileState] = useState(restored?.bikeProfile ?? 'touring') const [winterActivity, setWinterActivityState] = useState(restored?.winterActivity ?? null) + // HIK-01: the label the switch brings back, the tour's own while it is off, winter hiking for every other tour. + const [winterActivityChoice, setWinterActivityChoice] = useState(restored?.winterActivity ?? 'winter_hike') const [runProfile, setRunProfileState] = useState(restored?.runProfile ?? 'running') const [tourType, setTourTypeState] = useState(restored?.tourType ?? 'hike') const [selectedWaypointId, setSelectedWaypointId] = useState(restored?.waypoints[0]?.id ?? null) @@ -483,7 +485,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe /** HIK-01: the hike's winter label, a field like the description: it plans nothing again and is no step of the history. */ const setWinterActivity = useCallback((value: WinterActivity | null) => { - if (value !== winterActivity && markEdited()) setWinterActivityState(value) + if (value === winterActivity || !markEdited()) return + setWinterActivityState(value) + if (value) setWinterActivityChoice(value) }, [markEdited, winterActivity]) @@ -564,6 +568,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setRunProfileState('running') loadRunPace(null) setWinterActivityState(null) + setWinterActivityChoice('winter_hike') tourTypeRef.current = 'hike' setSelectedWaypointId(null) clearHistory() @@ -743,6 +748,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setRunProfileState(opened.runProfile) loadRunPace(opened.runPaceSecondsPerKm) setWinterActivityState(opened.winterActivity) + setWinterActivityChoice(opened.winterActivity ?? 'winter_hike') tourTypeRef.current = opened.tourType setSelectedWaypointId(nextWaypoints[0]?.id ?? null) clearHistory() @@ -910,7 +916,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe return { tripId, canEdit, canAssign, name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences, - bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity, + bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity, winterActivityChoice, runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid, tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete, description, setDescription, website, setWebsite, websiteInvalid, diff --git a/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx b/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx index 56f3af544..12c9794c0 100644 --- a/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx @@ -18,7 +18,7 @@ vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: import { useTourPlanner } from './useTourPlanner'; -// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-005 +// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-006 const routed = { coordinates: [ @@ -184,4 +184,22 @@ describe('useTourPlanner, the winter label of a hike (HIK-01)', () => { expect.objectContaining({ tour_type: 'walk', winter_activity: null }) ); }); + + it('FE-TOUR-WINTER-006: keeps the label the switch brings back with the tour, winter hiking for the next one', async () => { + detailTour.mockResolvedValue({ tour: savedHike, waypoints: savedPoints }); + const { result } = renderHook(() => useTourPlanner({ tripId: 66 })); + expect(result.current.winterActivityChoice).toBe('winter_hike'); + act(() => result.current.setWinterActivity('snowshoe')); + act(() => result.current.setWinterActivity(null)); + expect(result.current.winterActivityChoice).toBe('snowshoe'); + act(() => result.current.startNewTour(true)); + expect(result.current.winterActivity).toBeNull(); + expect(result.current.winterActivityChoice).toBe('winter_hike'); + await act(async () => { + await result.current.openTour({ place_id: 88 }); + }); + expect(result.current.winterActivityChoice).toBe('snowshoe'); + act(() => result.current.discard()); + expect(result.current.winterActivityChoice).toBe('winter_hike'); + }); }); diff --git a/client/tests/helpers/tourPlannerStub.ts b/client/tests/helpers/tourPlannerStub.ts index eaeff3e66..b961ba460 100644 --- a/client/tests/helpers/tourPlannerStub.ts +++ b/client/tests/helpers/tourPlannerStub.ts @@ -16,6 +16,7 @@ export function hikePlannerDefaults() { // HIK-01: a hike without a winter label, as every hike starts. winterActivity: null, setWinterActivity: vi.fn(), + winterActivityChoice: 'winter_hike' as const, // RUN-01: no run, so no pace asked for. isRun: false, runProfile: 'running' as const,