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 }) }); +}