mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date. The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give. On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-APISURF-001 to FE-APISURF-057
|
||||
// FE-APISURF-001 to FE-APISURF-058
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -572,6 +572,11 @@ describe('client > request payloads', () => {
|
||||
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
|
||||
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
|
||||
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
|
||||
})
|
||||
|
||||
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
|
||||
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
|
||||
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place, Trip } from '../types'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
@@ -434,7 +434,10 @@ export const tripsApi = {
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
// `trip` comes along when the new day changed the trip itself: a dated day
|
||||
// (`dated: true`) moved its end date, an insert at a position grew it by a day.
|
||||
create: (tripId: number | string, data: DayCreateRequest): Promise<{ day: Day; trip?: Trip }> =>
|
||||
apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
// Whole-day default route mode (#1281); per-segment leg modes override it.
|
||||
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { CalendarPlus, Plus } from 'lucide-react'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
|
||||
interface DayAddFooterProps {
|
||||
/** Without it, or on a trip without dates, the footer keeps its single "Add day" button. */
|
||||
dayAdd?: DayAddControls
|
||||
onAddDay: () => void
|
||||
onClose: () => void
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
const outlineBtn =
|
||||
'flex items-center justify-center gap-1.5 rounded-lg border border-edge py-2 text-body font-medium transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent'
|
||||
const primaryBtn =
|
||||
'flex items-center justify-center gap-1.5 rounded-lg bg-accent py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-accent'
|
||||
|
||||
/**
|
||||
* The foot of the reorder dialog: close it, or add a day.
|
||||
*
|
||||
* On a trip with dates there are two ways to add one, side by side. A day
|
||||
* without a date goes to the end and leaves the trip dates alone; the next
|
||||
* calendar day, named on its button, extends the trip by one. The line under
|
||||
* them says what the button in view does, and switches while the pointer or the
|
||||
* focus rests on the other one. When no day can be added it says why instead.
|
||||
*/
|
||||
export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) {
|
||||
const [aboutUndated, setAboutUndated] = useState(false)
|
||||
const hintId = useId()
|
||||
const busy = dayAdd?.busy ?? false
|
||||
const blocked = dayAdd?.blocked ?? null
|
||||
const addOff = busy || !!blocked
|
||||
|
||||
const closeButton = (
|
||||
<button type="button" onClick={onClose} className={`${outlineBtn} flex-shrink-0 px-4 text-content-muted`}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!dayAdd?.nextDate) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{closeButton}
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{blocked && <span className="min-w-0 text-caption text-content-faint">{blocked}</span>}
|
||||
<button type="button" onClick={onAddDay} disabled={addOff} className={`${primaryBtn} flex-shrink-0 px-4`}>
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const date = formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
|
||||
const hint = blocked
|
||||
?? (aboutUndated ? t('dayplan.addUndatedDayHint') : dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }))
|
||||
const pointAtUndated = {
|
||||
onMouseEnter: () => setAboutUndated(true),
|
||||
onMouseLeave: () => setAboutUndated(false),
|
||||
onFocus: () => setAboutUndated(true),
|
||||
onBlur: () => setAboutUndated(false),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddDay}
|
||||
disabled={addOff}
|
||||
aria-describedby={hintId}
|
||||
className={`${outlineBtn} min-w-0 px-3 text-center leading-tight text-content-secondary`}
|
||||
{...pointAtUndated}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2} className="flex-shrink-0" />
|
||||
{t('dayplan.addUndatedDay')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dayAdd.onAddDated}
|
||||
disabled={addOff || !!dayAdd.datedBlocked}
|
||||
aria-describedby={hintId}
|
||||
className={`${primaryBtn} min-w-0 px-3 text-center leading-tight`}
|
||||
>
|
||||
<CalendarPlus size={15} strokeWidth={2} className="flex-shrink-0" />
|
||||
{t('dayplan.addDatedDay', { date })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p id={hintId} aria-live="polite" className="m-0 min-w-0 text-caption text-content-faint">
|
||||
{hint}
|
||||
</p>
|
||||
{closeButton}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
|
||||
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
|
||||
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
|
||||
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
|
||||
@@ -76,6 +77,8 @@ interface DayPlanSidebarProps {
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
/** The planner's add controls, for the second "Add with date" button on a trip with dates. */
|
||||
dayAdd?: DayAddControls
|
||||
/** Asks to delete a day from the reorder dialog; the planner owns the question. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
|
||||
@@ -148,7 +151,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay, onDeleteDay,
|
||||
onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay,
|
||||
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -1131,6 +1134,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
dayAdd,
|
||||
onDeleteDay,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
@@ -1334,6 +1338,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
dayAdd,
|
||||
onDeleteDay,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
@@ -1594,6 +1599,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
canManageShare={canManageShare}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-024
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-025
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -243,6 +243,23 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(11)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-025: the popup gets the planner add controls, and its dated button adds the next date', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
|
||||
const onAddDated = vi.fn()
|
||||
const onAddDay = vi.fn()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({
|
||||
days, canEditDays: true, onReorderDays: vi.fn(), onAddDay,
|
||||
dayAdd: { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated },
|
||||
})} />)
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
||||
await user.click(screen.getByRole('button', { name: /^dayplan\.addDatedDay/ }))
|
||||
expect(onAddDated).toHaveBeenCalledTimes(1)
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
|
||||
// The popup asks for an append, never a position.
|
||||
expect(onAddDay).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => {
|
||||
const days = [buildDay({ id: 10 })]
|
||||
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
||||
|
||||
@@ -5,6 +5,7 @@ import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
@@ -37,6 +38,8 @@ interface DayPlanSidebarToolbarProps {
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
/** The planner's add controls: on a trip with dates the dialog offers the next date as well. */
|
||||
dayAdd?: DayAddControls
|
||||
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
}
|
||||
@@ -46,7 +49,7 @@ export function DayPlanSidebarToolbar({
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, onDeleteDay,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -199,6 +202,7 @@ export function DayPlanSidebarToolbar({
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-016
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-021
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { buildDay } from '../../../tests/helpers/factories'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
// The component takes `t` as a prop, so returning the key keeps assertions exact.
|
||||
@@ -181,4 +182,82 @@ describe('DayReorderPopup', () => {
|
||||
expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adding a day', () => {
|
||||
const tWithDate = (key: string, params?: Record<string, unknown>) => (params?.date ? `${key}|${params.date}` : key)
|
||||
const controls = (overrides: Partial<DayAddControls> = {}): DayAddControls => ({
|
||||
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-017: a trip with dates offers a day without a date and the next date, by name', () => {
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), t: tWithDate, dayAdd: controls() })} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /^dayplan\.addDatedDay\|.*Oct 13/ })).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.addDay' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/^dayplan\.addDatedDayHint\|.*Oct 13/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-018: each button adds its own kind of day', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAddDay = vi.fn()
|
||||
const dayAdd = controls()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd })} />)
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
|
||||
expect(onAddDay).toHaveBeenCalledTimes(1)
|
||||
expect(dayAdd.onAddDated).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.addDatedDay' }))
|
||||
expect(dayAdd.onAddDated).toHaveBeenCalledTimes(1)
|
||||
expect(onAddDay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-019: a trip without dates keeps the single button, which waits for the connection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAddDay = vi.fn()
|
||||
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null }) })} />)
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.addDatedDay' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.addDay' }))
|
||||
expect(onAddDay).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null, blocked: 'dayplan.daysOffline' }) })} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addDay' })).toBeDisabled()
|
||||
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-020: busy or offline turns both off, a trip at the day limit only the dated one, and the line says why', () => {
|
||||
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ busy: true }) })} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
|
||||
|
||||
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ blocked: 'dayplan.daysOffline' }) })} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
|
||||
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
|
||||
|
||||
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ datedBlocked: 'dashboard.tripTooLong' }) })} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
|
||||
expect(screen.getByText('dashboard.tripTooLong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-021: the line under the buttons follows the pointer and the focus onto the undated one', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls() })} />)
|
||||
const undated = screen.getByRole('button', { name: 'dayplan.addUndatedDay' })
|
||||
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
|
||||
|
||||
await user.hover(undated)
|
||||
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
|
||||
await user.unhover(undated)
|
||||
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
|
||||
|
||||
fireEvent.focus(undated)
|
||||
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
|
||||
fireEvent.blur(undated)
|
||||
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
|
||||
// Both buttons are described by that line.
|
||||
const hintId = screen.getByText('dayplan.addDatedDayHint').id
|
||||
expect(undated).toHaveAttribute('aria-describedby', hintId)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { DayAddFooter } from './DayAddFooter'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { dayLabel } from '../../utils/dayLabel'
|
||||
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
@@ -14,6 +16,11 @@ interface DayReorderPopupProps {
|
||||
locale: string
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
onAddDay: () => void
|
||||
/**
|
||||
* The planner's add controls. On a trip with dates they add a second button for
|
||||
* the next calendar day; without them the footer keeps its single "Add day".
|
||||
*/
|
||||
dayAdd?: DayAddControls
|
||||
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
onClose: () => void
|
||||
@@ -21,12 +28,13 @@ interface DayReorderPopupProps {
|
||||
|
||||
/**
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, add a day at the end, or delete one. Day headers stay untouched, so
|
||||
* this is the single surface for ordering. Reorders are applied optimistically
|
||||
* by the store, so the list reflects each move immediately. A delete only asks:
|
||||
* the planner shows what goes with the day before anything happens.
|
||||
* arrows, add a day, or delete one. Day headers stay untouched, so this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately. A delete only asks: the planner
|
||||
* shows what goes with the day before anything happens. Adding a day on a trip
|
||||
* with dates offers both kinds, a day without a date or the next date.
|
||||
*/
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
const { offline } = useNetworkMode()
|
||||
@@ -54,32 +62,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
onClose={onClose}
|
||||
title={t('dayplan.reorderTitle')}
|
||||
size="md"
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onAddDay}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
footer={<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={onClose} t={t} locale={locale} />}
|
||||
>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
|
||||
@@ -1,17 +1,71 @@
|
||||
import { CalendarRange, Plus, Trash2 } from 'lucide-react'
|
||||
import { CalendarPlus, CalendarRange, Plus, Trash2 } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { ReorderStack } from '../plan/MPlanTimelineRows'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { dayLabel } from '../../../../utils/dayLabel'
|
||||
import { formatDate } from '../../../../utils/formatters'
|
||||
import type { DayAddControls } from '../../../../utils/dayAdd'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
|
||||
const TILE = 'flex w-full items-center gap-[10px] rounded-[13px] px-[11px] text-left disabled:opacity-40'
|
||||
const TILE_ICON = 'flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full'
|
||||
const TILE_TITLE = 'block text-[0.8125rem] font-semibold leading-tight'
|
||||
const TILE_HINT = 'mt-[2px] block text-[0.6875rem] leading-snug'
|
||||
|
||||
/**
|
||||
* Day management sheet ('days'): move whole days up/down, append a new day or
|
||||
* delete one, the mobile counterpart of the desktop DayReorderPopup and
|
||||
* button-based like the rest of the touch reordering (#1432). A day's places,
|
||||
* notes and bookings move with it (store handles that optimistically). Delete
|
||||
* only asks; the confirm sheet with what goes with the day is MTripSheets'.
|
||||
* The two ways to add a day on a trip with dates, as tiles: the next date on
|
||||
* top in the action colour, since it is the one that moves the trip's end, and
|
||||
* a day without a date below it in the dashed look the single button had.
|
||||
*/
|
||||
function DayAddTiles({ dayAdd, date, onAddUndated, t }: {
|
||||
dayAdd: DayAddControls
|
||||
date: string
|
||||
onAddUndated: () => void
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const addOff = dayAdd.busy || !!dayAdd.blocked
|
||||
return (
|
||||
<div className="mt-[10px] flex flex-col gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dayAdd.onAddDated}
|
||||
disabled={addOff || !!dayAdd.datedBlocked}
|
||||
className={`${TILE} bg-m-act py-[9px] text-m-actfg`}
|
||||
>
|
||||
<span className={`${TILE_ICON} bg-[color:color-mix(in_srgb,var(--m-actfg)_14%,transparent)]`}>
|
||||
<CalendarPlus size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={TILE_TITLE}>{t('dayplan.addDatedDay', { date })}</span>
|
||||
<span className={`${TILE_HINT} opacity-70`}>{dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date })}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddUndated}
|
||||
disabled={addOff}
|
||||
className={`${TILE} border-[1.5px] border-dashed border-[color:var(--m-faint)] py-[8px] text-m-muted`}
|
||||
>
|
||||
<span className={`${TILE_ICON} bg-[color:var(--m-ic)]`}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`${TILE_TITLE} text-m-ink`}>{t('dayplan.addUndatedDay')}</span>
|
||||
<span className={TILE_HINT}>{t('dayplan.addUndatedDayHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Day management sheet ('days'): move whole days up/down, add a day or delete
|
||||
* one, the mobile counterpart of the desktop DayReorderPopup and button-based
|
||||
* like the rest of the touch reordering (#1432). A day's places, notes and
|
||||
* bookings move with it (store handles that optimistically). Delete only asks;
|
||||
* the confirm sheet with what goes with the day is MTripSheets'. On a trip with
|
||||
* dates a day can be added with the next date as well as without one.
|
||||
*/
|
||||
export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -19,6 +73,9 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const canEditDays = planner.can('day_edit', planner.trip)
|
||||
const ordered = [...planner.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
const deleteBlocked = planner.deleteDayBlocked
|
||||
const { dayAdd } = planner
|
||||
// Offline, both say the same sentence; it is shown once.
|
||||
const notices = [deleteBlocked, dayAdd.blocked].filter((n, i, all): n is string => !!n && all.indexOf(n) === i)
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
@@ -73,19 +130,28 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canEditDays && (
|
||||
{canEditDays && dayAdd.nextDate && (
|
||||
<DayAddTiles
|
||||
dayAdd={dayAdd}
|
||||
date={formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate}
|
||||
onAddUndated={() => planner.handleAddDay()}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{canEditDays && !dayAdd.nextDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => planner.handleAddDay()}
|
||||
className="mt-[10px] flex w-full items-center justify-center gap-[6px] rounded-[13px] border-[1.5px] border-dashed border-[color:var(--m-faint)] py-[9px] text-[0.75rem] font-semibold text-m-muted"
|
||||
disabled={dayAdd.busy || !!dayAdd.blocked}
|
||||
className="mt-[10px] flex w-full items-center justify-center gap-[6px] rounded-[13px] border-[1.5px] border-dashed border-[color:var(--m-faint)] py-[9px] text-[0.75rem] font-semibold text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && deleteBlocked && (
|
||||
<p className="mt-[10px] text-center text-[0.6875rem] text-m-muted">{deleteBlocked}</p>
|
||||
)}
|
||||
{canEditDays && notices.map(notice => (
|
||||
<p key={notice} className="mt-[10px] text-center text-[0.6875rem] text-m-muted">{notice}</p>
|
||||
))}
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
|
||||
@@ -1793,6 +1793,34 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-054: Adding the next date from the reorder dialog', () => {
|
||||
it('hands the sidebar the planner add controls, whose dated add goes through the store', async () => {
|
||||
vi.useFakeTimers();
|
||||
seedTripStore({ id: 42 });
|
||||
const appendDatedDay = vi.fn().mockResolvedValue({ id: 903, date: '2025-06-06' });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 42, start_date: '2025-06-01', end_date: '2025-06-05' }),
|
||||
days: [buildDay({ id: 901, trip_id: 42, day_number: 1, date: '2025-06-01' })],
|
||||
appendDatedDay,
|
||||
});
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const { dayAdd } = capturedDayPlanSidebarProps.current;
|
||||
expect(dayAdd).toMatchObject({ nextDate: '2025-06-06', blocked: null, datedBlocked: null, busy: false });
|
||||
await act(async () => { dayAdd.onAddDated(); });
|
||||
expect(appendDatedDay).toHaveBeenCalledWith(42);
|
||||
await waitFor(() => {
|
||||
expect(capturedDayPlanSidebarProps.current.dayAdd.busy).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-052: Road trip mode opens a booking the way the day plan does (#2428)', () => {
|
||||
// The rail is mounted only with the addon on and the mode on for this trip; the
|
||||
// road-trip hooks then read their own endpoints, answered empty here.
|
||||
|
||||
@@ -304,7 +304,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
|
||||
deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, handleDeleteDay, confirmDeleteDay,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
@@ -658,6 +658,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onReorder={handleReorder}
|
||||
onReorderDays={handleReorderDays}
|
||||
onAddDay={handleAddDay}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={handleDeleteDay}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
@@ -951,7 +952,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onDeleteDay={handleDeleteDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1298,7 +1298,9 @@ describe('TripPlannerPage — modals', () => {
|
||||
it('FE-PAGE-TPW-062: the question before a booked night becomes a pause answers through the hook', () => {
|
||||
renderPage({ stayRelease: { stop: { stopType: null, dwellMinutes: 30 }, name: 'Hotel Fjord', booking: 'Booking 4711' } })
|
||||
|
||||
const [, , release] = confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }>
|
||||
// Found by its handler rather than its place: the delete-day question sits in front of it now.
|
||||
const release = (confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }>)
|
||||
.find(dialog => dialog.onConfirm === hookState.confirmStayRelease)!
|
||||
expect(release.isOpen).toBe(true)
|
||||
|
||||
act(() => { release.onClose() })
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { datedDayOption, type DayAddControls } from '../../utils/dayAdd'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Trip } from '../../types'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
interface DayAddOptions {
|
||||
tripId: number
|
||||
trip: Trip | null
|
||||
days: Day[]
|
||||
canEditDays: boolean
|
||||
t: Translate
|
||||
locale: string
|
||||
toast: { success: (message: string) => unknown; error: (message: string) => unknown }
|
||||
}
|
||||
|
||||
export interface DayAdd {
|
||||
/** Add a day without a date at the end, or an empty day at a 1-based `position`. */
|
||||
handleAddDay: (position?: number) => void
|
||||
/** Everything the reorder dialog and the phone's day sheet need for their add buttons. */
|
||||
dayAdd: DayAddControls
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding a day, for both shells: the day without a date as before, and on a trip
|
||||
* with dates the next calendar day, which extends the trip by one. The desktop
|
||||
* dialog and the phone sheet only render what this returns, which keeps the logic
|
||||
* in one place for the duplication gate.
|
||||
*
|
||||
* Both run one at a time. A second click while a day is on its way does nothing,
|
||||
* or a double click on "Add with date" would grow the trip by two days. Both need
|
||||
* a connection: the server places the new day and moves the ones behind it.
|
||||
*/
|
||||
export function useDayAdd(options: DayAddOptions): DayAdd {
|
||||
const { tripId, trip, days, canEditDays, t, locale, toast } = options
|
||||
const { offline } = useNetworkMode()
|
||||
const [busy, setBusy] = useState(false)
|
||||
// The state redraws the buttons; the ref stops a click that lands before that redraw.
|
||||
const inFlight = useRef(false)
|
||||
|
||||
const { nextDate, datedBlocked } = useMemo(() => datedDayOption(trip, days, t), [trip, days, t])
|
||||
const blocked = offline ? t('dayplan.daysOffline') : null
|
||||
|
||||
const runOnce = useCallback((add: () => Promise<void>) => {
|
||||
if (inFlight.current) return
|
||||
inFlight.current = true
|
||||
setBusy(true)
|
||||
add().finally(() => {
|
||||
inFlight.current = false
|
||||
setBusy(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleAddDay = useCallback((position?: number) => {
|
||||
if (!canEditDays || blocked) return
|
||||
runOnce(() => useTripStore.getState().insertDay(tripId, position).then(
|
||||
() => undefined,
|
||||
(err: unknown) => { toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')) },
|
||||
))
|
||||
}, [canEditDays, blocked, runOnce, tripId, toast, t])
|
||||
|
||||
const onAddDated = useCallback(() => {
|
||||
if (!canEditDays || blocked || datedBlocked || !nextDate) return
|
||||
runOnce(() => useTripStore.getState().appendDatedDay(tripId).then(
|
||||
day => { toast.success(t('dayplan.tripExtended', { date: formatDate(day.date ?? nextDate, locale) ?? nextDate })) },
|
||||
(err: unknown) => {
|
||||
// The server's sentences are English, and the button already keeps a trip
|
||||
// without dates or at the day limit from asking, so a refusal here is a
|
||||
// failure the traveller only needs to hear about in their own language.
|
||||
console.error('Failed to add a dated day:', err)
|
||||
toast.error(t('dayplan.addDayError'))
|
||||
},
|
||||
))
|
||||
}, [canEditDays, blocked, datedBlocked, nextDate, runOnce, tripId, toast, t, locale])
|
||||
|
||||
const dayAdd = useMemo<DayAddControls>(
|
||||
() => ({ nextDate, blocked, datedBlocked, busy, onAddDated }),
|
||||
[nextDate, blocked, datedBlocked, busy, onAddDated],
|
||||
)
|
||||
|
||||
return { handleAddDay, dayAdd }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-125
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-129
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -102,6 +102,7 @@ interface PlannerActions {
|
||||
reorderAssignments: ReturnType<typeof vi.fn>
|
||||
reorderDays: ReturnType<typeof vi.fn>
|
||||
insertDay: ReturnType<typeof vi.fn>
|
||||
appendDatedDay: ReturnType<typeof vi.fn>
|
||||
deleteDay: ReturnType<typeof vi.fn>
|
||||
updateDayTitle: ReturnType<typeof vi.fn>
|
||||
addReservation: ReturnType<typeof vi.fn>
|
||||
@@ -130,6 +131,7 @@ function makeActions(): PlannerActions {
|
||||
reorderAssignments: vi.fn(async () => undefined),
|
||||
reorderDays: vi.fn(async () => undefined),
|
||||
insertDay: vi.fn(async () => undefined),
|
||||
appendDatedDay: vi.fn(async () => ({ id: 99, date: '2026-06-04' })),
|
||||
deleteDay: vi.fn(async () => undefined),
|
||||
updateDayTitle: vi.fn(async () => undefined),
|
||||
addReservation: vi.fn(async () => ({ id: 77 })),
|
||||
@@ -2376,3 +2378,98 @@ describe('useTripPlanner: deleting a day', () => {
|
||||
expect(actions.reorderDays).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner: adding a day', () => {
|
||||
// A trip running 1 to 3 June with one day per date and a spare day without one.
|
||||
function seedDated(extra: Partial<TripStoreState> = {}) {
|
||||
return seedTrip({
|
||||
trip: buildTrip({ id: 42, title: 'Kyoto', start_date: '2026-06-01', end_date: '2026-06-03' }),
|
||||
days: [
|
||||
buildDay({ id: 1, day_number: 1, date: '2026-06-01' }),
|
||||
buildDay({ id: 2, day_number: 2, date: '2026-06-02' }),
|
||||
buildDay({ id: 3, day_number: 3, date: '2026-06-03' }),
|
||||
buildDay({ id: 4, day_number: 4, date: null }),
|
||||
],
|
||||
...extra,
|
||||
} as Partial<TripStoreState>)
|
||||
}
|
||||
|
||||
it('FE-TP-HOOK-126: a trip with dates offers the day after its end date; one without dates offers none', async () => {
|
||||
seedDated()
|
||||
const dated = await renderPlanner()
|
||||
expect(dated.result.current.dayAdd).toMatchObject({ nextDate: '2026-06-04', blocked: null, datedBlocked: null, busy: false })
|
||||
dated.unmount()
|
||||
|
||||
seedTrip({ trip: buildTrip({ id: 42, start_date: null, end_date: null }), days: [buildDay({ id: 1, day_number: 1, date: null })] } as Partial<TripStoreState>)
|
||||
const { result } = await renderPlanner()
|
||||
expect(result.current.dayAdd.nextDate).toBeNull()
|
||||
act(() => { result.current.dayAdd.onAddDated() })
|
||||
expect(actions.appendDatedDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-127: without day_edit neither kind of day is added', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }) })
|
||||
usePermissionsStore.setState({ permissions: { day_edit: 'admin' } })
|
||||
seedDated()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
act(() => { result.current.dayAdd.onAddDated() })
|
||||
act(() => { result.current.handleAddDay() })
|
||||
|
||||
expect(actions.appendDatedDay).not.toHaveBeenCalled()
|
||||
expect(actions.insertDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-128: one day at a time, and the toast names the new end date', async () => {
|
||||
seedDated()
|
||||
let finish: (day: { id: number; date: string }) => void = () => {}
|
||||
actions.appendDatedDay.mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
act(() => { result.current.dayAdd.onAddDated() })
|
||||
expect(result.current.dayAdd.busy).toBe(true)
|
||||
// A double click, and a click on the other button while the first is on its way.
|
||||
act(() => { result.current.dayAdd.onAddDated() })
|
||||
act(() => { result.current.handleAddDay() })
|
||||
expect(actions.appendDatedDay).toHaveBeenCalledTimes(1)
|
||||
expect(actions.appendDatedDay).toHaveBeenCalledWith(42)
|
||||
expect(actions.insertDay).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => { finish({ id: 99, date: '2026-06-04' }) })
|
||||
await waitFor(() => expect(result.current.dayAdd.busy).toBe(false))
|
||||
expect(toasts).toContainEqual(expect.objectContaining({ type: 'success', message: expect.stringMatching(/^Day added\. The trip now ends on .*Jun 4/) }))
|
||||
|
||||
// Free again: the next click goes through.
|
||||
await act(async () => { result.current.handleAddDay() })
|
||||
expect(actions.insertDay).toHaveBeenCalledWith(42, undefined)
|
||||
await waitFor(() => expect(result.current.dayAdd.busy).toBe(false))
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-129: a failure is said in the traveller language; offline and at the day limit nothing is asked', async () => {
|
||||
seedDated()
|
||||
actions.appendDatedDay.mockRejectedValueOnce(new Error('A trip can span at most 999 days'))
|
||||
const failing = await renderPlanner()
|
||||
await act(async () => { failing.result.current.dayAdd.onAddDated() })
|
||||
await waitFor(() => expect(toasts).toContainEqual(expect.objectContaining({ type: 'error', message: 'Failed to add day' })))
|
||||
await waitFor(() => expect(failing.result.current.dayAdd.busy).toBe(false))
|
||||
failing.unmount()
|
||||
|
||||
actions.appendDatedDay.mockClear()
|
||||
env.forcedOffline = true
|
||||
seedDated()
|
||||
const offline = await renderPlanner()
|
||||
expect(offline.result.current.dayAdd.blocked).toBe('Changing days needs a connection')
|
||||
act(() => { offline.result.current.dayAdd.onAddDated() })
|
||||
act(() => { offline.result.current.handleAddDay() })
|
||||
expect(actions.appendDatedDay).not.toHaveBeenCalled()
|
||||
expect(actions.insertDay).not.toHaveBeenCalled()
|
||||
offline.unmount()
|
||||
|
||||
env.forcedOffline = false
|
||||
seedDated({ trip: buildTrip({ id: 42, start_date: '2026-01-01', end_date: '2028-09-25' }) } as Partial<TripStoreState>)
|
||||
const { result } = await renderPlanner()
|
||||
expect(result.current.dayAdd.datedBlocked).toBe('A trip can span at most 999 days')
|
||||
act(() => { result.current.dayAdd.onAddDated() })
|
||||
expect(actions.appendDatedDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from '../../utils/connectionsVisibility'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
|
||||
import { useDayDelete } from './useDayDelete'
|
||||
import { useDayAdd } from './useDayAdd'
|
||||
|
||||
/** Stable empty list so the road trip hook stays inert while its mode is off. */
|
||||
const EMPTY_DAYS: Day[] = []
|
||||
@@ -2521,10 +2522,9 @@ export function useTripPlanner() {
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleAddDay = useCallback((position?: number) => {
|
||||
tripActions.insertDay(tripId, position)
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
|
||||
}, [tripId, toast])
|
||||
const { handleAddDay, dayAdd } = useDayAdd({
|
||||
tripId, trip, days, canEditDays: can('day_edit', trip), t, locale, toast,
|
||||
})
|
||||
|
||||
// A deleted day can take a stay along, and the selected day's route may have
|
||||
// lost its day or its stops.
|
||||
@@ -2861,7 +2861,7 @@ export function useTripPlanner() {
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
|
||||
...dayDelete,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-REPO-DAY-001 to FE-REPO-DAY-006
|
||||
// FE-REPO-DAY-001 to FE-REPO-DAY-008
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import 'fake-indexeddb/auto'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -88,3 +88,30 @@ describe('dayRepo.remove', () => {
|
||||
expect(await offlineDb.days.get(51)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayRepo.appendDated', () => {
|
||||
it('FE-REPO-DAY-007: online, asks for a dated day and caches the trip it answers with', async () => {
|
||||
const trip = buildTrip({ id: 5, end_date: '2026-06-04' })
|
||||
const day = buildDay({ id: 61, trip_id: 5, day_number: 4, date: '2026-06-04' })
|
||||
let body: unknown = null
|
||||
server.use(http.post('/api/trips/5/days', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ day, trip }, { status: 201 })
|
||||
}))
|
||||
|
||||
const result = await dayRepo.appendDated(5)
|
||||
|
||||
expect(body).toEqual({ dated: true })
|
||||
expect(result).toMatchObject({ day: { id: 61, date: '2026-06-04' }, trip: { end_date: '2026-06-04' } })
|
||||
expect(await offlineDb.trips.get(5)).toMatchObject({ end_date: '2026-06-04' })
|
||||
})
|
||||
|
||||
it('FE-REPO-DAY-008: offline, refuses without a request', async () => {
|
||||
const hit = vi.fn()
|
||||
server.use(http.post('/api/trips/5/days', () => { hit(); return HttpResponse.json({}) }))
|
||||
setOnline(false)
|
||||
|
||||
await expect(dayRepo.appendDated(5)).rejects.toThrow('Adding a day needs a connection')
|
||||
expect(hit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,4 +35,19 @@ export const dayRepo = {
|
||||
if (result.trip) await upsertTrip(result.trip)
|
||||
return { trip: result.trip }
|
||||
},
|
||||
|
||||
/**
|
||||
* Add the calendar day after the trip's last date, which extends the trip by
|
||||
* one day. Online only, like remove: the server picks the date and moves the
|
||||
* days without one back, and a queued offline write replayed later could land
|
||||
* on a date the trip has grown past by then. The trip the server answers with
|
||||
* (new end date, new day count) replaces the cached one; the days are the
|
||||
* caller's to refresh, since the ones behind the new day moved.
|
||||
*/
|
||||
async appendDated(tripId: number | string): Promise<{ day: Day; trip?: Trip }> {
|
||||
if (isEffectivelyOffline()) throw new Error('Adding a day needs a connection')
|
||||
const result = await daysApi.create(tripId, { dated: true })
|
||||
if (result.trip) await upsertTrip(result.trip)
|
||||
return { day: result.day, trip: result.trip }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-TSLICE-DAYS-001 to FE-TSLICE-DAYS-011 (whole-day reorder + insert, #589; delete)
|
||||
// FE-TSLICE-DAYS-001 to FE-TSLICE-DAYS-013 (whole-day reorder + insert, #589; delete; dated append)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
@@ -248,4 +248,52 @@ describe('daysSlice', () => {
|
||||
expect(useTripStore.getState().trip?.end_date).toBe('2025-06-03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendDatedDay', () => {
|
||||
it('FE-TSLICE-DAYS-012: asks for the next date, takes the grown trip and pulls the days, but not the bookings', async () => {
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-03' }),
|
||||
days: datedDays(),
|
||||
});
|
||||
const created = buildDay({ id: 4, trip_id: 1, day_number: 4, date: '2025-06-04' });
|
||||
const grown = buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-04', day_count: 4 });
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
let reservationsLoaded = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/days', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ day: created, trip: grown }, { status: 201 });
|
||||
}),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [...datedDays(), created] })),
|
||||
http.get('/api/trips/1/reservations', () => {
|
||||
reservationsLoaded = true;
|
||||
return HttpResponse.json({ reservations: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const day = await useTripStore.getState().appendDatedDay(1);
|
||||
|
||||
expect(body).toEqual({ dated: true });
|
||||
expect(day).toMatchObject({ id: 4, date: '2025-06-04' });
|
||||
expect(useTripStore.getState().trip).toMatchObject({ end_date: '2025-06-04', day_count: 4 });
|
||||
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1, 2, 3, 4]);
|
||||
expect(reservationsLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TSLICE-DAYS-013: a refusal leaves the trip and the days as they were and throws the server sentence', async () => {
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-03' }),
|
||||
days: datedDays(),
|
||||
});
|
||||
server.use(
|
||||
http.post('/api/trips/1/days', () => HttpResponse.json({ error: 'A trip can span at most 999 days' }, { status: 400 })),
|
||||
);
|
||||
|
||||
await expect(useTripStore.getState().appendDatedDay(1)).rejects.toThrow('A trip can span at most 999 days');
|
||||
|
||||
expect(useTripStore.getState().trip?.end_date).toBe('2025-06-03');
|
||||
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ type GetState = StoreApi<TripStoreState>['getState']
|
||||
export interface DaysSlice {
|
||||
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
|
||||
appendDatedDay: (tripId: number | string) => Promise<Day>
|
||||
deleteDay: (tripId: number | string, dayId: number) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -80,6 +81,21 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Add the calendar day after the trip's last date. The trip grows by that day,
|
||||
// so the store takes the trip the server answered with (the socket echo skips
|
||||
// this tab) and pulls the days, where the ones without a date moved back one
|
||||
// place. No booking changes date, so reservations stay as they are.
|
||||
appendDatedDay: async (tripId) => {
|
||||
const outcome = await dayRepo.appendDated(tripId).then(
|
||||
answer => answer,
|
||||
(failure: unknown) => ({ failure }),
|
||||
)
|
||||
if ('failure' in outcome) throw new Error(getApiErrorMessage(outcome.failure, 'Error adding day'))
|
||||
if (outcome.trip) set({ trip: outcome.trip })
|
||||
await get().refreshDays(tripId)
|
||||
return outcome.day
|
||||
},
|
||||
|
||||
// Delete a day. The list closes up at once; a refusal (the last day, no
|
||||
// connection, a server error) puts every field back. Afterwards the store pulls
|
||||
// the re-dated days and the bookings, which were re-stamped or let go of, and
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-UTIL-DAYADD-001 to FE-UTIL-DAYADD-003
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { addIsoDays, MAX_TRIP_DAYS } from '@trek/shared'
|
||||
import { datedDayOption } from './dayAdd'
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
describe('datedDayOption', () => {
|
||||
it('FE-UTIL-DAYADD-001: offers the day after the end date, or after a day dated past it', () => {
|
||||
const trip = { start_date: '2026-10-10', end_date: '2026-10-12' }
|
||||
expect(datedDayOption(trip, [{ date: '2026-10-10' }, { date: '2026-10-12' }, { date: null }], t))
|
||||
.toEqual({ nextDate: '2026-10-13', datedBlocked: null })
|
||||
expect(datedDayOption(trip, [{ date: '2026-10-14' }], t))
|
||||
.toEqual({ nextDate: '2026-10-15', datedBlocked: null })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYADD-002: a trip without dates has no dated day to offer', () => {
|
||||
expect(datedDayOption({ start_date: null, end_date: null }, [{ date: null }], t))
|
||||
.toEqual({ nextDate: null, datedBlocked: null })
|
||||
expect(datedDayOption({ start_date: '2026-10-10', end_date: null }, [], t))
|
||||
.toEqual({ nextDate: null, datedBlocked: null })
|
||||
expect(datedDayOption(null, [], t)).toEqual({ nextDate: null, datedBlocked: null })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYADD-003: a trip at the day limit names its date but says why it cannot grow', () => {
|
||||
const start = '2026-01-01'
|
||||
const end = addIsoDays(start, MAX_TRIP_DAYS - 1)
|
||||
expect(datedDayOption({ start_date: start, end_date: end }, [], t)).toEqual({
|
||||
nextDate: addIsoDays(end, 1),
|
||||
datedBlocked: `dashboard.tripTooLong|${MAX_TRIP_DAYS}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MAX_TRIP_DAYS, planDatedAppend } from '@trek/shared'
|
||||
import type { Day, Trip } from '../types'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/**
|
||||
* What the reorder dialog and the phone's day sheet need to offer adding a day:
|
||||
* one button for a day without a date, and on a trip with dates a second one for
|
||||
* the next calendar day. The planner computes it once for both shells.
|
||||
*/
|
||||
export interface DayAddControls {
|
||||
/** The date "Add with date" gives the new day (ISO), or null on a trip without dates, which keeps the single button. */
|
||||
nextDate: string | null
|
||||
/** Why no day can be added right now (no connection), or null. */
|
||||
blocked: string | null
|
||||
/** Why the dated day in particular cannot be added (the trip is at the day limit), or null. */
|
||||
datedBlocked: string | null
|
||||
/** A day is on its way; both buttons wait for it, so a double click adds one day, not two. */
|
||||
busy: boolean
|
||||
onAddDated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The dated half of adding a day: the date the new day would get, and the reason
|
||||
* it cannot be added when the trip is already at the day limit. The rule is the
|
||||
* server's own (planDatedAppend), so the button never promises a date the server
|
||||
* would not give, and never offers a day the server would refuse.
|
||||
*/
|
||||
export function datedDayOption(
|
||||
trip: Pick<Trip, 'start_date' | 'end_date'> | null | undefined,
|
||||
days: readonly Pick<Day, 'date'>[],
|
||||
t: Translate,
|
||||
): Pick<DayAddControls, 'nextDate' | 'datedBlocked'> {
|
||||
const plan = trip ? planDatedAppend(trip, days.map(d => d.date)) : null
|
||||
if (!plan) return { nextDate: null, datedBlocked: null }
|
||||
return { nextDate: plan.date, datedBlocked: plan.fits ? null : t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS }) }
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { buildTrip } from './factories';
|
||||
export function buildTripActions(): Record<string, ReturnType<typeof vi.fn>> {
|
||||
const names = [
|
||||
'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem',
|
||||
'addPlace', 'addReservation', 'addTodoItem', 'assignPlaceToDay', 'clonePackingItem',
|
||||
'addPlace', 'addReservation', 'addTodoItem', 'appendDatedDay', 'assignPlaceToDay', 'clonePackingItem',
|
||||
'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace',
|
||||
'deleteDay', 'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems',
|
||||
'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace',
|
||||
@@ -185,6 +185,9 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
|
||||
deletePlaceIds: null,
|
||||
setDeletePlaceIds: vi.fn(),
|
||||
|
||||
// Adding a day from the days sheet: a trip without dates, so the single button.
|
||||
dayAdd: { nextDate: null, blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() },
|
||||
|
||||
// Deleting a day from the days sheet: no question open, nothing blocking.
|
||||
deleteDayId: null,
|
||||
setDeleteDayId: vi.fn(),
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
|
||||
import type { Day } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { cleanup, fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-015
|
||||
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-019
|
||||
//
|
||||
// The sheet reads its copy from the real TranslationProvider (useTranslation),
|
||||
// not from planner.t — assertions therefore go against the English strings.
|
||||
@@ -143,4 +143,50 @@ describe('MDaysSheet', () => {
|
||||
expect(planner.handleDeleteDay).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('A trip needs at least one day')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('adding a day on a trip with dates', () => {
|
||||
const dayAdd = (overrides: Partial<TripPlanner['dayAdd']> = {}): TripPlanner['dayAdd'] => ({
|
||||
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
|
||||
})
|
||||
const datedTile = () => screen.getByRole('button', { name: /^Add .*Oct 13/ })
|
||||
const undatedTile = () => screen.getByRole('button', { name: /^Add day without date/ })
|
||||
|
||||
it('FE-MOB-DAYSS-016: two tiles, the next date and a day without one, each with what it does', () => {
|
||||
const { planner } = renderSheet({ dayAdd: dayAdd() })
|
||||
expect(datedTile()).toHaveTextContent(/Extends the trip by one day, until .*Oct 13\./)
|
||||
expect(undatedTile()).toHaveTextContent('The trip dates stay as they are.')
|
||||
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(datedTile())
|
||||
expect(planner.dayAdd.onAddDated).toHaveBeenCalledTimes(1)
|
||||
expect(planner.handleAddDay).not.toHaveBeenCalled()
|
||||
fireEvent.click(undatedTile())
|
||||
expect(planner.handleAddDay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-017: a read-only member gets neither tile', () => {
|
||||
renderSheet({ can: vi.fn(() => false), dayAdd: dayAdd() })
|
||||
expect(screen.queryByRole('button', { name: /^Add/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-018: busy or offline turns both off, and the offline sentence is said once', () => {
|
||||
renderSheet({ dayAdd: dayAdd({ busy: true }) })
|
||||
expect(datedTile()).toBeDisabled()
|
||||
expect(undatedTile()).toBeDisabled()
|
||||
cleanup()
|
||||
|
||||
const offline = 'Changing days needs a connection'
|
||||
renderSheet({ deleteDayBlocked: offline, dayAdd: dayAdd({ blocked: offline }) })
|
||||
expect(datedTile()).toBeDisabled()
|
||||
expect(undatedTile()).toBeDisabled()
|
||||
expect(screen.getAllByText(offline)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-019: a trip at the day limit keeps the undated tile and says why the dated one is off', () => {
|
||||
renderSheet({ dayAdd: dayAdd({ datedBlocked: 'A trip can span at most 999 days' }) })
|
||||
expect(datedTile()).toBeDisabled()
|
||||
expect(datedTile()).toHaveTextContent('A trip can span at most 999 days')
|
||||
expect(undatedTile()).toBeEnabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,5 +72,16 @@ describe('remoteEventHandler > trip', () => {
|
||||
expect(refreshDays).not.toHaveBeenCalled();
|
||||
expect(loadReservations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-WSEVT-TRIP-006: a trip grown by a dated day (end date alone) refetches days + reservations', () => {
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-05' }) });
|
||||
const { refreshDays, loadReservations, dispatchSpy } = stubRefresh();
|
||||
const grown = buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-06', day_count: 6 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: grown });
|
||||
expect(useTripStore.getState().trip).toMatchObject({ end_date: '2025-06-06', day_count: 6 });
|
||||
expect(refreshDays).toHaveBeenCalledWith(1);
|
||||
expect(loadReservations).toHaveBeenCalledWith(1);
|
||||
expect(firedAccommodationsRefresh(dispatchSpy)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user