mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}) => (
|
||||
<input
|
||||
{...aria}
|
||||
data-testid="time-picker"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => 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(
|
||||
<PlaceFormModal {...defaultProps} place={place} assignmentId={42} dayAssignments={[assignment]} tour={tour} />
|
||||
);
|
||||
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(<PlaceFormModal {...defaultProps} place={place} assignmentId={42} dayAssignments={[assignment]} />);
|
||||
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 });
|
||||
|
||||
@@ -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(
|
||||
<PlaceFormModal {...placeFormProps} place={place} assignmentId={42} dayAssignments={[assignment]} tour={tour} />
|
||||
);
|
||||
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(<PlaceFormModal {...placeFormProps} place={place} assignmentId={42} dayAssignments={[assignment]} />);
|
||||
expect(screen.queryByTestId('tour-day-end-hint')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('places.startTime')}>
|
||||
<CustomTimePicker
|
||||
value={form.place_time}
|
||||
onChange={v => handleChange('place_time', v)}
|
||||
aria-label={t('places.startTime')}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('places.endTime')} hint={endIsLeave ? t('roadtrip.stop.endIsLeave') : undefined}>
|
||||
<CustomTimePicker
|
||||
value={form.end_time}
|
||||
onChange={v => handleChange('end_time', v)}
|
||||
aria-label={t('places.endTime')}
|
||||
aria-invalid={hasTimeError}
|
||||
aria-describedby={hasTimeError ? errorId : undefined}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<TourEndHint start={form.place_time} end={form.end_time} assignment={dayAssignments.find(a => a.id === assignmentId)} tour={tour} />
|
||||
{hasTimeError && (
|
||||
<div id={errorId} className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
{t('places.endTimeBeforeStart')}
|
||||
</div>
|
||||
)}
|
||||
{collisions.length > 0 && (
|
||||
<div className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t('places.timeCollision')}{' '}
|
||||
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, string | number>) => 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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('places.startTime')}>
|
||||
<CustomTimePicker
|
||||
value={form.place_time}
|
||||
onChange={(v) => handleChange('place_time', v)}
|
||||
aria-label={t('places.startTime')}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('places.endTime')} hint={endIsLeave ? t('roadtrip.stop.endIsLeave') : undefined}>
|
||||
<CustomTimePicker
|
||||
value={form.end_time}
|
||||
onChange={(v) => handleChange('end_time', v)}
|
||||
aria-label={t('places.endTime')}
|
||||
aria-invalid={hasTimeError}
|
||||
aria-describedby={hasTimeError ? errorId : undefined}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<TourEndHint
|
||||
start={form.place_time}
|
||||
end={form.end_time}
|
||||
assignment={dayAssignments.find((a) => a.id === assignmentId)}
|
||||
tour={tour}
|
||||
/>
|
||||
{hasTimeError && (
|
||||
<div id={errorId} className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
{t('places.endTimeBeforeStart')}
|
||||
</div>
|
||||
)}
|
||||
{collisions.length > 0 && (
|
||||
<div className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t('places.timeCollision')}{' '}
|
||||
{collisions
|
||||
.map((a) => a.place?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)';
|
||||
@@ -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 {
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { 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} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={placeForm.close} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={placeForm.dayAssignments} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} tour={placeForm.tour} />
|
||||
<TripFormModal
|
||||
isOpen={showTripForm}
|
||||
onClose={() => setShowTripForm(false)}
|
||||
|
||||
@@ -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<string, Assignment[]>,
|
||||
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) },
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<input
|
||||
{...aria}
|
||||
data-testid="time-picker"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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 }) });
|
||||
}
|
||||
Reference in New Issue
Block a user