Files
TREK/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
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.
2026-09-27 21:00:19 +02:00

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')
})
})