mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
0dd07befa7
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
315 lines
16 KiB
TypeScript
315 lines
16 KiB
TypeScript
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
|
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
|
import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
|
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
|
import type { Reservation } from '../../types'
|
|
|
|
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
|
|
|
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
|
// own test, and TripExportModal covers the entry that opens it.
|
|
vi.mock('./IcsSubscribeModal', () => ({
|
|
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
|
<div data-testid="ics-subscribe-modal">
|
|
{title}
|
|
<button onClick={onClose}>close-subscribe</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
const t = (key: string, params?: Record<string, unknown>) =>
|
|
params ? `${key}|${Object.values(params).join('|')}` : key
|
|
|
|
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
|
|
|
|
function makeToast() {
|
|
return {
|
|
success: vi.fn((_m: string) => {}),
|
|
error: vi.fn((_m: string) => {}),
|
|
warning: vi.fn((_m: string) => {}),
|
|
info: vi.fn((_m: string) => {}),
|
|
}
|
|
}
|
|
|
|
function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebarToolbar>> = {}) {
|
|
return {
|
|
tripId: 1,
|
|
trip,
|
|
days: [],
|
|
places: [],
|
|
categories: [],
|
|
assignments: {},
|
|
reservations: [] as Reservation[],
|
|
dayNotes: {},
|
|
t,
|
|
locale: 'en-US',
|
|
toast: makeToast(),
|
|
expandedDays: new Set<number>(),
|
|
setExpandedDays: vi.fn((_v: Set<number>) => {}),
|
|
canUndo: false,
|
|
undoHover: false,
|
|
setUndoHover: vi.fn((_v: boolean) => {}),
|
|
lastActionLabel: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const routableReservation = () => buildReservation({
|
|
id: 5, type: 'flight', title: 'BER → CDG',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'BER', code: null, lat: 52.3, lng: 13.5, timezone: null, local_date: null, local_time: null },
|
|
{ role: 'to', sequence: 1, name: 'CDG', code: null, lat: 49.0, lng: 2.5, timezone: null, local_date: null, local_time: null },
|
|
],
|
|
} as Partial<Reservation>)
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
sessionStorage.clear()
|
|
localStorage.clear()
|
|
})
|
|
|
|
describe('DayPlanSidebarToolbar', () => {
|
|
// ── Export ─────────────────────────────────────────────────────
|
|
// PDF, ICS and GPX each used to be their own button with its own hover menu.
|
|
// The mechanics now live in TripExportModal and are tested there; what is left
|
|
// here is the single button that opens it.
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => {
|
|
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
|
expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument()
|
|
expect(screen.queryByText('ICS')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('GPX')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
|
const user = userEvent.setup()
|
|
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
|
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
|
expect(btn).toHaveAttribute('aria-expanded', 'false')
|
|
await user.click(btn)
|
|
expect(btn).toHaveAttribute('aria-expanded', 'true')
|
|
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
|
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
|
await user.click(screen.getByText('dayplan.pdf'))
|
|
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
|
await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => {
|
|
const user = userEvent.setup()
|
|
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
|
expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()
|
|
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
|
await user.hover(btn)
|
|
await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument())
|
|
await user.unhover(btn)
|
|
await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument())
|
|
})
|
|
|
|
// The button sits at the right edge of the leftmost pane. Its own tooltip was
|
|
// anchored with `right: 0` and never wrapped, so a long label ran off the left
|
|
// of the window. The shared tooltip is portalled and clamped to the viewport.
|
|
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
|
|
const user = userEvent.setup()
|
|
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
|
await user.hover(screen.getByRole('button', { name: 'dayplan.export' }))
|
|
const tip = await screen.findByText('dayplan.exportIntro')
|
|
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
|
|
expect(tip.parentElement).toBe(document.body)
|
|
expect(tip.style.position).toBe('fixed')
|
|
// Placed once measured, and never to the left of the window edge.
|
|
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => {
|
|
const user = userEvent.setup()
|
|
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
|
|
await user.click(screen.getByRole('button', { name: 'dayplan.export' }))
|
|
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
|
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// ── Expand / collapse all ────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-011: with days collapsed the button offers "expand all"', async () => {
|
|
const user = userEvent.setup()
|
|
const days = [buildDay({ id: 10 }), buildDay({ id: 11 })]
|
|
const setExpandedDays = vi.fn((_v: Set<number>) => {})
|
|
render(<DayPlanSidebarToolbar {...makeProps({ days, setExpandedDays })} />)
|
|
const btn = screen.getByRole('button', { name: 'dayplan.expandAll' })
|
|
expect(btn).toHaveAttribute('aria-pressed', 'false')
|
|
await user.click(btn)
|
|
expect(setExpandedDays).toHaveBeenCalledWith(new Set([10, 11]))
|
|
// The sidebar restores the set from localStorage on mount, so that is the
|
|
// store the toolbar has to write to.
|
|
expect(JSON.parse(localStorage.getItem('day-expanded-1')!)).toEqual([10, 11])
|
|
expect(sessionStorage.getItem('day-expanded-1')).toBeNull()
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-012: with every day expanded the button collapses them all', async () => {
|
|
const user = userEvent.setup()
|
|
const days = [buildDay({ id: 10 }), buildDay({ id: 11 })]
|
|
const setExpandedDays = vi.fn((_v: Set<number>) => {})
|
|
render(<DayPlanSidebarToolbar {...makeProps({ days, expandedDays: new Set([10, 11]), setExpandedDays })} />)
|
|
const btn = screen.getByRole('button', { name: 'dayplan.collapseAll' })
|
|
expect(btn).toHaveAttribute('aria-pressed', 'true')
|
|
await user.click(btn)
|
|
expect(setExpandedDays).toHaveBeenCalledWith(new Set())
|
|
expect(JSON.parse(localStorage.getItem('day-expanded-1')!)).toEqual([])
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-013: a trip with no days is never treated as fully expanded', () => {
|
|
render(<DayPlanSidebarToolbar {...makeProps({ days: [] })} />)
|
|
expect(screen.getByRole('button', { name: 'dayplan.expandAll' })).toBeInTheDocument()
|
|
})
|
|
|
|
// ── Undo ─────────────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-014: no undo control without an onUndo handler', () => {
|
|
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
|
expect(screen.queryByRole('button', { name: 'undo.button' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-015: the undo button stays disabled while there is nothing to undo', async () => {
|
|
const user = userEvent.setup()
|
|
const onUndo = vi.fn()
|
|
render(<DayPlanSidebarToolbar {...makeProps({ onUndo, canUndo: false })} />)
|
|
const btn = screen.getByRole('button', { name: 'undo.button' })
|
|
expect(btn).toBeDisabled()
|
|
await user.click(btn)
|
|
expect(onUndo).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-016: clicking undo fires the handler and hovering reports the hover state', async () => {
|
|
const user = userEvent.setup()
|
|
const onUndo = vi.fn()
|
|
const setUndoHover = vi.fn((_v: boolean) => {})
|
|
render(<DayPlanSidebarToolbar {...makeProps({ onUndo, canUndo: true, setUndoHover })} />)
|
|
const btn = screen.getByRole('button', { name: 'undo.button' })
|
|
await user.hover(btn)
|
|
expect(setUndoHover).toHaveBeenCalledWith(true)
|
|
await user.click(btn)
|
|
expect(onUndo).toHaveBeenCalledTimes(1)
|
|
await user.unhover(btn)
|
|
expect(setUndoHover).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-017: the undo tooltip names the last action when there is one', async () => {
|
|
const user = userEvent.setup()
|
|
render(<DayPlanSidebarToolbar {...makeProps({ onUndo: vi.fn(), canUndo: true, lastActionLabel: 'Reorder' })} />)
|
|
await user.hover(screen.getByRole('button', { name: 'undo.button' }))
|
|
expect(await screen.findByRole('tooltip')).toHaveTextContent('undo.tooltip|Reorder')
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-018: with nothing to undo the button is off and names no action', () => {
|
|
render(<DayPlanSidebarToolbar {...makeProps({ onUndo: vi.fn(), canUndo: false, lastActionLabel: 'Reorder' })} />)
|
|
expect(screen.getByRole('button', { name: 'undo.button' })).toBeDisabled()
|
|
expect(screen.queryByText('undo.tooltip|Reorder')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// ── Reorder days ─────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-019: the reorder button toggles the day-reorder popup', async () => {
|
|
const user = userEvent.setup()
|
|
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
|
|
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
|
const btn = screen.getByRole('button', { name: 'dayplan.reorderDays' })
|
|
expect(btn).toHaveAttribute('aria-pressed', 'false')
|
|
await user.click(btn)
|
|
expect(screen.getByText('dayplan.reorderHint')).toBeInTheDocument()
|
|
await user.click(screen.getByText('common.close'))
|
|
expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-020: the popup passes its add-day action straight through', async () => {
|
|
const user = userEvent.setup()
|
|
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
|
|
const onAddDay = vi.fn((_p?: number) => {})
|
|
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay })} />)
|
|
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
|
await user.click(screen.getByText('dayplan.addDay'))
|
|
expect(onAddDay).toHaveBeenCalledWith()
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', async () => {
|
|
const user = userEvent.setup()
|
|
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
|
|
const onDeleteDay = vi.fn((_id: number) => {})
|
|
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay })} />)
|
|
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
|
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
|
|
expect(onDeleteDay).toHaveBeenCalledWith(11)
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-026: the planner question reaches the popup, which asks it in place of its list', async () => {
|
|
const user = userEvent.setup()
|
|
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
|
|
const onCancel = vi.fn()
|
|
const question = { dayId: 11, title: 'Delete Day 2?', lines: [], onCancel, onConfirm: vi.fn() }
|
|
const props = makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay: vi.fn() })
|
|
const { rerender } = render(<DayPlanSidebarToolbar {...props} />)
|
|
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
|
rerender(<DayPlanSidebarToolbar {...props} deleteDayQuestion={question} />)
|
|
expect(screen.getByRole('region', { name: 'Delete Day 2?' })).toBeInTheDocument()
|
|
expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument()
|
|
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
|
|
expect(onCancel).toHaveBeenCalled()
|
|
})
|
|
|
|
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() })} />)
|
|
expect(screen.queryByRole('button', { name: 'dayplan.reorderDays' })).not.toBeInTheDocument()
|
|
rerender(<DayPlanSidebarToolbar {...makeProps({ days: [], canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
|
expect(screen.queryByRole('button', { name: 'dayplan.reorderDays' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
// ── Show/hide all booking routes ─────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-022: the connections toggle appears only for a routable reservation', async () => {
|
|
const user = userEvent.setup()
|
|
const onToggleAllConnections = vi.fn()
|
|
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ onToggleAllConnections, reservations: [buildReservation({ id: 6 })] })} />)
|
|
expect(screen.queryByRole('button', { name: 'map.showAllConnections' })).not.toBeInTheDocument()
|
|
|
|
rerender(<DayPlanSidebarToolbar {...makeProps({ onToggleAllConnections, reservations: [routableReservation()] })} />)
|
|
const show = screen.getByRole('button', { name: 'map.showAllConnections' })
|
|
expect(show).toHaveAttribute('aria-pressed', 'false')
|
|
await user.click(show)
|
|
expect(onToggleAllConnections).toHaveBeenCalledTimes(1)
|
|
|
|
rerender(<DayPlanSidebarToolbar {...makeProps({ onToggleAllConnections, reservations: [routableReservation()], allConnectionsShown: true })} />)
|
|
expect(screen.getByRole('button', { name: 'map.hideAllConnections' })).toHaveAttribute('aria-pressed', 'true')
|
|
})
|
|
|
|
it('FE-PLANNER-DPTOOLBAR-023: the icon buttons rise like an active tab while they are on', () => {
|
|
const days = [buildDay({ id: 10 })]
|
|
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, onToggleAllConnections: vi.fn(), reservations: [routableReservation()] })} />)
|
|
const routeBtn = screen.getByRole('button', { name: 'map.showAllConnections' })
|
|
expect(routeBtn).not.toHaveClass('shadow-sm')
|
|
rerender(<DayPlanSidebarToolbar {...makeProps({ days, onToggleAllConnections: vi.fn(), reservations: [routableReservation()], allConnectionsShown: true })} />)
|
|
expect(screen.getByRole('button', { name: 'map.hideAllConnections' })).toHaveClass('bg-surface-card', 'shadow-sm')
|
|
})
|
|
|
|
})
|