From 5b60dda15034ead621a2af695eb451d1e1bc3a30 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 16 Sep 2026 22:23:01 +0000 Subject: [PATCH] feat(roadtrip): edit a stop's place from its phone sheet, title centred on the disc The stop sheet had no way into the place, so a fixed time could not be set from the road. A pencil in the header now hands over to the place editor the plan tab uses, with this visit in context, so Start (the time the chain anchors on) can be set or cleared there. It shows with place_edit, once the place row resolves from the whole trip store and the visit carries a server id. No second writer for the time is added. Opening the editor on a booked night or a service stop the day lists hide used to miss the visit, prefill Start from the pool place and write that over the pin on a plain save, dropping the note too. MPlaceEditSheet now resolves the visit from the stored assignments. The header row is centred, so a one line name no longer hangs from the top of the 34px disc. --- .../screens/trip/roadtrip/MRtStopSheet.tsx | 51 ++++++-- .../screens/trip/sheets/MPlaceEditSheet.tsx | 44 +++++-- .../useTripPlanner.roadtrip.test.tsx | 37 +++++- client/tests/helpers/mobileTrip.ts | 3 + .../unit/mobile/trip/MPlaceEditSheet.test.tsx | 83 ++++++++++++- .../unit/mobile/trip/MRtStopSheet.test.tsx | 114 ++++++++++++++++-- 6 files changed, 300 insertions(+), 32 deletions(-) diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx index d25211a33..ea0dcbba2 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type Ref } from 'react' import { - AlertTriangle, Clock, Footprints, Fuel, Hourglass, MapPin, Moon, Navigation, X, + AlertTriangle, Clock, Footprints, Fuel, Hourglass, MapPin, Moon, Navigation, Pencil, X, } from 'lucide-react' import MSheet from '../../../components/MSheet' import MIconBtn from '../../../components/MIconBtn' @@ -9,6 +9,7 @@ import MToggle from '../../../components/MToggle' import type { MTripSheetsProps } from '../MTripShell' import { useTranslation } from '../../../../i18n' import { useSettingsStore } from '../../../../store/settingsStore' +import { useTripStore } from '../../../../store/tripStore' import ChargingInfo from '../../../../components/Roadtrip/ChargingInfo' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { roadtripRows, stageOf, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' @@ -34,6 +35,13 @@ import type { RoadtripDay, ScheduleWarning } from '@trek/shared/roadtrip' * confirmation, simply absent, because a dead control on a phone is a promise the screen * cannot keep. What is left are the three things a passenger does decide on the road: * navigate there, stay longer, stop for the night here. + * + * Editing the place is the one way out of that rule, and it is a hand-over rather than a + * control of this sheet: the pencil opens the place editor the plan tab opens, with this + * very visit in context, and this sheet closes behind it. The time somebody fixed for a + * stop is that editor's start time (the chain anchors on the visit's own start time), so + * a time control here would be a second writer for one number. A booked night can also + * be held by its check-in, which belongs to the booking and not to the place. */ interface RtStopSheetPayload { @@ -132,6 +140,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const unitSetting = useSettingsStore(s => s.settings.distance_unit) const unit: DistanceUnit = unitSetting === 'imperial' ? 'imperial' : 'metric' + const tripPlaces = useTripStore(s => s.places) const days = planner.roadtripRoutes.days const live = useMemo( @@ -155,7 +164,10 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { useEffect(() => { setPending(null) }, [payload.assignmentId]) const stop = located?.row.stop ?? null - const place = planner.places.find(p => p.id === stop?.placeId) ?? null + // From the whole trip store, not planner.places: with service stops hidden from the day + // lists the phone filters them out of that list, while the stage still draws them, and + // both the pencil and the directions have to reach the row the chain drew. + const place = tripPlaces.find(p => p.id === stop?.placeId) ?? null /** * Addressed by stop, not by selection. @@ -210,11 +222,22 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { } const stayMinutes = stop.dwellMinutes - const canEditStay = planner.can('place_edit', planner.trip) + const canEditPlace = planner.can('place_edit', planner.trip) const editStay = () => { shell.openSheet('rtstay', { placeId: stop.placeId, minutes: stayMinutes }) } + // A negative id is the optimistic row of a stop still being saved. The editor would + // write the time against an id the server never issued, and handing it null instead is + // no way round that: the planner then falls back to the place's only visit, which can be + // that same row. So the pencil waits until the stop carries the id the server gave it. + const canOpenEditor = canEditPlace && place != null && stop.assignmentId > 0 + const editPlace = () => { + if (!place) return + planner.openPlaceEditor(place, stop.assignmentId) + shell.closeSheet() + } + const toggleEndDay = async (next: boolean) => { if (!canEndDay || saving) return setPending({ from: endDayTruth, to: next }) @@ -239,9 +262,12 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { return ( - {/* ── Header: the disc the chain draws, the name, the way out ── */} + {/* ── Header: the disc the chain draws, the name, the way to the editor and the way out ── */}
-
+ {/* Centred, not top-aligned: one line of the name is shorter than the 34px disc, so + hung from the top it sat high beside it. A name that wraps to its two lines grows + the row, and the disc and the buttons centre on both, as they do in the chain row. */} +
{row.service ? ( kind && KindIcon ? ( {stop.name} - - - +
+ {canOpenEditor && ( + + + + )} + + + +
@@ -347,7 +380,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { icon={} label={t('roadtrip.stop.stayShort')} value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')} - onClick={canEditStay ? editStay : undefined} + onClick={canEditPlace ? editStay : undefined} />
{navOpen && ( diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index a9027dbb8..e0c671950 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -14,7 +14,7 @@ import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch' import PlCategoryPicker from './PlCategoryPicker' import PlTimeFields from './PlTimeFields' import PlFileAttach from './PlFileAttach' -import type { Place } from '../../../../types' +import type { Assignment, AssignmentsMap, Place } from '../../../../types' import type { TripPlanner } from '../MTripShell' import { useLocationBias } from '../../../../hooks/useLocationBias' @@ -48,6 +48,21 @@ function findDuplicateName( return null } +/** + * The visit the editor was opened on, looked up in the planner's STORED list. + * + * Not planner.assignments: the phone filters booked nights (always) and service stops + * (while the day lists hide them) out of that one, and the road trip stop sheet opens + * this editor on exactly those visits. Looked up there the visit was not found, Start + * prefilled from the pool place, and a plain save wrote that over the time the stop was + * pinned to; the day note was dropped the same way. Every visit the plan tab shows is in + * the stored list as well, so its entry points resolve exactly as they did. + */ +function findVisit(stored: AssignmentsMap, assignmentId: number | null): Assignment | null { + if (!assignmentId) return null + return Object.values(stored).flat().find(a => a.id === assignmentId) ?? null +} + /** * Add/edit place sheet — the mobile counterpart of PlaceFormModal, driven by * the planner's own editor flags (showPlaceForm / editingPlace / prefillCoords / @@ -58,7 +73,7 @@ function findDuplicateName( */ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSheetProps) { const { - t, toast, places, assignments, canUploadFiles, + t, toast, places, assignments, storedAssignments, canUploadFiles, showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, prefillCoords, setPrefillCoords, @@ -84,11 +99,23 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh const [sheetPlace, setSheetPlace] = useState(null) const [sheetAssignmentId, setSheetAssignmentId] = useState(null) - const dayAssignments = useMemo( - () => (sheetPlace ? Object.values(assignments).flat() : []), - [sheetPlace, assignments], + // Live rather than the open-time snapshot, so the note's dirty check compares against + // the note as it stands when Save is tapped. + const ctxAssignment = useMemo( + () => (sheetPlace ? findVisit(storedAssignments, sheetAssignmentId) : null), + [sheetPlace, storedAssignments, sheetAssignmentId], ) + // The overlap warning keeps to what the day lists show: on the plan tab a clash with a + // pump or a booked night the list does not draw would name a stop nobody can see there. + // The visit under edit joins when the lists hide it, because the warning reads the day + // to compare against off that very row. + const dayAssignments = useMemo(() => { + if (!sheetPlace) return [] + const listed = Object.values(assignments).flat() + return ctxAssignment && !listed.some(a => a.id === ctxAssignment.id) ? [...listed, ctxAssignment] : listed + }, [sheetPlace, assignments, ctxAssignment]) + // Prefill on open — same source order as the desktop form: editing place // (times off the in-context assignment), map/POI prefill coords, blank. useEffect(() => { @@ -96,9 +123,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh setSheetPlace(editingPlace) setSheetAssignmentId(editingAssignmentId) if (editingPlace) { - const assignment = editingAssignmentId - ? Object.values(assignments).flat().find(a => a.id === editingAssignmentId) - : null + const assignment = findVisit(storedAssignments, editingAssignmentId) const timeSource = assignment?.place ?? editingPlace setForm({ name: editingPlace.name || '', @@ -143,7 +168,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh setPendingFiles([]) setDuplicateWarning(null) setDeleteArmed(false) - // assignments is a fresh map each load — read at open time only. + // storedAssignments is a fresh map each load, so it is read at open time only. // eslint-disable-next-line react-hooks/exhaustive-deps }, [showPlaceForm, editingPlace, prefillCoords, editingAssignmentId]) @@ -236,7 +261,6 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh // #2163: the per-assignment note only travels when an assignment is in // context AND the value actually changed — same dirty-check as the // desktop form, so an untouched note never produces a PUT. - const ctxAssignment = sheetAssignmentId ? dayAssignments.find(a => a.id === sheetAssignmentId) : null if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) { delete payload.assignment_notes } diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 3f8c892d1..914a0fa8d 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1,5 +1,5 @@ import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' -// FE-TP-ROAD-001 to FE-TP-ROAD-096 +// FE-TP-ROAD-001 to FE-TP-ROAD-097 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -2088,4 +2088,39 @@ describe('useTripPlanner road trip: the phone feed', () => { expect(result.current.showPlaceForm).toBe(true) expect(result.current.placeFormDayId).toBe(5) }) + + it('FE-TP-ROAD-097: a service stop opened for editing on the phone gets the place form with its own visit', async () => { + // The stop sheet's pencil relies on this. The desk popup it would otherwise open has + // no start time, and for a service stop the start time is the only way to pin or + // unpin its arrival (a booked night can also be held by the booking's check-in). + const fuelStop = buildPlace({ id: 102, name: 'Tankstelle', stop_type: 'fuel', lat: 60.39, lng: 5.32 }) + seedTrip({ + places: [fuelStop], + days: [buildDay({ id: 5, day_number: 1 })], + assignments: { '5': [buildAssignment({ id: 12, day_id: 5, order_index: 0, place: fuelStop })] }, + }) + // The STORED mode is on, set here rather than trusted to the suite's beforeEach: with + // it off the form branch would be taken anyway and this test would prove nothing. + sessionStorage.setItem('trip-roadtrip-42', '1') + sessionStorage.setItem('trip-tab-42', 'roadtrip') + + const phone = await renderPhone() + act(() => { phone.result.current.openPlaceEditor(fuelStop, 12) }) + + expect(phone.result.current.roadtripMode).toBe(false) + expect(phone.result.current.stopDraft).toBeNull() + expect(phone.result.current.showPlaceForm).toBe(true) + expect(phone.result.current.editingPlace?.id).toBe(102) + expect(phone.result.current.editingAssignmentId).toBe(12) + phone.unmount() + + // The very same trip at desk width opens the stop popup, so what kept the phone on + // the form is the width alone. + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: desktopWidth }) + const desk = await renderRoadtrip() + act(() => { desk.result.current.openPlaceEditor(fuelStop, 12) }) + + expect(desk.result.current.stopDraft).toMatchObject({ dayId: 5, editing: { placeId: 102, stopType: 'fuel' } }) + expect(desk.result.current.showPlaceForm).toBe(false) + }) }) diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts index a5723f1e7..2e903cb0c 100644 --- a/client/tests/helpers/mobileTrip.ts +++ b/client/tests/helpers/mobileTrip.ts @@ -58,6 +58,9 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner days: [], places: [], assignments: [], + // The unfiltered map the store holds. The place editor looks the visit it was + // opened on up here, so every sheet test that edits a place needs the shape. + storedAssignments: {}, packingItems: [], todoItems: [], categories: [], diff --git a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx index 0119b0671..120ed48bc 100644 --- a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx @@ -10,7 +10,7 @@ import { server } from '../../../helpers/msw/server' import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024, plus FE-MOB-PLEDIT-033 to -035 +// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-040, plus the 009b, 025b and 029b variants // planner.t echoes the key, so every label/placeholder is asserted as its key. const CATEGORIES = [ @@ -93,7 +93,9 @@ describe('MPlaceEditSheet', () => { id: 7, day_id: 2, place_id: 42, order_index: 0, place: { ...EDITED, place_time: '14:00', end_time: '15:00' }, } as unknown as Assignment - setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } }) + // A visit the plan tab lists is in both of the planner's lists. + const visits = { '2': [assignment] } + setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: visits, storedAssignments: visits }) expect(screen.getByText('places.startTime')).toBeInTheDocument() const times = screen.getAllByDisplayValue(/^1[45]:00$/) expect(times.map(el => (el as HTMLInputElement).value)).toEqual(['14:00', '15:00']) @@ -293,7 +295,8 @@ describe('MPlaceEditSheet', () => { it('FE-MOB-PLEDIT-024: blocks the save while the end time is not after the start', () => { const assignment = { id: 7, day_id: 2, place_id: 42, order_index: 0, place: EDITED } as unknown as Assignment - setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } }) + const visits = { '2': [assignment] } + setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: visits, storedAssignments: visits }) const [start, end] = screen.getAllByDisplayValue(/^0[89]:[0-3]0$/) as HTMLInputElement[] expect(submit()).not.toBeDisabled() fireEvent.change(end, { target: { value: '07:00' } }) @@ -466,4 +469,78 @@ describe('MPlaceEditSheet', () => { expect(onOpenExpense).not.toHaveBeenCalled() }) }) + + // ── A visit the day lists hide (the road trip stop sheet opens the editor on them) ── + + describe('a visit the day lists hide', () => { + /** + * A booked night: the phone's filtered `assignments` never carries it, the stored + * map does. Its own times differ from the pool place's (08:00 to 09:30), so a + * lookup in the wrong list shows up as the wrong clock. + */ + const NIGHT = { + id: 7, day_id: 2, place_id: 42, order_index: 0, accommodation_id: 9, notes: 'Late check-in', + place: { ...EDITED, place_time: '10:00', end_time: '11:00' }, + } as unknown as Assignment + + const openOnNight = (overrides: Partial = {}) => setup({ + editingPlace: EDITED, + editingAssignmentId: 7, + assignments: {}, + storedAssignments: { '2': [NIGHT] }, + ...overrides, + }) + + it('FE-MOB-PLEDIT-036: reads the times and the day note off the stored visit', () => { + openOnNight() + const times = screen.getAllByDisplayValue(/^1[01]:00$/) as HTMLInputElement[] + expect(times.map(el => el.value)).toEqual(['10:00', '11:00']) + expect(screen.getByPlaceholderText('places.assignmentNotesPlaceholder')).toHaveValue('Late check-in') + }) + + it('FE-MOB-PLEDIT-037: saving it untouched keeps the pinned time and sends no note', async () => { + const { planner } = openOnNight() + fireEvent.click(submit()) + await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1)) + const payload = vi.mocked(planner.handleSavePlace).mock.calls[0][0] + expect(payload).toMatchObject({ place_time: '10:00', end_time: '11:00' }) + expect(payload).not.toHaveProperty('assignment_notes') + }) + + it('FE-MOB-PLEDIT-038: an edited day note on it travels with the save', async () => { + const { planner } = openOnNight() + fireEvent.change(screen.getByPlaceholderText('places.assignmentNotesPlaceholder'), { target: { value: 'Key box at the door' } }) + fireEvent.click(submit()) + await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1)) + expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ assignment_notes: 'Key box at the door' })) + }) + + it('FE-MOB-PLEDIT-039: clearing Start on it sends the empty time that unpins the stop', async () => { + const { planner } = openOnNight() + const [start] = screen.getAllByDisplayValue('10:00') + fireEvent.change(start, { target: { value: '' } }) + fireEvent.click(submit()) + await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1)) + expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ place_time: '', end_time: '11:00' })) + }) + + it('FE-MOB-PLEDIT-040: the overlap warning names what the day lists show, never a hidden stop', () => { + const listed = { + id: 8, day_id: 2, place_id: 50, order_index: 1, + place: { id: 50, name: 'Kaminarimon', place_time: '10:30', end_time: '11:30' }, + } as unknown as Assignment + const hiddenPump = { + id: 9, day_id: 2, place_id: 51, order_index: 2, + place: { id: 51, name: 'ENEOS Asakusa', stop_type: 'fuel', place_time: '10:15', end_time: '10:45' }, + } as unknown as Assignment + openOnNight({ + assignments: { '2': [listed] }, + storedAssignments: { '2': [NIGHT, listed, hiddenPump] }, + }) + // The night itself is hidden too, and still gets warned: the warning reads its day off it. + const warning = screen.getByText(/places\.timeCollision/) + expect(warning).toHaveTextContent('Kaminarimon') + expect(warning).not.toHaveTextContent('ENEOS') + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx index f423d6807..799b1b0fe 100644 --- a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx @@ -4,13 +4,17 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/ import type { Place } from '../../../../src/types' import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' -import { resetAllStores } from '../../../helpers/store' +import { useTripStore } from '../../../../src/store/tripStore' +import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-027 +// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-034 // // The sheet renders inside the real TranslationProvider, so the copy is asserted // in English. `planner.t` from the fixture is never consulted here. +// +// The place rows come from the trip store, not from the planner: the sheet reads the +// unfiltered list, so renderSheet seeds the store and the planner keeps its empty one. // The charging panel polls a repo of its own every minute; stubbed so the sheet's // wiring can be asserted without a live availability lookup in the background. @@ -93,7 +97,6 @@ const PLACES = [ function makePlanner(overrides: Record = {}) { return buildPlanner({ tripId: 4, - places: PLACES, dailyTimesActive: true, setRoadtripEndDay: vi.fn(async () => undefined), roadtripRoutes: { days: [DAY_A, DAY_B] }, @@ -108,7 +111,12 @@ function makeShell(overrides: Record = {}) { } as unknown as Partial) } -function renderSheet(plannerOverrides: Record = {}, shellOverrides: Record = {}) { +function renderSheet( + plannerOverrides: Record = {}, + shellOverrides: Record = {}, + storePlaces: Place[] = PLACES, +) { + seedStore(useTripStore, { places: storePlaces }) const planner = makePlanner(plannerOverrides) const shell = makeShell(shellOverrides) render() @@ -183,7 +191,7 @@ describe('MRtStopSheet', () => { }) it('FE-MOB-RTSTOP-009: offers the map apps built from the stop itself when no place row matches', () => { - renderSheet({ places: [] }) + renderSheet({}, {}, []) fireEvent.click(screen.getByRole('button', { name: 'Navigation' })) expect(screen.getByRole('menu')).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument() @@ -192,9 +200,11 @@ describe('MRtStopSheet', () => { it('FE-MOB-RTSTOP-010: has no delete, no reorder and no stop-kind control at all', () => { renderSheet() - // Close, navigation, stay, show-on-map, plus the end-day switch, which is a switch. - expect(screen.getAllByRole('button')).toHaveLength(4) + // Close, edit, navigation, stay, show-on-map, plus the end-day switch, which is a switch. + // Edit is a hand-over to the place editor, not a control of this sheet (RTSTOP-030). + expect(screen.getAllByRole('button')).toHaveLength(5) expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Show on map' })).toBeInTheDocument() @@ -310,7 +320,7 @@ describe('MRtStopSheet', () => { it('FE-MOB-RTSTOP-022: one map app means no picker, the tap opens it and names it on the tile', () => { const open = vi.spyOn(window, 'open').mockReturnValue(null) // Name only: no coordinates and no address, so only the OSM search link survives. - renderSheet({ places: [{ id: 203, name: 'Bremen Marktplatz', lat: null, lng: null, address: null }] }) + renderSheet({}, {}, [{ id: 203, name: 'Bremen Marktplatz', lat: null, lng: null, address: null }] as unknown as Place[]) const tile = screen.getByRole('button', { name: /^Navigation/ }) expect(tile).toHaveTextContent('OpenStreetMap') fireEvent.click(tile) @@ -324,7 +334,7 @@ describe('MRtStopSheet', () => { }) it('FE-MOB-RTSTOP-023: no reachable map app at all drops the tile instead of leaving a dead one', () => { - renderSheet({ places: [{ id: 203, name: '', lat: null, lng: null, address: null }] }) + renderSheet({}, {}, [{ id: 203, name: '', lat: null, lng: null, address: null }] as unknown as Place[]) expect(screen.queryByRole('button', { name: /Navigation/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument() }) @@ -358,4 +368,90 @@ describe('MRtStopSheet', () => { renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } }) expect(screen.queryByTestId('charging-info')).not.toBeInTheDocument() }) + + // jsdom has no layout, so the two header tests pin the class contract rather than pixels. + it('FE-MOB-RTSTOP-028: the header centres the name on the disc instead of hanging it from the top', () => { + renderSheet() + const heading = screen.getByRole('heading', { name: 'Bremen Marktplatz' }) + const header = heading.parentElement + expect(header).toHaveClass('items-center') + expect(header).not.toHaveClass('items-start') + // Disc, name and buttons are one row, so they share that one cross-axis alignment. + expect(heading.previousElementSibling).toHaveTextContent('2') + const buttons = heading.nextElementSibling + expect(buttons).toHaveClass('items-center') + expect(buttons).toContainElement(screen.getByRole('button', { name: 'Close' })) + expect(buttons).toContainElement(screen.getByRole('button', { name: 'Edit' })) + }) + + it('FE-MOB-RTSTOP-029: a service stop with a long name centres the same way and wraps rather than truncating', () => { + const longName = 'Aral Tankstelle an der Autobahnausfahrt Hamburg Dammtor Nord' + renderSheet( + { roadtripRoutes: { days: [{ ...DAY_A, stops: [HAMBURG, { ...ARAL, name: longName }, BREMEN] }, DAY_B] } }, + { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } }, + ) + const heading = screen.getByRole('heading', { name: longName }) + expect(heading.parentElement).toHaveClass('items-center') + expect(heading).toHaveClass('line-clamp-2') + expect(heading).not.toHaveClass('truncate') + expect(heading.previousElementSibling?.querySelector('svg')).not.toBeNull() + }) + + it('FE-MOB-RTSTOP-030: the pencil opens the place editor on this very visit and closes the sheet', () => { + const { planner, shell } = renderSheet() + const edit = screen.getByRole('button', { name: 'Edit' }) + // Before the way out, so the close button keeps the corner it has on every sheet. + expect(edit.compareDocumentPosition(screen.getByRole('button', { name: 'Close' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(edit) + expect(planner.openPlaceEditor).toHaveBeenCalledTimes(1) + expect(planner.openPlaceEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 203, address: 'Am Markt' }), 103) + expect(shell.closeSheet).toHaveBeenCalledTimes(1) + // The stop sheet does not hand over to a stay or a map on the way. + expect(shell.openSheet).not.toHaveBeenCalled() + }) + + it('FE-MOB-RTSTOP-031: somebody who may not edit places gets no pencil at all', () => { + // Every right but place_edit, so it is that one right the pencil answers to. + const { planner } = renderSheet({ can: vi.fn((action: string) => action !== 'place_edit') }) + // Absent rather than disabled, the same rule as every other write on this sheet. + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + // The night switch answers to day_edit and stays, so no other right took the pencil away. + expect(screen.getByRole('switch', { name: 'End the day here' })).toBeInTheDocument() + expect(planner.can).toHaveBeenCalledWith('place_edit', planner.trip) + }) + + it('FE-MOB-RTSTOP-032: a pump hidden from the day lists is still editable from the stage', () => { + // The planner's filtered list lacks the pump, the store still holds it. + const pump = { id: 202, name: 'Aral Dammtor', lat: 53.56, lng: 9.99, stop_type: 'fuel' } as unknown as Place + const { planner } = renderSheet( + { places: PLACES }, + { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } }, + [...PLACES, pump], + ) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(planner.openPlaceEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 202 }), 102) + }) + + it('FE-MOB-RTSTOP-033: a stop still being saved under its optimistic id offers no pencil yet', () => { + const saving = stop({ assignmentId: -5, placeId: 203, name: 'Bremen Marktplatz', ownerIndex: 0, dwellMinutes: 90 }) + const day = { + ...DAY_A, + stops: [saving], + schedule: { entries: [{ arrival: '13:05', departure: '14:35', anchored: false, dayOffset: 0 }], warnings: [] }, + driveWarnings: [], + } as unknown as RoadtripDay + const { planner } = renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: -5 } } }) + expect(screen.getByRole('dialog', { name: 'Bremen Marktplatz' })).toBeInTheDocument() + // Neither the temporary id nor a null the planner would resolve back to that same row. + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(planner.openPlaceEditor).not.toHaveBeenCalled() + }) + + it('FE-MOB-RTSTOP-034: a stop whose place row is not in the trip store offers no pencil', () => { + // The editor needs the whole row; the stop's name and position are enough for directions only. + renderSheet({}, {}, []) + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument() + }) })