Files
TREK/client/e2e/help/trip-shared.ts
Maurice cb7014b378 feat(costs): table view, and the wiki and help center for the new trip page
Costs gets a second view next to the ledger: the expenses as a table grouped
by category, with name, date, total, persons and days editable in place and
the per person and per day figures worked out beside them. A total with a
payer or in a foreign currency stays locked and opens the expense instead, so
balances and frozen rates are never changed behind the dialog. In the table
view the side column shows a summary by category, day, payer and status. The
switch sits in the bar and the choice is remembered; the ledger stays the
default.

On the Plan tab a place's reservation shows as one Reservation pill whose
colour carries the status and which opens the booking, and the drop line of
a drag that lost its source row now clears on the next mouse move. The
booking detail formats its times in the user's clock, and a hotel without a
check-out runs to the end of the last day in the timeline.

The wiki pages of the trip are rewritten for the new layout, with new
screenshots from the screenshot run (new Plan tab, bookings, transports,
dialogs, costs table). The help center guides of the Plan tab are rewritten
and translated into every locale, and every new string is translated.
2026-09-27 23:15:19 +02:00

201 lines
9.2 KiB
TypeScript

import { expect, type Locator, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { dismissReleaseNotice, settle, type StepAction } from './guide'
import { seededTrip } from './fixtures'
/**
* What every trip screen's script needs: the way into the seeded trip, the
* day selection and the dialogs. Not a `.guide.ts`, so importing it registers
* no tests twice.
*/
export const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
export const dialog = (page: Page) => modal(page).locator('> [role="presentation"], > div').first()
/**
* ConfirmDialog is its own portal and carries `trek-backdrop-enter` without the
* `trek-modal-backdrop` of `Modal`, so the yes/no questions need their own
* locator: `modal` would never see them.
*/
export const confirmBackdrop = (page: Page) => page.locator('.trek-backdrop-enter:not(.trek-modal-backdrop)').last()
export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.trek-modal-enter').last()
/**
* The import dialogs of the places column are portals of their own with no
* shared backdrop class at all, so they are found by what stands inside them.
* Both the overlay and the card carry `role="presentation"`, and the card comes
* second in document order, which is the one worth a picture.
*/
export const portalDialog = (page: Page, contains: Locator): Locator =>
page.locator('div[role="presentation"]').filter({ has: contains }).last()
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
const { tripId } = seededTrip()
const { tab, day = 1 } = opts
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
await clearNotices(page)
await dismissReleaseNotice(page)
// Exact: the Lists tab carries a Shared button, which the loose name also matches.
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
await untilTranslated(page)
if (!tab && day) {
// The trip is running, so the plan opens on today, an empty day at the
// bottom. Day 1 has the places; its details panel would cover the map.
await selectDay(page, day)
await closeDayDetails(page)
}
// The map tiles land after the page says it is idle.
await page.waitForTimeout(1200)
}
/**
* A text that is still its key: `dayplan.dayN` where "Day 1" belongs. The
* locale bundles come from `shared/dist`, and while a watcher rebuilds it a
* page can load with a part of them missing. Reloading after the rebuild has
* landed brings the strings back; a page that stays untranslated fails here
* rather than in a picture.
*/
const rawKey = (page: Page) => page.getByText(/^(common|dayplan|planner|places|reservations|trip|day|budget|files|collab|packing|todo)\.[A-Za-z0-9.]+$/)
async function untilTranslated(page: Page): Promise<void> {
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
await page.waitForTimeout(4000)
await page.reload()
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
}
/** Click the day's header: selects it and opens its details panel. */
export async function selectDay(page: Page, n: number): Promise<void> {
const header = page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
// The header carries buttons of its own: the booked night in the middle, the
// transit, note and fold actions on the right. A click on the centre lands on
// the booked night as soon as a day has one, which selects nothing, so the
// click goes to the day's number at the left edge.
await header.click({ position: { x: 22, y: 20 } })
// The details panel is the selection: without it the day's route tools, the
// + on a place row and To day are all absent, and a guide fails far from here.
await expect(dayDetails(page)).toBeVisible({ timeout: 15_000 })
await settle(page)
}
/** The day details panel: the one fixed, floating card over the map. */
export const dayDetails = (page: Page) => page.locator('div.fixed.z-50').first()
/**
* The details panel's own Close, in its head band. Found inside the panel: the
* last X of the page is not always this one once the day carries bookings.
*/
export async function closeDayDetails(page: Page): Promise<void> {
const close = dayDetails(page).getByRole('button', { name: 'Close', exact: true })
if (await close.isVisible().catch(() => false)) {
await close.click()
await settle(page)
}
}
/**
* Open the trip with day `n` selected and its details panel standing. The panel
* IS the selection — closing it deselects the day — and the day's route tools
* and the "+" at the end of a place row render for a selected day only, so a
* guide about either of them starts here rather than at `openTrip`.
*/
export async function openTripOnDay(page: Page, n = 1): Promise<void> {
await openTrip(page, { day: null })
await selectDay(page, n)
}
/**
* Dismiss a `ContextMenu`. It closes on a document click and ignores Escape,
* and a real click would land on the map or on the row underneath.
*/
export async function closeMenu(page: Page): Promise<void> {
await page.evaluate(() => document.body.click())
await settle(page)
}
// ── Import from file ──────────────────────────────────────────────────────────
/**
* Import from file, on the Bookings and the Transports tab alike. The dialog
* is another bare portal, told apart by its heading; the parse it starts is
* reported by the background widget, a fixed portal in the bottom-right
* corner (the one at z-index 50000) with a card per run.
*/
export const importDialog = (page: Page) =>
portalDialog(page, page.getByText('Import booking confirmations', { exact: true }))
export const importWidget = (page: Page) => page.locator('body > div[style*="z-index: 50000"]')
/** The widget's card for the run that parses `file`; the card is labelled with the file names. */
export const importTask = (page: Page, file: string) =>
importWidget(page).locator('.bg-surface-card').filter({ hasText: file }).last()
/**
* The first three steps of an import guide, the same on either tab: the
* toolbar button, the drop box with the file named on it, and Import.
*/
export function importSteps(fixture: () => string, file: string): StepAction[] {
return [
{
// An icon in the toolbar, named like the dialog it opens.
target: p => p.getByRole('button', { name: 'Import booking confirmations', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Import booking confirmations', exact: true }).click()
await expect(importDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
// Set the hidden input directly: the box opens the file picker of the
// operating system, which Playwright cannot photograph.
await importDialog(p).locator('input[type="file"]').setInputFiles(fixture())
await expect(importDialog(p).getByText(file)).toBeVisible()
await settle(p)
},
target: p => importDialog(p).getByText(file).locator('xpath=ancestor::button[1]'),
act: settle,
},
{
target: p => importDialog(p).getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await importDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(importDialog(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
]
}
/**
* Put the widget away after an import guide. The review takes the card with
* it when it starts, so one still standing means the run stopped before
* that, and it would sit in the corner of every later guide's pictures. Close
* is only offered once the parse has stopped, and the wait is bounded so a
* card nobody can close does not fail the guide from its cleanup.
*/
export async function dismissImportTask(page: Page, file: string): Promise<void> {
const task = importTask(page, file)
if (!(await task.isVisible().catch(() => false))) return
const close = task.getByRole('button', { name: 'Close' })
await close.waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {})
if (await close.isVisible().catch(() => false)) await close.click()
}
/**
* Take a document a guide uploaded out of the trip for good. DELETE alone is
* the trash, and the Files guides photograph the trash, so the permanent
* delete follows.
*/
export async function deleteTripFiles(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/files`)
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of files.filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
}