fix(roadtrip): the stop popup shows the answers of the stop it is handed, not the previous one

The desktop road trip stop popup read its kind, length, night and
check-in answers only when it mounted. When the planner handed the open
popup another stop draft, the popup showed the new stop's name with the
previous stop's kind, length, night switch and check-in, and saving wrote
those stale answers onto the new stop.

The phone sheet already seeds the shared useStopDraftForm again for
every draft. The popup now does the same in an effect keyed on the draft,
and seeds the night switch and the check-in time from the same draft.

Red test: FE-ROADTRIP-STOPPOPUP-034 in
client/src/components/Roadtrip/RoadtripStopPopup.test.tsx failed with
expect(element).toHaveAttribute("aria-pressed", "true") on the Charging
chip after the draft was swapped.

No existing assertions changed.
This commit is contained in:
Maurice
2026-10-09 17:56:27 +02:00
parent 8c9a31e5ef
commit 55e6b55f4d
3 changed files with 41 additions and 6 deletions
@@ -373,6 +373,27 @@ describe('editing a roadtrip stop', () => {
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:00', checkOut: '10:00' }))
})
it('FE-ROADTRIP-STOPPOPUP-034: a new draft handed to the open popup shows the answers of that stop, not the previous one', async () => {
const onSave = vi.fn()
const onSaveNight = vi.fn()
const nights = { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 }
const hotelEdit = draft({ poi: poi({ category: 'hotel', name: 'Hotel Adlon' }), overnight: nights, editing: { placeId: 7, stopType: 'hotel', dwellMinutes: 30, accommodationId: 8, checkIn: '16:00', checkOut: '10:00' } })
const fuelEdit = draft({ editing: { placeId: 9, stopType: 'charging', dwellMinutes: 20 } })
const ui = (d: RoadtripStopDraft) => <TranslationProvider><RoadtripStopPopup draft={d} {...noop} onSave={onSave} onSaveNight={onSaveNight} /></TranslationProvider>
const { rerender } = render(ui(hotelEdit))
expect(screen.getByRole('textbox', { name: 'Check-in' })).toHaveValue('16:00')
rerender(ui(fuelEdit))
expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 20 }))
rerender(ui(draft({ poi: poi({ category: 'hotel', name: 'Hotel Bristol' }), arrivalTime: '15:45', overnight: nights })))
expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('textbox', { name: 'Check-in' })).toHaveValue('15:45')
})
})
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -69,6 +69,12 @@ interface RoadtripStopPopupProps {
* which neither column shows. This asks the two questions that matter (what kind of stop,
* how long) and says where it will land, with a way out to the full form for the rest.
*/
const nightByDefault = (draft: RoadtripStopDraft | null): boolean =>
draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel'
const checkInByDefault = (draft: RoadtripStopDraft | null): string =>
draft?.editing?.checkIn || draft?.arrivalTime || ''
export default function RoadtripStopPopup({
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
}: RoadtripStopPopupProps): React.ReactElement | null {
@@ -80,12 +86,20 @@ export default function RoadtripStopPopup({
// /trips/<id>/www.hotel.de instead of leaving the app. The helper adds the
// scheme for a bare host and drops anything that is not http(s).
const websiteHref = safeExternalHref(draft?.poi.website)
const { stopType, dwell, setDwell, saving, setSaving, kind, pickKind } = useStopDraftForm(draft)
const { stopType, dwell, setDwell, saving, setSaving, kind, pickKind, seed } = useStopDraftForm(draft)
// A hotel is a night by default and a campsite a pause, which is what each already
// means everywhere else — but both offer the other, because a campsite is somewhere
// people sleep and a hotel is somewhere people stop for lunch.
const [night, setNight] = useState<boolean>(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel')
const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '')
const [night, setNight] = useState<boolean>(() => nightByDefault(draft))
const [checkIn, setCheckIn] = useState(() => checkInByDefault(draft))
// The popup stays mounted while the draft is swapped for another one, so every
// answer is seeded again from the new draft, the way the phone sheet does it.
useEffect(() => {
seed(draft)
setNight(nightByDefault(draft))
setCheckIn(checkInByDefault(draft))
}, [draft, seed])
if (!draft) return null
const overnight = draft.overnight
@@ -24,8 +24,8 @@ export function stopDraftDefaults(draft: RoadtripStopDraft | null | undefined):
/**
* The two questions a stop found along the drive answers before it goes onto the trip,
* what kind it is and how long it takes, behind the desktop stop popup and the phone's
* draft sheet. `initialDraft` gives the first answers; the phone seeds them again for
* every draft it is handed.
* draft sheet. `initialDraft` gives the first answers; both seed them again with
* `seed` for every draft they are handed while they stay open.
*/
export function useStopDraftForm(initialDraft: RoadtripStopDraft | null) {
const [stopType, setStopType] = useState<RoadtripStopType | null>(() => stopDraftDefaults(initialDraft).stopType);