From cb7014b378752e16669764c50d2db29e3ae88d68 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 27 Sep 2026 23:15:19 +0200 Subject: [PATCH] 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. --- client/e2e/help/trip-day-detail.guide.ts | 46 ++- client/e2e/help/trip-days.guide.ts | 45 +- client/e2e/help/trip-place.guide.ts | 21 +- client/e2e/help/trip-places.guide.ts | 120 ++++-- client/e2e/help/trip-roadtrip.guide.ts | 3 +- client/e2e/help/trip-shared.ts | 34 +- client/e2e/help/trip-transports.guide.ts | 108 +++-- client/e2e/help/trip.guide.ts | 13 +- client/e2e/screenshots/collab.shot.ts | 13 +- client/e2e/screenshots/detail.shot.ts | 7 +- client/e2e/screenshots/dialogs.shot.ts | 7 +- client/e2e/screenshots/pages.shot.ts | 14 +- client/e2e/screenshots/plan.shot.ts | 142 +++++++ client/e2e/screenshots/planner.shot.ts | 9 +- client/e2e/screenshots/shot.ts | 24 +- .../Budget/CostsPanel.table.test.tsx | 92 +++++ client/src/components/Budget/CostsPanel.tsx | 53 ++- .../src/components/Budget/CostsTable.test.tsx | 227 ++++++++++ client/src/components/Budget/CostsTable.tsx | 387 ++++++++++++++++++ .../components/Budget/CostsToolbar.test.tsx | 85 +++- client/src/components/Budget/CostsToolbar.tsx | 23 +- .../Budget/ExpenseModal.receipts.test.tsx | 81 ++++ .../Budget/ReceiptScanModal.test.tsx | 18 +- .../components/Collab/CollabLinks.test.tsx | 35 +- .../Collab/CollabPanelHead.test.tsx | 33 ++ .../components/Files/FileManagerRow.test.tsx | 105 +++++ .../Planner/DayPlanSidebar.test.tsx | 61 ++- .../src/components/Planner/DayPlanSidebar.tsx | 59 ++- .../Planner/PlaceFormModal.test.tsx | 64 ++- .../components/Planner/PlacesSidebar.test.tsx | 22 +- .../Planner/ReservationsPanel.views.test.tsx | 318 ++++++++++++++ .../Planner/TransportModal.test.tsx | 43 +- .../bookings/BookingDetailDialog.test.tsx | 210 ++++++++++ .../Planner/bookings/BookingDetailDialog.tsx | 5 +- .../bookings/BookingsTimeline.test.tsx | 242 +++++++++++ .../Planner/bookings/bookingFacts.test.ts | 192 +++++++++ .../Planner/bookings/bookingsModel.test.ts | 353 ++++++++++++++++ .../Planner/bookings/showOnMap.test.ts | 99 +++++ .../Planner/bookings/timelineModel.test.ts | 192 +++++++++ .../Planner/bookings/timelineModel.ts | 5 +- .../Planner/bookings/transitParts.test.tsx | 76 ++++ .../Planner/bookings/useBookingsView.test.ts | 199 +++++++++ .../Planner/placeDialogParts.test.tsx | 28 ++ .../src/components/Planner/planParts.test.tsx | 136 ++++++ .../components/Weather/WeatherWidget.test.tsx | 27 +- .../components/shared/ConfirmDialog.test.tsx | 27 ++ .../components/shared/DialogShell.test.tsx | 172 ++++++++ .../src/pages/TripPlannerPage.wiring.test.tsx | 28 +- shared/src/i18n/ar/budget.ts | 13 + shared/src/i18n/ar/help.ts | 118 +++--- shared/src/i18n/az/budget.ts | 13 + shared/src/i18n/az/help.ts | 115 +++--- shared/src/i18n/br/budget.ts | 13 + shared/src/i18n/br/help.ts | 116 +++--- shared/src/i18n/ca/budget.ts | 13 + shared/src/i18n/ca/help.ts | 114 +++--- shared/src/i18n/cs/budget.ts | 13 + shared/src/i18n/cs/help.ts | 119 +++--- shared/src/i18n/de/budget.ts | 13 + shared/src/i18n/de/help.ts | 114 +++--- shared/src/i18n/en/budget.ts | 13 + shared/src/i18n/en/help.ts | 118 +++--- shared/src/i18n/es/budget.ts | 13 + shared/src/i18n/es/help.ts | 114 +++--- shared/src/i18n/et/budget.ts | 13 + shared/src/i18n/et/help.ts | 117 +++--- shared/src/i18n/fr/budget.ts | 14 + shared/src/i18n/fr/help.ts | 115 +++--- shared/src/i18n/gr/budget.ts | 13 + shared/src/i18n/gr/help.ts | 116 +++--- shared/src/i18n/hu/budget.ts | 13 + shared/src/i18n/hu/help.ts | 113 ++--- shared/src/i18n/id/budget.ts | 13 + shared/src/i18n/id/help.ts | 115 +++--- shared/src/i18n/it/budget.ts | 13 + shared/src/i18n/it/help.ts | 114 +++--- shared/src/i18n/ja/budget.ts | 13 + shared/src/i18n/ja/help.ts | 114 +++--- shared/src/i18n/ko/budget.ts | 13 + shared/src/i18n/ko/help.ts | 117 +++--- shared/src/i18n/nl/budget.ts | 13 + shared/src/i18n/nl/help.ts | 114 +++--- shared/src/i18n/pl/budget.ts | 13 + shared/src/i18n/pl/help.ts | 116 +++--- shared/src/i18n/ru/budget.ts | 13 + shared/src/i18n/ru/help.ts | 118 +++--- shared/src/i18n/sk/budget.ts | 13 + shared/src/i18n/sk/help.ts | 116 +++--- shared/src/i18n/sv/budget.ts | 13 + shared/src/i18n/sv/help.ts | 119 +++--- shared/src/i18n/th/budget.ts | 13 + shared/src/i18n/th/help.ts | 117 +++--- shared/src/i18n/tr/budget.ts | 13 + shared/src/i18n/tr/help.ts | 116 +++--- shared/src/i18n/uk/budget.ts | 13 + shared/src/i18n/uk/help.ts | 119 +++--- shared/src/i18n/uk/reservations.ts | 2 +- shared/src/i18n/vi/budget.ts | 13 + shared/src/i18n/vi/help.ts | 117 +++--- shared/src/i18n/zh-TW/budget.ts | 13 + shared/src/i18n/zh-TW/help.ts | 123 +++--- shared/src/i18n/zh/budget.ts | 13 + shared/src/i18n/zh/help.ts | 123 +++--- wiki/AI-Booking-Import.md | 116 +++--- wiki/Accommodations.md | 144 +++---- wiki/Budget-Tracking.md | 162 +++++--- wiki/Calendar-Feeds.md | 76 ++-- wiki/Collab-Chat.md | 40 +- wiki/Collab-Notes.md | 89 ++-- wiki/Collab-Polls.md | 56 ++- wiki/Day-Plans-and-Notes.md | 179 +++++--- wiki/Documents-and-Files.md | 90 ++-- wiki/Map-Features.md | 91 ++-- wiki/PDF-Export.md | 73 ++-- wiki/Packing-Lists.md | 131 +++--- wiki/Places-and-Search.md | 238 +++++++---- wiki/Reservations-and-Bookings.md | 245 +++++++---- wiki/Route-Optimization.md | 48 ++- wiki/Todos-and-Tasks.md | 58 ++- wiki/Transport-Flights-Trains-Cars.md | 166 +++++--- wiki/Trip-Planner-Overview.md | 173 +++----- wiki/Weather-Forecasts.md | 38 +- wiki/Whats-Next-Widget.md | 32 +- wiki/assets/BookingDetail.png | Bin 0 -> 55678 bytes wiki/assets/BookingEditor.png | Bin 0 -> 102921 bytes wiki/assets/BookingsList.png | Bin 0 -> 66047 bytes wiki/assets/BookingsTimeline.png | Bin 0 -> 76652 bytes wiki/assets/Collab.png | Bin 235560 -> 251685 bytes wiki/assets/CollabChat.png | Bin 83861 -> 102483 bytes wiki/assets/CollabNotes.png | Bin 89825 -> 65012 bytes wiki/assets/CollabPolls.png | Bin 40215 -> 50252 bytes wiki/assets/Costs.png | Bin 202450 -> 189316 bytes wiki/assets/CostsSettleUp.png | Bin 24469 -> 39058 bytes wiki/assets/CostsTable.png | Bin 0 -> 195187 bytes wiki/assets/DayDetails.png | Bin 0 -> 650332 bytes wiki/assets/ExpenseDialog.png | Bin 0 -> 117652 bytes wiki/assets/ExportDialog.png | Bin 0 -> 67730 bytes wiki/assets/PackingList.png | Bin 133956 -> 137814 bytes wiki/assets/PlaceForm.png | Bin 0 -> 242506 bytes wiki/assets/PlaceInspector.png | Bin 0 -> 427035 bytes wiki/assets/PlacesColumn.png | Bin 0 -> 126866 bytes wiki/assets/PlanDayAddMenu.png | Bin 0 -> 661895 bytes wiki/assets/PlanDayCard.png | Bin 0 -> 50364 bytes wiki/assets/PlanRouteBar.png | Bin 0 -> 8007 bytes wiki/assets/StayEditor.png | Bin 0 -> 178169 bytes wiki/assets/TransportEditor.png | Bin 0 -> 94458 bytes wiki/assets/Transports.png | Bin 87199 -> 85686 bytes wiki/assets/TripPlanner.png | Bin 622049 -> 658432 bytes 148 files changed, 7361 insertions(+), 2688 deletions(-) create mode 100644 client/e2e/screenshots/plan.shot.ts create mode 100644 client/src/components/Budget/CostsPanel.table.test.tsx create mode 100644 client/src/components/Budget/CostsTable.test.tsx create mode 100644 client/src/components/Budget/CostsTable.tsx create mode 100644 client/src/components/Budget/ExpenseModal.receipts.test.tsx create mode 100644 client/src/components/Collab/CollabPanelHead.test.tsx create mode 100644 client/src/components/Files/FileManagerRow.test.tsx create mode 100644 client/src/components/Planner/ReservationsPanel.views.test.tsx create mode 100644 client/src/components/Planner/bookings/BookingDetailDialog.test.tsx create mode 100644 client/src/components/Planner/bookings/BookingsTimeline.test.tsx create mode 100644 client/src/components/Planner/bookings/bookingFacts.test.ts create mode 100644 client/src/components/Planner/bookings/bookingsModel.test.ts create mode 100644 client/src/components/Planner/bookings/showOnMap.test.ts create mode 100644 client/src/components/Planner/bookings/timelineModel.test.ts create mode 100644 client/src/components/Planner/bookings/transitParts.test.tsx create mode 100644 client/src/components/Planner/bookings/useBookingsView.test.ts create mode 100644 client/src/components/Planner/placeDialogParts.test.tsx create mode 100644 client/src/components/Planner/planParts.test.tsx create mode 100644 client/src/components/shared/DialogShell.test.tsx create mode 100644 wiki/assets/BookingDetail.png create mode 100644 wiki/assets/BookingEditor.png create mode 100644 wiki/assets/BookingsList.png create mode 100644 wiki/assets/BookingsTimeline.png create mode 100644 wiki/assets/CostsTable.png create mode 100644 wiki/assets/DayDetails.png create mode 100644 wiki/assets/ExpenseDialog.png create mode 100644 wiki/assets/ExportDialog.png create mode 100644 wiki/assets/PlaceForm.png create mode 100644 wiki/assets/PlaceInspector.png create mode 100644 wiki/assets/PlacesColumn.png create mode 100644 wiki/assets/PlanDayAddMenu.png create mode 100644 wiki/assets/PlanDayCard.png create mode 100644 wiki/assets/PlanRouteBar.png create mode 100644 wiki/assets/StayEditor.png create mode 100644 wiki/assets/TransportEditor.png diff --git a/client/e2e/help/trip-day-detail.guide.ts b/client/e2e/help/trip-day-detail.guide.ts index a918ad56c..b03352640 100644 --- a/client/e2e/help/trip-day-detail.guide.ts +++ b/client/e2e/help/trip-day-detail.guide.ts @@ -31,17 +31,29 @@ const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: ne * nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it; * the card is the second `role="presentation"` and holds the day range. */ -const picker = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Apply to days' }).last() -/** The accommodation block: the heading's parent holds the stays and the add button. */ -const stays = (page: Page) => panel(page).getByText('Accommodation', { exact: true }).locator('xpath=..') -/** One booked stay: a card of the list inside that block. */ -const stayCard = (page: Page, name: string) => stays(page).locator('> div > div').filter({ hasText: name }).first() +const picker = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Apply to days' }) +/** The row of the stay's three times, with Confirmation right under it. */ +const times = (page: Page) => picker(page).getByPlaceholder('14:00').locator('xpath=ancestor::div[contains(@class,"grid-cols-3")][1]') +/** + * A block of the panel by its heading. Every block is a
whose first + * row carries the heading, so the section is the block with everything in it. + */ +const block = (page: Page, heading: string) => + panel(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]') +/** The accommodation block: the stays and the add button. */ +const stays = (page: Page) => block(page, 'Accommodation') +/** One booked stay: an
card inside that block. */ +const stayCard = (page: Page, name: string) => stays(page).locator('article').filter({ hasText: name }).first() /** The caption that opens the weather block. */ const forecast = (page: Page) => panel(page).getByText(/^Forecast for /) -/** The chips row; the summary above it and the hourly strip below are its siblings. */ -const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[2]') +/** The weather's pills (rain, wind, sunrise, sunset), at the right end of the summary row. */ +const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[1]') +/** The summary row's numbers: the temperature, the low and high, the condition. */ +const summary = (page: Page) => forecast(page).locator('xpath=following-sibling::div[1]/div[1]') +/** The hourly strip under its own heading. */ +const hourly = (page: Page) => panel(page).getByText('Hourly Forecast', { exact: true }).locator('xpath=..') /** The day's reservations block. */ -const bookings = (page: Page) => panel(page).getByText('Reservations', { exact: true }).locator('xpath=..') +const bookings = (page: Page) => block(page, 'Reservations') /** One booking row: on the desktop a button that opens the booking, named with its title. */ const bookingRow = (page: Page, title: string) => panel(page).getByRole('button', { name: title }) /** The booking a row opens, named by its title. */ @@ -158,9 +170,9 @@ const SCRIPTS: Record = { }, steps: [ only(forecast), - only(p => chips(p).locator('xpath=preceding-sibling::div[1]')), + only(summary), only(chips), - only(p => chips(p).locator('xpath=following-sibling::div[1]')), + only(hourly), ], }, 'rename-day': { @@ -216,7 +228,8 @@ const SCRIPTS: Record = { await expect(picker(p).getByRole('button', { name: /^Day 7/ })).toBeVisible() await settle(p) }, - target: p => picker(p).getByText('Apply to days').locator('xpath=..'), + // The row of Start, End and All: the All button's parent. + target: p => picker(p).getByRole('button', { name: 'All', exact: true }).locator('xpath=..'), }, { // The three time fields normalise what is typed; the labels carry no @@ -228,13 +241,14 @@ const SCRIPTS: Record = { await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842') await settle(p) }, - target: p => picker(p).getByPlaceholder('ABC-12345').locator('xpath=ancestor::div[2]'), + target: times, }, { - // "All" is both the whole-trip shortcut and the no-filter chip, so the - // category is clicked by its own name. + // The category is a pill over the list; its choices open in a list of their own. prepare: async p => { - await picker(p).getByRole('button', { name: 'Hotel', exact: true }).click() + await picker(p).getByRole('button', { name: /^Category:/ }).click() + await p.getByRole('group', { name: 'Category' }).getByRole('button', { name: 'Hotel', exact: true }).click() + await expect(p.getByRole('group', { name: 'Category' })).toHaveCount(0) await settle(p) }, target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }), @@ -280,7 +294,7 @@ const SCRIPTS: Record = { await picker(p).getByPlaceholder('ABC-12345').click() await settle(p) }, - target: p => picker(p).getByPlaceholder('ABC-12345').locator('xpath=ancestor::div[2]'), + target: times, }, { target: p => picker(p).getByRole('button', { name: 'Save' }), diff --git a/client/e2e/help/trip-days.guide.ts b/client/e2e/help/trip-days.guide.ts index 70f59090d..4a6c20613 100644 --- a/client/e2e/help/trip-days.guide.ts +++ b/client/e2e/help/trip-days.guide.ts @@ -50,6 +50,10 @@ const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name const noteRow = (page: Page) => page.getByRole('button', { name: new RegExp(`^${NOTE.title}`) }) /** A row of the places column. */ const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() +/** The "+ Day" at the end of a places row: it puts the place on the open day. */ +const toOpenDay = (page: Page, name: string) => placeRow(page, name).getByRole('button', { name: '+ Day' }) +/** The "+" in a day's head band; the note, the transports and the stay are in the menu it opens. */ +const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' }) /** * The context menu of a stop, a note or a leg. Tooltip renders into the same * `.trek-popover-enter` class and a click on a connector leaves its tooltip @@ -70,9 +74,9 @@ const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).loc * leg of their own, and the day fixtures put a hotel on the first four nights. */ const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first() -/** The note dialog: its own portal with no backdrop class, known by its body field. */ +/** The note dialog, known by its body field. */ const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…') -const noteDialog = (page: Page) => portalDialog(page, noteBody(page)) +const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBody(page) }) /** The question a timed stop asks before it moves; also a portal of its own. */ const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?')) /** The reorder popup's panel, the list and its footer, or the question a bin asks in its place. */ @@ -149,7 +153,7 @@ const SCRIPTS: Record = { // The "+" offers the OPEN day, so it is still there with the place on // day 2 — and the place stays on a day, which is what the result says. prepare: async p => { await placeRow(p, SPARE.name).hover() }, - target: p => placeRow(p, SPARE.name).getByRole('button'), + target: p => toOpenDay(p, SPARE.name), }, only(p => p.getByRole('button', { name: 'Add place to this day' }).first()), only(p => p.getByRole('button', { name: 'Add to the open day' })), @@ -250,7 +254,7 @@ const SCRIPTS: Record = { // The "+" puts the place on the OPEN day, so day 1 stays selected. await openTripOnDay(p, 1) await placeRow(p, SPARE.name).hover() - await placeRow(p, SPARE.name).getByRole('button').click() + await toOpenDay(p, SPARE.name).click() await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 }) await settle(p) }, @@ -298,22 +302,28 @@ const SCRIPTS: Record = { start: p => openTrip(p), steps: [ { - target: p => dayHeader(p, 2).getByRole('button', { name: 'Add Note' }), + // The "+" of the day's head band opens the menu; Add Note is its last entry. + prepare: async p => { + await addToDay(p, 2).click() + await expect(menu(p).getByRole('button', { name: 'Add Note' })).toBeVisible() + await beat(p, 300) + }, + target: p => menu(p).getByRole('button', { name: 'Add Note' }), act: async p => { - await dayHeader(p, 2).getByRole('button', { name: 'Add Note' }).click() + await menu(p).getByRole('button', { name: 'Add Note' }).click() await expect(noteBody(p)).toBeVisible() await settle(p) }, }, { prepare: async p => { - // Note is the name the day card shows and the only required field; - // Daily Note below it is the markdown the toolbar formats. + // The name is typed into the head band and is what the day card shows, + // the only required field; Daily Note is the markdown the toolbar formats. await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title) await typeInto(p, noteBody(p), NOTE.body) await beat(p, 300) }, - target: p => noteBody(p).locator('xpath=ancestor::div[2]'), + target: noteDialog, }, { target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'), @@ -416,8 +426,11 @@ const SCRIPTS: Record = { await settle(p) }, // Scoped to the panel: a stop row carries an Edit of its own, so the - // bare name matches two buttons as soon as any day is unfolded. - target: p => renameDay(p).locator('xpath=..'), + // bare name matches two buttons as soon as any day is unfolded. The + // ring goes round the panel's head band, title and pencil together: a + // ring round the pencil alone sits on the day's name beside it. + target: p => renameDay(p).locator('xpath=ancestor::div[@role="presentation"][1]'), + hover: p => renameDay(p).hover(), act: closeDayDetails, }, only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })), @@ -431,7 +444,7 @@ const SCRIPTS: Record = { steps: [ // The flight, not the train: Departure and Arrival are what a booking // gets when it crosses days, and the train starts and ends on one. - only(p => bookingRow(p, /^Departure LH716/)), + only(p => bookingRow(p, /^LH716 FRA → HND Departure/)), { prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) }, target: p => stop(p, 'Nishiki Market'), @@ -442,7 +455,7 @@ const SCRIPTS: Record = { await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible() await settle(p) }, - target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=..'), + target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'), act: closeDayDetails, }, only(p => toolbar(p).getByRole('button', { name: /Show all booking routes|Hide all booking routes/ })), @@ -464,9 +477,9 @@ const SCRIPTS: Record = { await settle(p) }, }, - only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=..')), - only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=..')), - only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=..')), + only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=ancestor::section[1]')), + only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=ancestor::section[1]')), + only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=ancestor::section[1]')), ], cleanup: closeModal, }, diff --git a/client/e2e/help/trip-place.guide.ts b/client/e2e/help/trip-place.guide.ts index 781ae32bb..f9eddb0f7 100644 --- a/client/e2e/help/trip-place.guide.ts +++ b/client/e2e/help/trip-place.guide.ts @@ -38,19 +38,22 @@ const UPLOADED = 'cover-fixture.jpg' /** The card itself: the scroll body's parent holds head, body and footer. */ const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..') -const head = (page: Page) => card(page).locator('> div').first() -const footer = (page: Page) => card(page).locator('div.border-t') +const head = (page: Page) => card(page).locator('> header') +const footer = (page: Page) => card(page).locator('> footer') +/** A block of the card by its heading: every block is a
that opens with it. */ +const section = (page: Page, heading: string) => + card(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]') /** A row of the places column (the rows are options of a list). */ const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() /** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */ const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first() /** PlaceRating's root: the stars, the average and the voter faces on one line. */ const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..') -/** ParticipantsBox: found by its eyebrow, which is the box's first child. */ -const participants = (page: Page) => card(page).getByText('Participants', { exact: true }).locator('xpath=..') +/** ParticipantsBox: the block under its heading. */ +const participants = (page: Page) => section(page, 'Participants') /** A member's chip, or, once the chip is gone, that member's line in the add list. */ const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) }) -const addMember = (page: Page) => participants(page).getByRole('button', { name: '+', exact: true }) +const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true }) /** "Files" while the place has none, " files" once it has. */ const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ }) /** The booking strip opens the booking; it is a button named after it. */ @@ -71,8 +74,8 @@ const weekday = (offset: number) => long(offset).split(',')[0] * ("Saturday: 06:00-17:00") and the day moves with the run. */ const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)') -/** The hours box: the row and, once unfolded, the week under it. */ -const hoursBox = (page: Page) => hoursRow(page).locator('xpath=..') +/** The hours block: the row and, once unfolded, the week in its place. */ +const hoursBox = (page: Page) => section(page, 'Opening Hours') /** The card's own X, the one in its head; the day panel over the map has another. */ async function closeCard(page: Page): Promise { @@ -237,7 +240,7 @@ const SCRIPTS: Record = { // around the collapsed row would show the reader one line of it. prepare: async p => { await hoursRow(p).click() - await expect(hoursRow(p)).toHaveText('Opening Hours') + await expect(hoursBox(p).getByRole('button', { name: 'Collapse' })).toBeVisible() await expect(hoursBox(p).getByText(/^Sunday: /)).toBeVisible() await settle(p) }, @@ -551,7 +554,7 @@ const SCRIPTS: Record = { await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible() }, }, - only(p => card(p).getByText('Track Stats', { exact: true }).locator('xpath=ancestor::div[2]')), + only(p => section(p, 'Track Stats')), { target: p => card(p).getByRole('button', { name: 'Track color' }), act: async p => { diff --git a/client/e2e/help/trip-places.guide.ts b/client/e2e/help/trip-places.guide.ts index 561653ede..ebddfdb6e 100644 --- a/client/e2e/help/trip-places.guide.ts +++ b/client/e2e/help/trip-places.guide.ts @@ -1,7 +1,7 @@ import { test, expect, type Page, type Locator } from '@playwright/test' import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' import { seededTrip, gpxFixture, ensureTrack } from './fixtures' -import { openTrip, modal, dialog, confirmDialog, portalDialog } from './trip-shared' +import { openTrip, modal, dialog, confirmDialog } from './trip-shared' import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces' import type { HelpGuide } from '../../src/help/types' @@ -22,22 +22,41 @@ const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, addre /** What create-place looks up. See the note in its second step for why this one. */ const SEARCHED = { query: 'Osaka Castle', match: /^Osaka Castle/ } +/** The search result create-place picks. */ +const suggestion = (page: Page) => + modal(page).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first() /** A row of the places column by the place's name (the rows are options of a list). */ const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() /** The context menu is a fixed popover of plain buttons; the item texts tell it apart. */ const menu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Delete' }) }).last() /** The add button: "Add Place/Activity" with no day open, "New place" with one. */ const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\/Activity|New place)$/ }) -/** The filter row's controls. */ -const filterSelect = (page: Page) => page.getByTestId('places-filter').getByRole('button').first() -const searchBox = (page: Page) => page.getByPlaceholder('Search places...').last() -/** FileImportModal: its own portal, recognised by the hidden file input and its heading. */ +/** + * The head band of the column: the add row, the search with the select switch, + * and the row of filters. Found from its import button, the one control only + * this band has, so the plain "Search" of the field is read inside it. + */ +const head = (page: Page) => + page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]') +/** The import button's menu: Import file and the list import. */ +const importMenu = (page: Page) => + page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Import file' }) }).last() +/** The Show dropdown (All, Unplanned, Planned, Tracks) and the list it opens. */ +const showTrigger = (page: Page) => head(page).getByRole('button', { name: 'Show', exact: true }) +const showList = (page: Page) => page.getByTestId('places-filter').locator('xpath=..') +/** The category filter: a tag button whose list sits right after it. */ +const categoryTrigger = (page: Page) => head(page).getByRole('button', { name: 'Categories', exact: true }) +const categoryList = (page: Page) => categoryTrigger(page).locator('xpath=following-sibling::div[1]') +const searchBox = (page: Page) => head(page).getByPlaceholder('Search', { exact: true }) +/** The selection bar at the foot of the column, found by its delete action. */ +const selectionBar = (page: Page) => page.getByRole('button', { name: 'Delete selected' }).locator('xpath=..') +/** FileImportModal, recognised by its hidden file input. */ const importFileDialog = (page: Page) => - portalDialog(page, page.locator('input[type="file"][accept*=".gpx"]')) -/** ListImportModal: its own portal, recognised by the link box. */ -const listImportDialog = (page: Page) => portalDialog(page, page.getByPlaceholder(/goo\.gl|naver/)) + page.getByRole('dialog').filter({ has: page.locator('input[type="file"][accept*=".gpx"]') }) +/** ListImportModal, recognised by the link box. */ +const listImportDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder(/goo\.gl|naver/) }) /** The drop box of the file dialog: the dashed button that opens the file picker. */ -const dropBox = (page: Page) => importFileDialog(page).getByRole('button').first() +const dropBox = (page: Page) => importFileDialog(page).locator('button.border-dashed').first() /** The column itself: the header block around the search box. */ const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[contains(@class,"scroll") or @data-places-sidebar][1]') @@ -81,6 +100,9 @@ const SCRIPTS: Record = { }, }, { + // The results are pictured before the pick: once one is taken the + // search box empties again, and a ring round an empty box shows the + // reader nothing of what the step says. prepare: async p => { const box = modal(p).getByPlaceholder('Search places...') // Osaka Castle on purpose: its entry in the index carries a picture, @@ -88,16 +110,19 @@ const SCRIPTS: Record = { // text says it shows. Most results have none of that and the column // would be an empty state in the picture. await typeInto(p, box, SEARCHED.query) - const suggestion = modal(p).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first() - await expect(suggestion).toBeVisible({ timeout: 20_000 }) - await suggestion.click() + await expect(suggestion(p)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + target: p => modal(p).locator('.shadow-dropdown').first(), + hover: p => suggestion(p).hover(), + act: async p => { + await suggestion(p).click() await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(SEARCHED.match, { timeout: 20_000 }) // The details column answers after the form; wait for it or the // picture catches the spinner. await expect(modal(p).getByText('Pick a picture', { exact: false }).first()).toBeVisible({ timeout: 30_000 }) await settle(p) }, - target: p => modal(p).getByPlaceholder('Search places...').locator('xpath=../..'), }, // The details column is an