From 786df612fd373247e5051bb80ac9782fd6daa147 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 22:02:48 +0200 Subject: [PATCH] refactor(planner): split the tour day time growth out of four size-capped files The place form's time section moves into PlaceFormTimeSection, its tour test into PlaceFormModal.tour.test.tsx over a shared tests/helpers/placeForm setup, the page hook hands the place form its close, day plan and tour, and the day plan's surface classes and tones move into dayPlanSurfaces. The size baseline drops to the new counts. --- client/scripts/size-baseline.json | 6 +- .../src/components/Planner/DayPlanSidebar.tsx | 15 +-- .../Planner/PlaceFormModal.test.tsx | 74 ++------------- .../Planner/PlaceFormModal.tour.test.tsx | 51 ++++++++++ .../src/components/Planner/PlaceFormModal.tsx | 68 +------------- .../Planner/PlaceFormTimeSection.tsx | 93 +++++++++++++++++++ .../src/components/Planner/dayPlanSurfaces.ts | 21 +++++ client/src/pages/TripPlannerPage.tsx | 7 +- .../pages/tripPlanner/useTripPlannerPage.ts | 29 ++++++ client/tests/helpers/placeForm.tsx | 56 +++++++++++ 10 files changed, 268 insertions(+), 152 deletions(-) create mode 100644 client/src/components/Planner/PlaceFormModal.tour.test.tsx create mode 100644 client/src/components/Planner/PlaceFormTimeSection.tsx create mode 100644 client/src/components/Planner/dayPlanSurfaces.ts create mode 100644 client/tests/helpers/placeForm.tsx diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 6ad44b8a4..474a25cb1 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -12,9 +12,9 @@ "src/components/PDF/TripPDF.tsx": 1082, "src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayPlanSidebar.test.tsx": 5378, - "src/components/Planner/DayPlanSidebar.tsx": 2969, - "src/components/Planner/PlaceFormModal.test.tsx": 2864, - "src/components/Planner/PlaceFormModal.tsx": 1250, + "src/components/Planner/DayPlanSidebar.tsx": 2963, + "src/components/Planner/PlaceFormModal.test.tsx": 2836, + "src/components/Planner/PlaceFormModal.tsx": 1193, "src/components/Planner/PlaceInspector.tsx": 1304, "src/components/Studio/TravelElements.tsx": 1738, "src/components/Studio/autoLayout.ts": 1691, diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 0c04bc74b..bfcf2a17e 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -39,10 +39,6 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell' import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts' import './dayPlanRows.css' -/** The round white button a row carries on its right (a route on the map, a journey's legs). */ -const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors' -/** The white face every control of a day's route bar shares, so the row reads as one set. */ -const ROUTE_TOOL = 'bg-surface-card shadow-sm ring-1 ring-edge-faint' import { TypeTile } from './bookings/bookingParts' import { typeInfo } from './bookings/bookingsModel' import { dayHeadStays } from './dayHeadStays' @@ -89,6 +85,7 @@ import type { TourPlannedTimes } from '../Tours/tourPresentation' import type { TourListItem } from '@trek/shared' import { TourDayRowAvatar } from '../Tours/TourDayRowFacts' import { DayPlanPlaceSubline } from './DayPlanPlaceSubline' +import { DAY_HEAD_DROP, DAY_HEAD_HOVER, DAY_HEAD_SELECTED, LOCK_OVERLAY, LOCK_OVERLAY_HOVER, LOCKED_ROW, ROUTE_TOOL, ROW_ROUND } from './dayPlanSurfaces' export interface DayPlanSidebarProps { tripId: number @@ -194,16 +191,6 @@ export interface DayPlanSidebarProps { * days (places, transports, notes). Returns everything the timeline view renders * from, keeping DayPlanSidebar a thin shell over one large day list. */ -// The day head's tones: hovered, open, and a drop landing on it. Mixed from the -// accent so they follow the user's scheme in both themes. -const DAY_HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)' -const DAY_HEAD_SELECTED = 'color-mix(in srgb, var(--accent) 12%, transparent)' -const DAY_HEAD_DROP = 'color-mix(in srgb, var(--accent) 9%, transparent)' -// A stop pinned in place: a whisper of the danger tone on the row, a stronger one over its avatar. -const LOCKED_ROW = 'color-mix(in srgb, var(--danger) 7%, transparent)' -const LOCK_OVERLAY = 'color-mix(in srgb, var(--danger) 62%, transparent)' -const LOCK_OVERLAY_HOVER = 'color-mix(in srgb, var(--danger) 42%, transparent)' - function useDayPlanSidebar(props: DayPlanSidebarProps) { const { tripId, diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 3a26ba0e5..47bfb6925 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -1,6 +1,5 @@ -// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -125 +// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -124 import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; -import type { TourListItem } from '@trek/shared'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -12,47 +11,19 @@ import { usePermissionsStore } from '../../store/permissionsStore'; import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories'; +import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeForm'; import PlaceFormModal from './PlaceFormModal'; -// Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI. -// The aria props land on the input, as the real picker spreads them onto its own. -vi.mock('../shared/CustomTimePicker', () => ({ - default: ({ value, onChange, placeholder, ...aria }: { - value: string - onChange: (v: string) => void - placeholder?: string - 'aria-label'?: string - 'aria-describedby'?: string - 'aria-invalid'?: boolean - }) => ( - onChange(e.target.value)} - placeholder={placeholder ?? '00:00'} - /> - ), +// CustomTimePicker as a plain text input, see TimePickerStub. +vi.mock('../shared/CustomTimePicker', async () => ({ + default: (await import('../../../tests/helpers/placeForm')).TimePickerStub, })); -const defaultProps = { - isOpen: true, - onClose: vi.fn(), - onSave: vi.fn(), - place: null, - prefillCoords: null, - tripId: 1, - categories: [], - onCategoryCreated: vi.fn(), - assignmentId: null, - dayAssignments: [], -}; +const defaultProps = placeFormProps; beforeEach(() => { resetAllStores(); - seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false }); - seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); + seedPlaceFormStores(); }); describe('PlaceFormModal', () => { @@ -879,37 +850,6 @@ describe('PlaceFormModal', () => { expect(screen.getByDisplayValue('20:34')).toBeInTheDocument(); }); - it('FE-PLANNER-PLACEFORM-125: a tour with a start and no end says below its times the end it works out to (TIME-02)', () => { - const place = buildPlace({ id: 7, name: 'Ridge', place_time: '09:00', end_time: null }); - const assignment = buildAssignment({ id: 42, day_id: 3, place }); - const tour: TourListItem = { - place_id: 7, - name: 'Ridge', - tour_type: 'hike', - distance: 8, - elevation_gain: 300, - elevation_loss: 300, - duration: 150, - planned_duration_minutes: null, - break_additional_minutes: 30, - difficulty: null, - wanderer_ref: null, - match_confidence: 1, - max_hiking_difficulty: 2, - planned: true, - caution: false, - }; - const { unmount } = render( - - ); - expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent( - 'Left empty, the end is worked out: 12:00, the start plus the tour time of 3 h.' - ); - unmount(); - render(); - expect(screen.queryByTestId('tour-day-end-hint')).not.toBeInTheDocument(); - }); - it('FE-PLANNER-PLACEFORM-086: on a road trip the End says the drive leaves at it', () => { const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' }); const assignment = buildAssignment({ id: 10, day_id: 5, place }); diff --git a/client/src/components/Planner/PlaceFormModal.tour.test.tsx b/client/src/components/Planner/PlaceFormModal.tour.test.tsx new file mode 100644 index 000000000..3b1fa0cd6 --- /dev/null +++ b/client/src/components/Planner/PlaceFormModal.tour.test.tsx @@ -0,0 +1,51 @@ +// FE-PLANNER-PLACEFORM-125: the place form of a tour (TIME-02) +import type { TourListItem } from '@trek/shared'; + +import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'; +import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeForm'; +import { render, screen } from '../../../tests/helpers/render'; +import { resetAllStores } from '../../../tests/helpers/store'; +import PlaceFormModal from './PlaceFormModal'; + +// CustomTimePicker as a plain text input, see TimePickerStub. +vi.mock('../shared/CustomTimePicker', async () => ({ + default: (await import('../../../tests/helpers/placeForm')).TimePickerStub, +})); + +beforeEach(() => { + resetAllStores(); + seedPlaceFormStores(); +}); + +describe('PlaceFormModal for a tour', () => { + it('FE-PLANNER-PLACEFORM-125: a tour with a start and no end says below its times the end it works out to (TIME-02)', () => { + const place = buildPlace({ id: 7, name: 'Ridge', place_time: '09:00', end_time: null }); + const assignment = buildAssignment({ id: 42, day_id: 3, place }); + const tour: TourListItem = { + place_id: 7, + name: 'Ridge', + tour_type: 'hike', + distance: 8, + elevation_gain: 300, + elevation_loss: 300, + duration: 150, + planned_duration_minutes: null, + break_additional_minutes: 30, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: 2, + planned: true, + caution: false, + }; + const { unmount } = render( + + ); + expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent( + 'Left empty, the end is worked out: 12:00, the start plus the tour time of 3 h.' + ); + unmount(); + render(); + expect(screen.queryByTestId('tour-day-end-hint')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index fa33afb50..8ce0dbb55 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -10,15 +10,14 @@ import CollectionPicker from '../Collections/CollectionPicker' import PlaceDetailsColumn from './PlaceDetailsColumn' import { useToast } from '../shared/Toast' import { Tooltip } from '../shared/Tooltip' -import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' +import { Search, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' import { useTranslation } from '../../i18n' -import CustomTimePicker from '../shared/CustomTimePicker' import { PlaceContactFields } from './PlaceContactFields' import { weekFromPeriods } from './placeHours' import type { PlaceOpeningHours } from '@trek/shared' import { DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload, - prefillForm, timeCollisions, type PlaceFormData, + prefillForm, type PlaceFormData, } from './PlaceFormModal.helpers' import { useNewCategory, usePlaceForm } from './usePlaceForm' import { usePlaceSearch } from './usePlaceSearch' @@ -43,7 +42,7 @@ import { import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts' import { SoftPill, tintOf } from './planParts' import { WHITE_BUTTON } from './placeDialogParts' -import { TourEndHint } from '../Tours/TourDayTimeParts' +import { TimeSection } from './PlaceFormTimeSection' // The submit payload mirrors the form, but lat/lng are parsed to numbers and // category_id is normalised, plus any files chosen before the place existed. @@ -472,7 +471,6 @@ const STACK = 'flex flex-col gap-5' const FORM_COLUMN = 'flex min-w-0 flex-1 flex-col gap-4' /** A row of the typed-ahead list or of the search results. */ const PICK_ROW = 'flex w-full items-center gap-2 rounded-[8px] px-2.5 py-2 text-start' -const WARNING_BANNER = 'flex items-start gap-1.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5 text-warning' /** The small accent square beside a field that runs its action, sized by the row it sits in. */ const SIDE_BUTTON = 'grid w-[38px] flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-60' /** @@ -1160,63 +1158,3 @@ function PendingFiles({ files, fileRef, onAdd, onRemove, t }: { ) } - -interface TimeSectionProps { - form: PlaceFormData - handleChange: (field: string, value: string) => void - assignmentId: number | null - dayAssignments: Assignment[] - hasTimeError: boolean - /** On a road trip the End is when the drive leaves, which the field says. In Days it - * stays the plain label it has always been: nothing is scheduled off it there. */ - endIsLeave: boolean - tour?: TourListItem | null - t: Translate -} - -function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, tour, t }: TimeSectionProps) { - const collisions = useMemo( - () => timeCollisions(assignmentId, dayAssignments, form.place_time, form.end_time), - [assignmentId, dayAssignments, form.place_time, form.end_time], - ) - const errorId = useId() - - return ( -
-
- - handleChange('place_time', v)} - aria-label={t('places.startTime')} - /> - - - handleChange('end_time', v)} - aria-label={t('places.endTime')} - aria-invalid={hasTimeError} - aria-describedby={hasTimeError ? errorId : undefined} - /> - -
- a.id === assignmentId)} tour={tour} /> - {hasTimeError && ( -
-
- )} - {collisions.length > 0 && ( -
-
- )} -
- ) -} diff --git a/client/src/components/Planner/PlaceFormTimeSection.tsx b/client/src/components/Planner/PlaceFormTimeSection.tsx new file mode 100644 index 000000000..429959375 --- /dev/null +++ b/client/src/components/Planner/PlaceFormTimeSection.tsx @@ -0,0 +1,93 @@ +import type { TourListItem } from '@trek/shared'; +import { AlertTriangle } from 'lucide-react'; +import { useId, useMemo } from 'react'; + +import type { Assignment } from '../../types'; +import CustomTimePicker from '../shared/CustomTimePicker'; +import { EditorField, GRID_2 } from '../shared/dialogParts'; +import { fs } from '../shared/DialogShell'; +import { TourEndHint } from '../Tours/TourDayTimeParts'; +import { timeCollisions, type PlaceFormData } from './PlaceFormModal.helpers'; + +type Translate = (key: string, params?: Record) => string; + +const WARNING_BANNER = 'flex items-start gap-1.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5 text-warning'; + +interface TimeSectionProps { + form: PlaceFormData; + handleChange: (field: string, value: string) => void; + assignmentId: number | null; + dayAssignments: Assignment[]; + hasTimeError: boolean; + /** On a road trip the End is when the drive leaves, which the field says. In Days it + * stays the plain label it has always been: nothing is scheduled off it there. */ + endIsLeave: boolean; + /** TIME-02: the tour this place is, whose end the time fields work out from its start. */ + tour?: TourListItem | null; + t: Translate; +} + +/** The place form's start and end time, with the warnings about them and a tour's worked-out end. */ +export function TimeSection({ + form, + handleChange, + assignmentId, + dayAssignments, + hasTimeError, + endIsLeave, + tour, + t, +}: TimeSectionProps) { + const collisions = useMemo( + () => timeCollisions(assignmentId, dayAssignments, form.place_time, form.end_time), + [assignmentId, dayAssignments, form.place_time, form.end_time] + ); + const errorId = useId(); + + return ( +
+
+ + handleChange('place_time', v)} + aria-label={t('places.startTime')} + /> + + + handleChange('end_time', v)} + aria-label={t('places.endTime')} + aria-invalid={hasTimeError} + aria-describedby={hasTimeError ? errorId : undefined} + /> + +
+ a.id === assignmentId)} + tour={tour} + /> + {hasTimeError && ( +
+
+ )} + {collisions.length > 0 && ( +
+
+ )} +
+ ); +} diff --git a/client/src/components/Planner/dayPlanSurfaces.ts b/client/src/components/Planner/dayPlanSurfaces.ts new file mode 100644 index 000000000..cf7e03d95 --- /dev/null +++ b/client/src/components/Planner/dayPlanSurfaces.ts @@ -0,0 +1,21 @@ +/* + * The surfaces the day plan draws its rows and day heads on: the class strings + * of the white controls a row and a route bar carry, and the colour mixes a day + * head and a pinned stop take. Mixed from the scheme's tokens, so they follow + * the user's accent and both themes. + */ + +/** The round white button a row carries on its right (a route on the map, a journey's legs). */ +export const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors'; +/** The white face every control of a day's route bar shares, so the row reads as one set. */ +export const ROUTE_TOOL = 'bg-surface-card shadow-sm ring-1 ring-edge-faint'; + +// The day head's tones: hovered, open, and a drop landing on it. Mixed from the +// accent so they follow the user's scheme in both themes. +export const DAY_HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)'; +export const DAY_HEAD_SELECTED = 'color-mix(in srgb, var(--accent) 12%, transparent)'; +export const DAY_HEAD_DROP = 'color-mix(in srgb, var(--accent) 9%, transparent)'; +// A stop pinned in place: a whisper of the danger tone on the row, a stronger one over its avatar. +export const LOCKED_ROW = 'color-mix(in srgb, var(--danger) 7%, transparent)'; +export const LOCK_OVERLAY = 'color-mix(in srgb, var(--danger) 62%, transparent)'; +export const LOCK_OVERLAY_HOVER = 'color-mix(in srgb, var(--danger) 42%, transparent)'; diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 87c791f83..824cda6b6 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -84,13 +84,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null { showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed, stayPickerDayId, setStayPickerDayId, showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, setPlaceFormDayId, - prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, + prefillCoords, editingAssignmentId, setEditingAssignmentId, stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop, setRoadtripStopKind, setRoadtripStopFill, saveRoadtripLimit, roadtripSettingsLoading, storedAssignments, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries, - openManualRoadtripStop, serviceStopMode, setServiceStopForm, + openManualRoadtripStop, serviceStopMode, routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls, stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, @@ -143,6 +143,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { tourMap, locationSearch, dayPlanTours, + placeForm, } = pageState // Tablets run this very layout but cannot start an HTML5 drag with a finger, // so a long press stands in for one (#1616). Only where the pointer is @@ -831,7 +832,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { /> )} - { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} tour={editingPlace ? tours.find(item => item.place_id === editingPlace.id) ?? null : null} /> + tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} tour={placeForm.tour} /> setShowTripForm(false)} diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 20392074a..f85dc6268 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { TourListItem } from '@trek/shared' +import type { Assignment } from '../../types' import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' import { placeActions } from '../../components/Planner/usePlaceActions' import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle' @@ -16,6 +18,22 @@ import { useTripPlanner } from './useTripPlanner' import { useTourWorkspace } from './useTourWorkspace' import { mapFocusKeyOf, offeredWaysOf, paddleLineOf } from './tourMapFocus' +/** + * The place form's day context: while a place is edited, the whole day plan to check its times + * against and, TIME-02, the tour that place is, whose end the time fields work out from its start. + */ +function placeFormContext( + editingPlace: { id: number } | null, + assignments: Record, + tours: readonly TourListItem[], +): { dayAssignments: Assignment[]; tour: TourListItem | null } { + if (!editingPlace) return { dayAssignments: [], tour: null } + return { + dayAssignments: Object.values(assignments).flat(), + tour: tours.find(item => item.place_id === editingPlace.id) ?? null, + } +} + export function useTripPlannerPage() { const planner = useTripPlanner() const { @@ -99,6 +117,16 @@ export function useTripPlannerPage() { reportViewport(bounds) }, [lastPlanMapViewportRef, reportViewport]) + // The place form closes with everything it was opened with cleared. + const { setShowPlaceForm, setEditingPlace, setEditingAssignmentId, setPrefillCoords, setServiceStopForm } = planner + const closePlaceForm = () => { + setShowPlaceForm(false) + setEditingPlace(null) + setEditingAssignmentId(null) + setPrefillCoords(null) + setServiceStopForm(false) + } + // Each tour's planned times keyed by its place, for the day plan's tour rows. const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const)) @@ -136,5 +164,6 @@ export function useTripPlannerPage() { locationSearch, // The day plan's tours: which places are tours, their planned times and where their stages run (MDT-02). dayPlanTours: { tourPlaceIds: planner.tourPlaceIds, plannedTourDurations, tourStageStops: planner.tourStageStops }, + placeForm: { close: closePlaceForm, ...placeFormContext(planner.editingPlace, planner.assignments, tours) }, } } \ No newline at end of file diff --git a/client/tests/helpers/placeForm.tsx b/client/tests/helpers/placeForm.tsx new file mode 100644 index 000000000..d7b99e0b3 --- /dev/null +++ b/client/tests/helpers/placeForm.tsx @@ -0,0 +1,56 @@ +import { vi } from 'vitest'; + +import { useAuthStore } from '../../src/store/authStore'; +import { useTripStore } from '../../src/store/tripStore'; +import { buildTrip, buildUser } from './factories'; +import { seedStore } from './store'; + +/** + * Stands in for CustomTimePicker in the place form's tests: a plain text input + * instead of the portal-heavy picker. The aria props land on the input, as the + * real picker spreads them onto its own. Hand it to vi.mock as the default export. + */ +export function TimePickerStub({ + value, + onChange, + placeholder, + ...aria +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + 'aria-label'?: string; + 'aria-describedby'?: string; + 'aria-invalid'?: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder ?? '00:00'} + /> + ); +} + +/** The props an open place form gets when a test sets none: a new place in trip 1. */ +export const placeFormProps = { + isOpen: true, + onClose: vi.fn(), + onSave: vi.fn(), + place: null, + prefillCoords: null, + tripId: 1, + categories: [], + onCategoryCreated: vi.fn(), + assignmentId: null, + dayAssignments: [], +}; + +/** A signed-in user without a maps key, in trip 1: what the form reads before it renders. */ +export function seedPlaceFormStores() { + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false }); + seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); +}