mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
276 lines
14 KiB
TypeScript
276 lines
14 KiB
TypeScript
// FE-PLANNER-TRANSITJOURNEY-001 to 018 — the journey view for a saved transit entry.
|
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { buildUser, buildReservation } from '../../../tests/helpers/factories'
|
|
import TransitJourneyModal from './TransitJourneyModal'
|
|
|
|
function makeReservation() {
|
|
return {
|
|
...buildReservation({ id: 7, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', status: 'confirmed' }),
|
|
metadata: {
|
|
transit: {
|
|
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
|
|
legs: [
|
|
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
|
|
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
|
|
],
|
|
},
|
|
},
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.52, lng: 13.4, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
|
{ role: 'to', sequence: 1, name: 'Zoo', code: null, lat: 52.5, lng: 13.33, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
|
],
|
|
} as any
|
|
}
|
|
|
|
/** The journey's title in the head band, a button that turns into the rename field. */
|
|
const titleButton = () => screen.getByRole('button', { name: /^Fernsehturm/ })
|
|
|
|
function makeProps(overrides = {}) {
|
|
return {
|
|
reservation: makeReservation(),
|
|
onClose: vi.fn(),
|
|
onSave: vi.fn().mockResolvedValue({}),
|
|
onDelete: vi.fn().mockResolvedValue({}),
|
|
onChangeRoute: vi.fn(),
|
|
canEdit: true,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
vi.clearAllMocks()
|
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
|
})
|
|
|
|
describe('TransitJourneyModal', () => {
|
|
it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => {
|
|
render(<TransitJourneyModal {...makeProps()} />)
|
|
expect(screen.getByText('U2')).toBeInTheDocument()
|
|
// stat tiles: value + caption
|
|
expect(screen.getByText('Transfers')).toBeInTheDocument()
|
|
expect(screen.getByText('Walking')).toBeInTheDocument()
|
|
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Ruhleben/)).toBeInTheDocument()
|
|
expect(screen.getByText(/BVG/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-002: inline title rename + notes save as a minimal field payload', async () => {
|
|
const user = userEvent.setup()
|
|
const onSave = vi.fn().mockResolvedValue({})
|
|
render(<TransitJourneyModal {...makeProps({ onSave })} />)
|
|
// The title renames inline in the header: a click on it opens the field.
|
|
await user.click(titleButton())
|
|
const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo')
|
|
await user.clear(titleInput)
|
|
await user.type(titleInput, 'Zum Zoo')
|
|
await user.keyboard('{Enter}')
|
|
await user.type(screen.getByPlaceholderText(/notes/i), 'Take **coffee**')
|
|
await user.click(screen.getByRole('button', { name: /^Save$/ }))
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
|
expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', notes: 'Take **coffee**' })
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-006: no status or booking-code fields; notes support a markdown preview', async () => {
|
|
const user = userEvent.setup()
|
|
render(<TransitJourneyModal {...makeProps()} />)
|
|
expect(screen.queryByText('Status')).not.toBeInTheDocument()
|
|
expect(screen.queryByText(/Booking code|Confirmation/i)).not.toBeInTheDocument()
|
|
await user.type(screen.getByPlaceholderText(/notes/i), '**bold** note')
|
|
await user.click(screen.getByRole('button', { name: 'Preview' }))
|
|
const bold = document.querySelector('.collab-note-md strong')
|
|
expect(bold?.textContent).toBe('bold')
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-008: existing notes open rendered as markdown, not raw text', () => {
|
|
const res = { ...makeReservation(), notes: 'bring **wefwe** along' }
|
|
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
|
// Preview tab is active on open: bold is rendered, no raw asterisks visible.
|
|
const bold = document.querySelector('.collab-note-md strong')
|
|
expect(bold?.textContent).toBe('wefwe')
|
|
expect(screen.queryByDisplayValue(/\*\*wefwe\*\*/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-007: the markdown toolbar wraps the note text', async () => {
|
|
const user = userEvent.setup()
|
|
render(<TransitJourneyModal {...makeProps()} />)
|
|
const area = screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement
|
|
await user.type(area, 'coffee')
|
|
area.setSelectionRange(0, 6)
|
|
await user.click(screen.getByRole('button', { name: 'Bold' }))
|
|
expect(area.value).toBe('**coffee**')
|
|
await user.click(screen.getByRole('button', { name: 'Checklist' }))
|
|
expect((screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement).value).toMatch(/^- \[ \] /)
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
|
|
const user = userEvent.setup()
|
|
const onChangeRoute = vi.fn()
|
|
render(<TransitJourneyModal {...makeProps({ onChangeRoute })} />)
|
|
await user.click(screen.getByRole('button', { name: /Change route/ }))
|
|
expect(onChangeRoute).toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-017: edit details hands off to the full transport editor', async () => {
|
|
const user = userEvent.setup()
|
|
const onEditDetails = vi.fn()
|
|
render(<TransitJourneyModal {...makeProps({ onEditDetails })} />)
|
|
await user.click(screen.getByRole('button', { name: /Edit details/ }))
|
|
expect(onEditDetails).toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-018: edit details stays hidden without the callback or edit rights', () => {
|
|
const { rerender } = render(<TransitJourneyModal {...makeProps()} />)
|
|
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
|
|
rerender(<TransitJourneyModal {...makeProps({ onEditDetails: vi.fn(), canEdit: false })} />)
|
|
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
|
|
const user = userEvent.setup()
|
|
const onDelete = vi.fn().mockResolvedValue({})
|
|
render(<TransitJourneyModal {...makeProps({ onDelete })} />)
|
|
await user.click(screen.getByRole('button', { name: /^Delete$/ }))
|
|
expect(onDelete).not.toHaveBeenCalled()
|
|
// Confirm dialog appears — confirm it.
|
|
const confirmBtns = await screen.findAllByRole('button', { name: /Delete/ })
|
|
await user.click(confirmBtns[confirmBtns.length - 1])
|
|
await waitFor(() => expect(onDelete).toHaveBeenCalled())
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-005: read-only without edit rights — no delete/save/change-route', () => {
|
|
render(<TransitJourneyModal {...makeProps({ canEdit: false })} />)
|
|
expect(screen.queryByRole('button', { name: /^Delete$/ })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /Change route/ })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument()
|
|
// The round X in the head band and the Close that finishes the footer.
|
|
expect(screen.getAllByRole('button', { name: /^Close$/ })).toHaveLength(2)
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-009: metadata stored as a JSON string is parsed into the itinerary', () => {
|
|
const res = makeReservation()
|
|
res.metadata = JSON.stringify(res.metadata)
|
|
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
|
expect(screen.getByText('U2')).toBeInTheDocument()
|
|
expect(screen.getByText('Transfers')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-010: unparsable metadata degrades to a journey without an itinerary', () => {
|
|
const res = { ...makeReservation(), metadata: '{not json' }
|
|
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
|
// Title/date header still renders, the stat tiles and legs do not.
|
|
expect(screen.getByText('Fernsehturm')).toBeInTheDocument()
|
|
expect(screen.queryByText('Transfers')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('U2')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-011: a zero-duration, zero-walk journey renders em dashes, not "0 min"', () => {
|
|
const res = makeReservation()
|
|
res.metadata = { transit: { ...res.metadata.transit, duration: 0, walk_seconds: 12, transfers: 0 } }
|
|
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
|
// Duration + walking tiles both fall back to the dash; transfers shows 0.
|
|
expect(screen.getAllByText('—')).toHaveLength(2)
|
|
expect(screen.getByText('0')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-012: Escape while renaming restores the previous title', async () => {
|
|
const user = userEvent.setup()
|
|
render(<TransitJourneyModal {...makeProps()} />)
|
|
await user.click(titleButton())
|
|
const input = screen.getByDisplayValue('Fernsehturm → Zoo')
|
|
await user.clear(input)
|
|
await user.type(input, 'Scrapped')
|
|
await user.keyboard('{Escape}')
|
|
// Inline editing closed and the original title is back — save stays disabled.
|
|
expect(screen.queryByDisplayValue('Scrapped')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Fernsehturm')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-013: blurring the title input closes inline editing but keeps the typed name', async () => {
|
|
const user = userEvent.setup()
|
|
render(<TransitJourneyModal {...makeProps()} />)
|
|
await user.click(titleButton())
|
|
const input = screen.getByDisplayValue('Fernsehturm → Zoo')
|
|
await user.clear(input)
|
|
await user.type(input, 'Zum Zoo')
|
|
fireEvent.blur(input)
|
|
expect(screen.queryByDisplayValue('Zum Zoo')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Zum Zoo')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-014: switching to a different reservation re-seeds title and notes', () => {
|
|
const first = { ...makeReservation(), notes: 'first note' }
|
|
const { rerender } = render(<TransitJourneyModal {...makeProps({ reservation: first })} />)
|
|
expect(document.querySelector('.collab-note-md')?.textContent).toContain('first note')
|
|
|
|
const second = { ...makeReservation(), id: 8, title: 'Zoo → Hbf', notes: '' }
|
|
rerender(<TransitJourneyModal {...makeProps({ reservation: second })} />)
|
|
expect(screen.getByText('Hbf')).toBeInTheDocument()
|
|
// No notes on the new entry → the write tab is active again.
|
|
expect(screen.getByPlaceholderText(/notes/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('TransitJourneyModal (mobile viewport)', () => {
|
|
const desktopWidth = window.innerWidth
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
|
})
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: desktopWidth })
|
|
})
|
|
|
|
function makeMobileReservation() {
|
|
const res = makeReservation()
|
|
res.metadata = {
|
|
transit: {
|
|
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
|
|
legs: [
|
|
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
|
|
{
|
|
mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF',
|
|
headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6,
|
|
from: { name: 'Alexanderplatz', time: '08:36', track: '2' },
|
|
to: { name: 'Zoo', time: '09:00' },
|
|
},
|
|
// No line/colour/headsign/agency and no times — exercises every fallback.
|
|
{ mode: 'BUS', from: { name: 'Zoo' }, to: { name: 'Kudamm' } },
|
|
],
|
|
},
|
|
}
|
|
return res
|
|
}
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-015: renders the per-leg rail with platform, headsign and meta line', () => {
|
|
render(<TransitJourneyModal {...makeProps({ reservation: makeMobileReservation() })} />)
|
|
expect(screen.getByText('U2')).toBeInTheDocument()
|
|
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
|
|
expect(screen.getByText('Ruhleben')).toBeInTheDocument()
|
|
// mins · stops · agency collapse into one quiet line.
|
|
expect(screen.getByText(/24 min · 6 stops · BVG/)).toBeInTheDocument()
|
|
expect(screen.getByText('08:36')).toBeInTheDocument()
|
|
// The meta-less leg falls back to its mode as the badge and shows no meta line.
|
|
expect(screen.getByText('BUS')).toBeInTheDocument()
|
|
expect(screen.getByText('Kudamm')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-PLANNER-TRANSITJOURNEY-016: mobile stat tiles stay iconless but keep value + caption', () => {
|
|
render(<TransitJourneyModal {...makeProps({ reservation: makeMobileReservation() })} />)
|
|
expect(screen.getByText('Transfers')).toBeInTheDocument()
|
|
expect(screen.getByText('Walking')).toBeInTheDocument()
|
|
expect(screen.getByText('1')).toBeInTheDocument()
|
|
// Delete collapses to an icon-only button on a phone.
|
|
const deleteBtn = screen.getByRole('button', { name: 'Delete' })
|
|
expect(deleteBtn.textContent?.trim()).toBe('')
|
|
})
|
|
})
|