mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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.
This commit is contained in:
@@ -58,13 +58,24 @@ const travellers = (page: Page) => page.locator('[aria-label="Travelers"]')
|
||||
/** The card's delete question is its own portal, with no backdrop class to find it by. */
|
||||
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
|
||||
|
||||
/** The Booking Type field at the head of the form is a CustomSelect; this opens it. */
|
||||
/** The Booking Type is a pill in the head of the form; its name reads the type after the label. */
|
||||
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
|
||||
/** The pill's list is its own portal, named after the pill's label. */
|
||||
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
|
||||
/** The status pill beside it says what a click turns it into. */
|
||||
const statusPill = (page: Page) => modal(page).getByRole('button', { name: /^Set to / })
|
||||
|
||||
async function openTypeMenu(page: Page): Promise<void> {
|
||||
await block(page, /^Booking Type$/).getByRole('button').first().click()
|
||||
await expect(selectMenu(page)).toBeVisible()
|
||||
await typePill(page).click()
|
||||
await expect(typeMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
|
||||
async function pickType(page: Page, type: RegExp): Promise<void> {
|
||||
await typeMenu(page).getByRole('button', { name: type }).first().click()
|
||||
await expect(typeMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/** The Travelers field opens its members as a list under it, inside the form. */
|
||||
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
|
||||
|
||||
@@ -162,17 +173,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
prepare: async p => {
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: selectMenu,
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await selectMenu(p).getByRole('button', { name: /^Event$/ }).first().click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(block(p, /^Booking Type$/).getByRole('button', { name: /Event/ })).toBeVisible()
|
||||
await pickType(p, /^Event$/)
|
||||
await expect(typePill(p)).toHaveAccessibleName(/^Booking Type:\s*Event$/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, titleBox(p), 'Kabuki at the Minamiza') },
|
||||
target: p => block(p, /^Title \*$/),
|
||||
target: titleBox,
|
||||
act: async p => {
|
||||
await expect(saveButton(p, 'Add')).toBeEnabled()
|
||||
await settle(p)
|
||||
@@ -195,7 +205,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, codeBox(p), 'MZ-5521')
|
||||
await chooseIn(p, /^Status$/, /^Confirmed$/)
|
||||
await statusPill(p).click()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
|
||||
},
|
||||
target: p => row(p, /^Booking Code$/),
|
||||
act: settle,
|
||||
@@ -222,10 +233,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: selectMenu,
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await selectMenu(p).getByRole('button', { name: /^Accommodation$/ }).first().click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await pickType(p, /^Accommodation$/)
|
||||
await expect(label(p, /^Check-in until$/)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -583,16 +593,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Status$/).getByRole('button').first().click()
|
||||
await expect(selectMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => block(p, /^Status$/),
|
||||
target: statusPill,
|
||||
act: async p => {
|
||||
await selectMenu(p).getByRole('button', { name: /^Confirmed$/ }).click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(block(p, /^Status$/).getByRole('button', { name: 'Confirmed' })).toBeVisible()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Confirmed')
|
||||
await statusPill(p).click()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
|
||||
await expect(statusPill(p)).toHaveText('Confirmed')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,8 +42,10 @@ const forecast = (page: Page) => panel(page).getByText(/^Forecast for /)
|
||||
const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[2]')
|
||||
/** The day's reservations block. */
|
||||
const bookings = (page: Page) => panel(page).getByText('Reservations', { exact: true }).locator('xpath=..')
|
||||
/** One booking row: the title sits in a wrapper, the row is its grandparent. */
|
||||
const bookingRow = (page: Page, title: string) => panel(page).getByText(title).locator('xpath=ancestor::div[2]')
|
||||
/** 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. */
|
||||
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
|
||||
/**
|
||||
* Select a day by its title rather than by the middle of its header: a day with
|
||||
@@ -305,8 +307,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
only(bookings),
|
||||
only(p => bookingRow(p, EVENING_WALK)),
|
||||
only(p => bookingRow(p, 'Nozomi 21')),
|
||||
// Only pointed at: clicking it would leave the plan for the Bookings tab.
|
||||
only(p => p.getByRole('button', { name: 'Bookings', exact: true })),
|
||||
{
|
||||
// A row opens its booking over the plan; Edit at its foot is where it changes.
|
||||
prepare: async p => {
|
||||
await bookingRow(p, 'Nozomi 21').click()
|
||||
await expect(bookingDetail(p, 'Nozomi 21')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => bookingDetail(p, 'Nozomi 21').getByRole('button', { name: 'Edit', exact: true }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureMapFixtures } from './fixtures'
|
||||
import { openTrip, openTripOnDay, selectDay, modal } from './trip-shared'
|
||||
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
|
||||
import { ensureDawarichConnection, disconnectDawarich } from './external'
|
||||
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
@@ -367,7 +367,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{ target: p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]') },
|
||||
// The whole dialog: the name is typed in its head band, outside the form.
|
||||
{ target: p => dialog(p) },
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
@@ -413,7 +414,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]'),
|
||||
target: p => dialog(p),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
|
||||
@@ -53,8 +53,10 @@ const member = (page: Page, name: string) => participants(page).getByRole('butto
|
||||
const addMember = (page: Page) => participants(page).getByRole('button', { name: '+', exact: true })
|
||||
/** "Files" while the place has none, "<n> files" once it has. */
|
||||
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ })
|
||||
/** The booking strip is a button only for a viewer who may open the booking. */
|
||||
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: 'Edit Reservation' })
|
||||
/** The booking strip opens the booking; it is a button named after it. */
|
||||
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: `Open booking: ${BOOKING}` })
|
||||
/** The booking the strip opens, named by its title. Its form is one Edit further. */
|
||||
const bookingDetail = (page: Page) => page.getByRole('dialog', { name: BOOKING })
|
||||
/** The strip's lower row: Date, Time and Booking Code beside each other. */
|
||||
const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1)
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
@@ -407,11 +409,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: bookingStrip,
|
||||
act: async p => {
|
||||
await bookingStrip(p).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await expect(bookingDetail(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await bookingDetail(p).getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByText('Link to day assignment', { exact: true }).locator('xpath=..'),
|
||||
act: closeModal,
|
||||
},
|
||||
|
||||
@@ -102,7 +102,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// The details column is an <aside> beside the form; both used to resolve to
|
||||
// the whole dialog, which gave two steps the same picture.
|
||||
only(p => modal(p).getByText('Place details', { exact: true }).locator('xpath=ancestor::aside[1]')),
|
||||
only(p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]')),
|
||||
// The name and the category sit in the head band, above the form, so the
|
||||
// step about the fields shows the dialog rather than the form alone.
|
||||
only(p => dialog(p)),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
|
||||
@@ -43,6 +43,10 @@ const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: ne
|
||||
const card = (page: Page, text: string) => page.locator('.bg-surface-card').filter({ hasText: text }).first()
|
||||
/** A transport's row in the day plan; the whole row is the button that opens it. */
|
||||
const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first()
|
||||
/** The booking a click on a row or a card opens, named by the booking's title. */
|
||||
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
/** The Edit at the foot of that booking, which leads on to its form. */
|
||||
const detailEdit = (page: Page, title: string) => bookingDetail(page, title).getByRole('button', { name: 'Edit', exact: true })
|
||||
/** A toolbar type chip or a section heading — both are a label followed by their count. */
|
||||
const counted = (page: Page, label: string) => page.getByRole('button', { name: new RegExp(`^${label} \\d+$`) })
|
||||
/** A labelled field block inside the open dialog. */
|
||||
@@ -393,15 +397,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => card(p, JOURNEY.onCard),
|
||||
act: async p => {
|
||||
await card(p, JOURNEY.onCard).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Public transit journey' })).toBeVisible()
|
||||
await expect(bookingDetail(p, JOURNEY.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('Itinerary', { exact: true }).locator('xpath=..')),
|
||||
only(p => bookingDetail(p, JOURNEY.title).getByText('Itinerary', { exact: true }).locator('xpath=..')),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Change route' }),
|
||||
target: p => bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Change route' }).click()
|
||||
await bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -476,11 +480,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => transportRow(p, FLIGHT.row),
|
||||
act: async p => {
|
||||
await transportRow(p, FLIGHT.row).click()
|
||||
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => detailEdit(p, FLIGHT.title),
|
||||
act: async p => {
|
||||
await detailEdit(p, FLIGHT.title).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit transport' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(dialog),
|
||||
only(p => block(p, 'Route')),
|
||||
{
|
||||
// Escape rather than Update: nothing about the seeded flight may change.
|
||||
|
||||
@@ -23,7 +23,7 @@ const tripMembers = [
|
||||
]
|
||||
|
||||
/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */
|
||||
const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded'))
|
||||
const bobInForm = () => screen.getAllByRole('checkbox', { name: /bob/i })
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
@@ -52,7 +52,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await screen.findByText('Dinner')
|
||||
await screen.findByText('Payment')
|
||||
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a manual payment via the Add payment button', async () => {
|
||||
@@ -70,7 +70,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '25')
|
||||
// The footer submit is the second "Add payment" control once the modal is open.
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
const submit = addButtons[addButtons.length - 1]
|
||||
@@ -242,7 +242,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Lunch')
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[0])
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(patched).toBeTruthy())
|
||||
@@ -296,7 +296,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]')
|
||||
const card = screen.getByRole('region', { name: 'Outstanding amount' })
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
@@ -317,7 +317,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
// "Your share" is the first of the two bold figures in the Total spend card's
|
||||
// footer; the second is "You paid". 60 from the paid expense only — the 40
|
||||
// nobody has paid for is not a share, though it still counts as spend.
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')!
|
||||
const card = screen.getByRole('region', { name: 'Total trip spend' })!
|
||||
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
|
||||
expect(figures[0]).toContain('60')
|
||||
expect(figures[0]).not.toContain('100')
|
||||
@@ -344,10 +344,9 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
// Deselect everyone so the cost carries no split, and mark it as unpaid: a picked
|
||||
// payer now always goes out (#1766), so "nobody paid" must be said explicitly.
|
||||
await user.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
await user.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
await user.click(screen.getByRole('button', { name: 'You' })) // open the Who-paid select
|
||||
pickOption('No one paid yet')
|
||||
await user.click(screen.getByRole('checkbox', { name: 'You' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'bob' }))
|
||||
await user.click(screen.getByRole('radio', { name: 'No one paid yet' }))
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
const submit = addBtns[addBtns.length - 1] // footer submit
|
||||
@@ -381,8 +380,8 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
// A personal expense: alice (the default "You" payer) fronted it, but nobody shares
|
||||
// the split. The web used to drop the payer once no participants remained.
|
||||
await user.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
await user.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'You' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'bob' }))
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
const submit = addBtns[addBtns.length - 1] // footer submit
|
||||
@@ -417,10 +416,10 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Hotel')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
// Nobody paid this expense — reopening it must not silently reselect "You".
|
||||
expect(await screen.findByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('radio', { name: 'No one paid yet' })).toBeChecked()
|
||||
|
||||
// …and saving an untouched edit must not assign the current user as payer.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
@@ -439,7 +438,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('radio', { name: 'You' })).toBeChecked()
|
||||
})
|
||||
|
||||
// ── Multi-payer (#1426 regression) ─────────────────────────────────────────
|
||||
@@ -543,7 +542,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
// Loading used to be payers.find(...), which silently dropped the second payer.
|
||||
// Seeded with two decimals since #2175, localized for EUR.
|
||||
@@ -572,7 +571,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Dinner; tapas')
|
||||
await user.click(screen.getByTitle('Export CSV'))
|
||||
await user.click(screen.getByRole('button', { name: 'Export CSV' }))
|
||||
|
||||
expect(exported).toBeTruthy()
|
||||
const text = await exported!.text()
|
||||
@@ -624,8 +623,8 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
|
||||
|
||||
expect(screen.getByText('Individual shares')).toBeInTheDocument()
|
||||
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/75,00/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/25,00/)).toBeInTheDocument()
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1])
|
||||
@@ -655,7 +654,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Sushi')
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
|
||||
const card = screen.getByRole('region', { name: 'Total trip spend' })
|
||||
// Yen, unconverted and with JPY's zero decimals — not a euro/dollar default.
|
||||
expect(card).toHaveTextContent('¥3,000')
|
||||
})
|
||||
@@ -680,7 +679,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '25')
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(addButtons[addButtons.length - 1])
|
||||
|
||||
@@ -703,7 +702,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '25')
|
||||
// Bob paid me back in dollars — the server freezes the USD rate on write.
|
||||
await user.click(screen.getByText(/^EUR/))
|
||||
await user.click(await screen.findByText(/^USD/))
|
||||
@@ -732,7 +731,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Payment')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
// The stored USD amount comes back as-is (padded to two decimals, #2175),
|
||||
// not silently reread as euros.
|
||||
@@ -831,7 +830,7 @@ describe('CostsPanel — overview', () => {
|
||||
mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] })
|
||||
|
||||
// Both travellers appear; neither has a signed amount.
|
||||
const balances = (await screen.findByText('Balances')).parentElement as HTMLElement
|
||||
const balances = (await screen.findByRole('region', { name: 'Balances' })) as HTMLElement
|
||||
expect(within(balances).getAllByText('0,00 €')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -868,7 +867,7 @@ describe('CostsPanel — overview', () => {
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
const card = (await screen.findByRole('region', { name: 'Final budget' })) as HTMLElement
|
||||
await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2))
|
||||
// The main view stays one figure per person until someone asks for more.
|
||||
expect(within(card).queryByText('Expenses paid')).toBeNull()
|
||||
@@ -880,7 +879,7 @@ describe('CostsPanel — overview', () => {
|
||||
// 100 USD is never converted again here. What was entered labels the row (#2525).
|
||||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||||
expect(within(card).getByText('· $100.00')).toBeInTheDocument()
|
||||
expect(within(card).getByText('$100.00')).toBeInTheDocument()
|
||||
// Received and still pending both lower her cost, each line with its one row.
|
||||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||||
@@ -902,7 +901,7 @@ describe('CostsPanel — overview', () => {
|
||||
mount([dinner(), taxi()])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
const breakdown = screen.getByText('By category').parentElement as HTMLElement
|
||||
const breakdown = screen.getByRole('region', { name: 'By category' }) as HTMLElement
|
||||
const labels = within(breakdown).getAllByText(/Food & drink|Transport/).map(el => el.textContent)
|
||||
// Bars are ordered by spend, so the 90 € food row comes before the 30 € taxi.
|
||||
expect(labels).toEqual(['Food & drink', 'Transport'])
|
||||
@@ -924,7 +923,7 @@ describe('CostsPanel — overview', () => {
|
||||
mount([expense({ id: 110, name: 'Mystery', category: 'other', total_price: 90 })])
|
||||
|
||||
await screen.findByText('Mystery')
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
|
||||
const card = screen.getByRole('region', { name: 'Total trip spend' })
|
||||
expect(card).toHaveTextContent('90.00 XX')
|
||||
})
|
||||
})
|
||||
@@ -1001,10 +1000,10 @@ describe('CostsPanel — settle up', () => {
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
fireEvent.click(await screen.findByTitle('Undo'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Undo' }))
|
||||
await waitFor(() => expect(deleted).toBe(1))
|
||||
|
||||
fireEvent.click(screen.getByTitle('Undo'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
delete window.__addToast
|
||||
})
|
||||
@@ -1057,7 +1056,7 @@ describe('CostsPanel — settle up', () => {
|
||||
mount([], { flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 20 }] })
|
||||
|
||||
await screen.findByText('You owe')
|
||||
const card = screen.getByText('You owe').closest('div[style*="border-radius: 22"]') as HTMLElement
|
||||
const card = screen.getByRole('region', { name: 'You owe' }) as HTMLElement
|
||||
expect(within(card).getByText('To')).toBeInTheDocument()
|
||||
expect(within(card).getByText('bob')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nothing owed to you')).toBeInTheDocument()
|
||||
@@ -1073,6 +1072,7 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paid by me' }))
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
@@ -1085,6 +1085,7 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: "I'm owed" }))
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
@@ -1096,8 +1097,8 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All categories/ }))
|
||||
pickOption('Transport')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Transport' }))
|
||||
|
||||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
|
||||
@@ -1108,8 +1109,8 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
|
||||
pickOption('Sun, Jun 15')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||||
|
||||
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 expenses')).toBeInTheDocument()
|
||||
@@ -1126,8 +1127,8 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
|
||||
pickOption('Sun, Jun 15')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
@@ -1214,7 +1215,7 @@ describe('CostsPanel — expense rows', () => {
|
||||
mount([dinner()])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
fireEvent.click(screen.getByTitle('Delete'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
// The optimistic removal is rolled back by the store.
|
||||
@@ -1237,8 +1238,8 @@ describe('CostsPanel — expense rows', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Settle up' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add payment' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Settle' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Undo')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1274,7 +1275,7 @@ describe('CostsPanel — mobile layout', () => {
|
||||
expect(screen.getByText("You're owed")).toBeInTheDocument()
|
||||
expect(screen.getByText('Outstanding amount')).toBeInTheDocument()
|
||||
// The unfinished expense is flagged with a badge on its icon instead of a pill.
|
||||
expect(screen.getByTitle('Total only — not settled yet')).toHaveTextContent('!')
|
||||
expect(screen.getByRole('img', { name: 'Total only — not settled yet' })).toHaveTextContent('!')
|
||||
expect(screen.queryByText('Unfinished')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1315,8 +1316,8 @@ describe('CostsPanel — payment modal', () => {
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
const amount = await screen.findByPlaceholderText('0.00')
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
const amount = await screen.findByPlaceholderText('0,00')
|
||||
await user.clear(amount)
|
||||
await user.type(amount, '12,50')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
@@ -1330,7 +1331,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '10')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '10')
|
||||
|
||||
// Default is You → bob; pointing "to" back at me makes it a no-op transfer.
|
||||
await user.click(screen.getByRole('button', { name: 'bob' }))
|
||||
@@ -1348,7 +1349,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '10')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '10')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
@@ -1370,7 +1371,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
|
||||
try {
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '10')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '10')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
@@ -1390,7 +1391,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
@@ -1403,7 +1404,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
const user = userEvent.setup()
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
expect(save).toBeEnabled()
|
||||
|
||||
@@ -1439,7 +1440,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
mount([expense({ id: 150, name: 'Market run', category: 'groceries', total_price: 30, note, payers: [{ user_id: 1, amount: 30 }], members: [{ user_id: 1, username: 'alice', amount: 15 }, { user_id: 2, username: 'bob', amount: 15 }] })])
|
||||
|
||||
await screen.findByText('Market run')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
expect(await screen.findByDisplayValue('Apples')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Cake')).toBeInTheDocument()
|
||||
@@ -1461,7 +1462,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
mount([expense({ id: 151, name: 'Market run', category: 'groceries', total_price: 30, note: 'TICKETJSON:{oops', payers: [{ user_id: 1, amount: 30 }], members: [{ user_id: 1, username: 'alice' }] })])
|
||||
|
||||
await screen.findByText('Market run')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Add item/i })).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
|
||||
@@ -1482,7 +1483,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
})])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
// Custom shares reopen padded to two decimals (#2175), localized for EUR.
|
||||
expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT')
|
||||
@@ -1496,7 +1497,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
|
||||
// Bringing him back in via the excluded-state hint restores an empty field,
|
||||
// pre-filled as a placeholder with what is still missing from the total.
|
||||
await user.click(screen.getByRole('button', { name: 'Tap to include' }))
|
||||
await user.click(bobInForm()[0])
|
||||
await user.type(screen.getByPlaceholderText('30,00'), '30')
|
||||
|
||||
expect(screen.getByText('Split matches total')).toBeInTheDocument()
|
||||
@@ -1526,6 +1527,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
expect(screen.getByText('50,00 €')).toBeInTheDocument()
|
||||
|
||||
// A different category than the default is carried into the payload.
|
||||
await user.click(screen.getByRole('button', { name: /^Category:/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Sightseeing' }))
|
||||
const submits = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
@@ -1550,7 +1552,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
// Dropping Bob again leaves Alice absorbing the whole amount.
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
expect(screen.getByRole('button', { name: 'Tap to include' })).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('payer-toggle')[1]).toHaveAttribute('aria-checked', 'false')
|
||||
await user.click(screen.getByRole('button', { name: 'One person paid' }))
|
||||
|
||||
const submits = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
@@ -1572,8 +1574,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
|
||||
await user.type(screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
||||
await user.type(screen.getAllByPlaceholderText('0,00')[0], '120')
|
||||
await user.click(screen.getByRole('button', { name: 'You' }))
|
||||
pickOption('No one paid yet')
|
||||
await user.click(screen.getByRole('radio', { name: 'No one paid yet' }))
|
||||
|
||||
const submits = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
@@ -1723,7 +1724,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Tip')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
|
||||
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note')
|
||||
@@ -1749,7 +1750,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('-5 refund')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
|
||||
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[1]).toBe('06/15/2025;"\'=HYPERLINK(""http://evil"",""click"")";Food & drink;12.00;EUR;12.00;\'@SUM(A1:A9)')
|
||||
@@ -1763,7 +1764,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
mount([dinner()])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
fireEvent.click(screen.getByTitle('Delete'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true))
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
|
||||
@@ -1790,7 +1791,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
})])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]') as HTMLElement
|
||||
const card = screen.getByRole('region', { name: 'Total trip spend' }) as HTMLElement
|
||||
// Not the 50/50 an equal split would have produced.
|
||||
expect(within(card).getByText('80 €')).toBeInTheDocument()
|
||||
expect(within(card).getByText('0 €')).toBeInTheDocument()
|
||||
@@ -1804,7 +1805,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
})])
|
||||
|
||||
await screen.findByText('Bob solo')
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]') as HTMLElement
|
||||
const card = screen.getByRole('region', { name: 'Total trip spend' }) as HTMLElement
|
||||
expect(within(card).getAllByText('0 €')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -1814,7 +1815,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
mount([expense({ id: 220, name: 'Dinner', category: 'food', total_price: 90, currency: null })])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
|
||||
const card = screen.getByRole('region', { name: 'Total trip spend' })
|
||||
expect(card).toHaveTextContent('90,00 €')
|
||||
})
|
||||
|
||||
@@ -1834,7 +1835,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={[...tripMembers, { id: 3, username: 'cara', avatar_url: null }]} />)
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
// No symbol is known, so the code itself prefixes the amount and labels the option.
|
||||
await screen.findByPlaceholderText('0.00')
|
||||
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
|
||||
@@ -1852,7 +1853,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
mount([expense({ id: 240, name: 'Mining rig', category: 'other', total_price: 2, currency: 'XBT', payers: [{ user_id: 1, amount: 2 }], members: [{ user_id: 1, username: 'alice' }] })])
|
||||
|
||||
await screen.findByText('Mining rig')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
// XBT is unknown to the locale map → dot separator, two decimals (#2175).
|
||||
expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument()
|
||||
@@ -1868,7 +1869,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={[{ id: 1, username: 'alice', avatar_url: null }]} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '10')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '10')
|
||||
|
||||
// From and To both resolve to me, so the transfer stays unsavable.
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
@@ -1897,7 +1898,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
// Excluding Bob leaves the include hint; tapping it puts him back on the bill.
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
await user.click(screen.getByRole('button', { name: 'Tap to include' }))
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
expect(screen.getAllByTestId('payer-amount')).toHaveLength(2)
|
||||
|
||||
// Three decimals are not a valid money amount, so the field ignores them.
|
||||
@@ -1922,13 +1923,13 @@ describe('CostsPanel — remaining paths', () => {
|
||||
expect(price.value).toBe('10,99')
|
||||
|
||||
// Toggling a participant off and on again leaves the shares unchanged.
|
||||
const bob = screen.getAllByRole('button', { name: /bob/i })[0]
|
||||
const bob = screen.getAllByRole('checkbox', { name: /bob/i })[0]
|
||||
await user.click(bob)
|
||||
await user.click(bob)
|
||||
// 10.99 across two people leaves the odd cent with the lower user id.
|
||||
expect(screen.getByText('Individual shares')).toBeInTheDocument()
|
||||
expect(screen.getByText('€5.50')).toBeInTheDocument()
|
||||
expect(screen.getByText('€5.49')).toBeInTheDocument()
|
||||
expect(screen.getByText('5,50 €')).toBeInTheDocument()
|
||||
expect(screen.getByText('5,49 €')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1951,7 +1952,7 @@ describe('CostsPanel — split modes and guests', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Custom' }))
|
||||
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Equally' }))
|
||||
expect(screen.getByText('Split 2 ways · €45.00 each')).toBeInTheDocument()
|
||||
expect(screen.getByText('45,00 € per person')).toBeInTheDocument()
|
||||
|
||||
const submits = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
@@ -1972,7 +1973,7 @@ describe('CostsPanel — split modes and guests', () => {
|
||||
})])
|
||||
|
||||
await screen.findByText('Hotel')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
// Nobody paid, so switching to multi-payer seeds me as the only payer.
|
||||
await user.click(await screen.findByRole('button', { name: 'Multiple people paid' }))
|
||||
@@ -2049,7 +2050,7 @@ describe('CostsPanel — mobile extras', () => {
|
||||
await waitFor(() => expect(document.querySelector('.costs-summary')).toBeNull())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add payment' }))
|
||||
|
||||
expect(await screen.findByPlaceholderText('0.00')).toBeInTheDocument()
|
||||
expect(await screen.findByPlaceholderText('0,00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-062: the mobile search box keeps the caret across keystrokes', async () => {
|
||||
@@ -2093,11 +2094,11 @@ describe('CostsPanel — decimal padding on edit (#2175)', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Coffee')
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[0])
|
||||
expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await user.click(screen.getAllByTitle('Edit')[1])
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[1])
|
||||
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2177,10 +2178,10 @@ describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
// Not "Unfinished": the refund has its recipient recorded.
|
||||
expect(within(row).queryByText('Unfinished')).toBeNull()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
// The negative payer reopens selected instead of resetting to "Nobody",
|
||||
// and the total is seeded signed and padded.
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('radio', { name: 'You' })).toBeChecked()
|
||||
expect(screen.getByDisplayValue('-100,00')).toBeInTheDocument()
|
||||
|
||||
// Saving untouched must not silently drop the negative payer.
|
||||
@@ -2205,7 +2206,7 @@ describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
const breakdown = screen.getByText('By category').parentElement as HTMLElement
|
||||
const breakdown = screen.getByRole('region', { name: 'By category' }) as HTMLElement
|
||||
// Food nets 90 − 30 = 60; the fully refunded tour keeps a row of its own.
|
||||
expect(within(breakdown).getByText('60 €')).toBeInTheDocument()
|
||||
expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument()
|
||||
@@ -2221,10 +2222,10 @@ describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
})} />)
|
||||
|
||||
// -70 of -100: 30 still to hand out, not 30 too much.
|
||||
expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Sum of splits: -70,00 € of -100,00 € (under by 30,00 €)')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('-30,00'), '-40')
|
||||
expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sum of splits: -110,00 € of -100,00 € (over by 10,00 €)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => {
|
||||
@@ -2306,19 +2307,20 @@ describe('CostsPanel — expense modal in another language', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Individuell' }))
|
||||
|
||||
// Nothing entered yet, so the whole 90 is still unaccounted for.
|
||||
expect(screen.getByText('Summe der Anteile: €0.00 von €90.00 (es fehlen €90.00)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Summe der Anteile: 0,00 € von 90,00 € (es fehlen 90,00 €)')).toBeInTheDocument()
|
||||
|
||||
const shares = screen.getAllByPlaceholderText('45,00')
|
||||
await user.type(shares[0], '90')
|
||||
expect(screen.getByText('Aufteilung passt zur Summe')).toBeInTheDocument()
|
||||
|
||||
await user.type(shares[1], '10')
|
||||
expect(screen.getByText('Summe der Anteile: €100.00 von €90.00 (€10.00 zu viel)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Summe der Anteile: 100,00 € von 90,00 € (10,00 € zu viel)')).toBeInTheDocument()
|
||||
|
||||
// Back to the equal split: a member taken out of it is marked, not dropped.
|
||||
await user.click(screen.getByRole('button', { name: 'Gleichmäßig' }))
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
expect(screen.getByText('Nicht dabei')).toBeInTheDocument()
|
||||
// Taken out, the box is simply unticked; no English word leaks into the German dialog.
|
||||
await user.click(screen.getByRole('checkbox', { name: /bob/i }))
|
||||
expect(screen.getByRole('checkbox', { name: /bob/i })).not.toBeChecked()
|
||||
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -2390,7 +2392,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
try {
|
||||
mount([hotel()])
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
@@ -2471,7 +2473,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
@@ -2480,7 +2482,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
@@ -2499,11 +2501,11 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
const card = (await screen.findByRole('region', { name: 'Final budget' })) as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$791.55')).toHaveLength(2)
|
||||
expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument()
|
||||
expect(within(card).getByText('· $801.76')).toBeInTheDocument()
|
||||
expect(within(card).getByText('$801.76')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => {
|
||||
@@ -2522,10 +2524,10 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
const card = (await screen.findByRole('region', { name: 'Final budget' })) as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$801.76')).toHaveLength(2)
|
||||
expect(within(card).queryByText('· $801.76')).toBeNull()
|
||||
expect(within(card).queryByText('$801.76')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => {
|
||||
@@ -2541,7 +2543,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
// The list reads the missing currency as the trip's own.
|
||||
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
|
||||
// Seeding the display currency here would label 100 euro as 100 dollars, and a
|
||||
// plain save would then store it that way.
|
||||
@@ -2675,7 +2677,7 @@ describe('CostsPanel: a bill the server cannot convert', () => {
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '25')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,30 @@
|
||||
import { Check, Plus, ScanLine } from 'lucide-react'
|
||||
import { useCallback, useRef, useState, type ReactNode } from 'react'
|
||||
import { Check, Download, Filter, Plus, RotateCcw, ScanLine, Search, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER, useDismissOnOutside } from '../Packing/packingPopoverStyles'
|
||||
import { PopoverItem } from '../Packing/PackingPopover'
|
||||
|
||||
export type OwnerFilter = 'all' | 'mine' | 'owed'
|
||||
|
||||
export interface CostsFilterProps {
|
||||
query: string
|
||||
onQuery: (query: string) => void
|
||||
owner: OwnerFilter
|
||||
onOwner: (owner: OwnerFilter) => void
|
||||
/** The picked category, '' for all; the options start with that "all" entry. */
|
||||
category: string
|
||||
categoryOptions: { value: string; label: string; icon?: ReactNode }[]
|
||||
onCategory: (category: string) => void
|
||||
/** The picked day as YYYY-MM-DD, '' for all; the options start with that "all" entry. */
|
||||
day: string
|
||||
dayOptions: { value: string; label: string }[]
|
||||
onDay: (day: string) => void
|
||||
onResetFilters: () => void
|
||||
onExport: () => void
|
||||
canExport: boolean
|
||||
}
|
||||
|
||||
interface CostsToolbarProps {
|
||||
/** The trip's span as a formatted range and its length in days; null for a trip without dates. */
|
||||
@@ -15,24 +39,29 @@ interface CostsToolbarProps {
|
||||
onAddExpense: () => void
|
||||
/** Opens the receipt scan; left out when the AI model reads no images. */
|
||||
onScanReceipt?: () => void
|
||||
/** Search, filters and export, the way the Transports and Bookings bars carry them. */
|
||||
filters?: CostsFilterProps
|
||||
}
|
||||
|
||||
const BODY_SIZE = 'calc(13px * var(--fs-scale-body, 1))'
|
||||
/** A 36 px control on the bar, in the card colour so it reads on the bar's tint. */
|
||||
const BAR_BTN = 'relative grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-card text-content-secondary hover:text-content disabled:cursor-default disabled:opacity-40'
|
||||
|
||||
/**
|
||||
* The bar on top of the Costs tab, in the same shape as the one Transports,
|
||||
* Bookings, Lists and Files open with: the tab's name, then what the numbers
|
||||
* cover (the trip's days and who travels), and the two actions on the right.
|
||||
* Bookings, Lists and Files open with: the tab's name and what the numbers
|
||||
* cover, then how to narrow the ledger and the actions on the right.
|
||||
*/
|
||||
export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit, canSettle, onSettleAll, onAddExpense, onScanReceipt }: CostsToolbarProps) {
|
||||
export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit, canSettle, onSettleAll, onAddExpense, onScanReceipt, filters }: CostsToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const chip = 'inline-flex items-center whitespace-nowrap rounded-full bg-surface-card px-3 py-1.5 font-medium text-content-muted shadow-sm'
|
||||
const button = 'inline-flex items-center gap-1.5 rounded-[10px] border-0 px-[14px] py-[9px] font-medium hover:opacity-[0.88]'
|
||||
const button = 'inline-flex h-9 items-center gap-1.5 rounded-[10px] border-0 px-3.5 font-medium hover:opacity-[0.88]'
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 rounded-[18px] bg-surface-tertiary py-3.5 pl-[22px] pr-4">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 pl-[22px] pr-3">
|
||||
<h2 className="m-0 shrink-0 text-subtitle font-semibold tracking-[-0.01em] text-content">{t('trip.tabs.budget')}</h2>
|
||||
<div className="h-[22px] w-px shrink-0 bg-edge-faint" />
|
||||
<div className="mx-1.5 h-[22px] w-px shrink-0 bg-edge-faint" />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5" style={{ fontSize: BODY_SIZE }}>
|
||||
{dateMeta && (
|
||||
<span className={`${chip} gap-3`}>
|
||||
@@ -59,23 +88,117 @@ export default function CostsToolbar({ dateMeta, people, me, colorFor, canEdit,
|
||||
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="ml-auto flex shrink-0 flex-wrap gap-1.5" style={{ fontSize: BODY_SIZE }}>
|
||||
<button type="button" onClick={onSettleAll} disabled={!canSettle}
|
||||
className={`${button} bg-surface-card text-content disabled:cursor-default disabled:opacity-40`}>
|
||||
<Check size={14} strokeWidth={2.5} />
|
||||
{t('costs.settleUp')}
|
||||
</button>
|
||||
{onScanReceipt && (
|
||||
<button type="button" onClick={onScanReceipt} className={`${button} bg-surface-card text-content`}>
|
||||
<ScanLine size={14} strokeWidth={2.5} />
|
||||
{t('costs.scan.button')}
|
||||
|
||||
{filters && (
|
||||
<>
|
||||
<label className="flex h-9 w-[200px] items-center gap-2 rounded-[10px] bg-surface-card px-3 text-content-faint focus-within:ring-2 focus-within:ring-[color:var(--text-primary)]">
|
||||
<Search size={14} strokeWidth={2} className="flex-none" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={filters.query}
|
||||
onChange={e => filters.onQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { filters.onQuery(''); searchRef.current?.blur() } }}
|
||||
placeholder={t('costs.searchPlaceholder')}
|
||||
aria-label={t('common.search')}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint dark:bg-transparent"
|
||||
style={{ fontSize: BODY_SIZE }}
|
||||
/>
|
||||
{filters.query && (
|
||||
<Tooltip label={t('common.clear')}>
|
||||
<button type="button" onClick={() => filters.onQuery('')} aria-label={t('common.clear')} className="flex-none text-content-faint hover:text-content"><X size={13} /></button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</label>
|
||||
<FilterMenu {...filters} />
|
||||
<Tooltip label={t('budget.exportCsv')}>
|
||||
<button type="button" onClick={filters.onExport} disabled={!filters.canExport} aria-label={t('budget.exportCsv')} className={BAR_BTN}>
|
||||
<Download size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
{filters && <span className="mx-0.5 h-[22px] w-px flex-none bg-edge-faint" />}
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5" style={{ fontSize: BODY_SIZE }}>
|
||||
<button type="button" onClick={onSettleAll} disabled={!canSettle}
|
||||
className={`${button} bg-surface-card text-content disabled:cursor-default disabled:opacity-40`}>
|
||||
<Check size={14} strokeWidth={2.5} />
|
||||
{t('costs.settleUp')}
|
||||
</button>
|
||||
{onScanReceipt && (
|
||||
<button type="button" onClick={onScanReceipt} className={`${button} bg-surface-card text-content`}>
|
||||
<ScanLine size={14} strokeWidth={2.5} />
|
||||
{t('costs.scan.button')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onAddExpense} className={`${button} bg-accent text-accent-text`}>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
{t('costs.addExpense')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Whose expenses, which category and which day, in one place; the button counts what is switched on. */
|
||||
function FilterMenu(f: CostsFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
useDismissOnOutside(ref, open, close)
|
||||
const active = (f.owner !== 'all' ? 1 : 0) + (f.category ? 1 : 0) + (f.day ? 1 : 0)
|
||||
const owners: [OwnerFilter, string][] = [['all', t('costs.filter.all')], ['mine', t('costs.filter.mine')], ['owed', t('costs.filter.owed')]]
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-none">
|
||||
<Tooltip label={t('reservations.filter')}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-label={t('reservations.filter')} className={`${BAR_BTN} ${active ? 'text-content' : ''}`}>
|
||||
<Filter size={15} strokeWidth={2} />
|
||||
{active > 0 && <span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-bold text-accent-text" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}>{active}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
<div role="menu" onKeyDown={e => { if (e.key === 'Escape') close() }} className="absolute right-0 top-11 z-30" style={{ ...POPOVER, width: 310 }}>
|
||||
<div className="mx-1.5 mb-1 mt-1.5 flex rounded-[10px] bg-surface-tertiary p-[3px]">
|
||||
{owners.map(([o, label]) => (
|
||||
<button key={o} type="button" onClick={() => f.onOwner(o)} aria-pressed={f.owner === o}
|
||||
className={`flex-1 rounded-[8px] py-1 font-medium ${f.owner === o ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={POPOVER_DIVIDER} />
|
||||
<div style={POPOVER_CAPTION}>{t('costs.category')}</div>
|
||||
<div className="max-h-[220px] overflow-y-auto">
|
||||
{f.categoryOptions.map(o => (
|
||||
<PopoverItem key={o.value || 'all'} active={f.category === o.value} onClick={() => f.onCategory(o.value)}
|
||||
icon={o.icon ?? null} label={o.label}
|
||||
trailing={f.category === o.value ? <Check size={13} className="text-content-muted" /> : undefined} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={POPOVER_DIVIDER} />
|
||||
<div style={POPOVER_CAPTION}>{t('costs.day')}</div>
|
||||
<div className="max-h-[220px] overflow-y-auto">
|
||||
{f.dayOptions.map(o => (
|
||||
<PopoverItem key={o.value || 'all'} active={f.day === o.value} onClick={() => f.onDay(o.value)} icon={null} label={o.label}
|
||||
trailing={f.day === o.value ? <Check size={13} className="text-content-muted" /> : undefined} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active > 0 && (
|
||||
<>
|
||||
<div style={POPOVER_DIVIDER} />
|
||||
<PopoverItem icon={<RotateCcw size={14} />} label={t('reservations.resetFilters')} onClick={() => { f.onResetFilters(); close() }} />
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onAddExpense} className={`${button} bg-accent text-accent-text`}>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
{t('costs.addExpense')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,10 @@ describe('Scan receipt', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
await openDialog()
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement!)
|
||||
// Only a press that starts and ends on the backdrop closes it.
|
||||
const backdrop = screen.getByRole('dialog').parentElement!
|
||||
fireEvent.mouseDown(backdrop)
|
||||
fireEvent.click(backdrop)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(dialog).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { ScanLine, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { RECEIPT_PHOTO_ACCEPT, type ReceiptScan } from './useReceiptScan'
|
||||
|
||||
/**
|
||||
@@ -13,6 +15,8 @@ export function ReceiptScanModal({ scan }: { scan: ReceiptScan }) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const isPhone = useIsPhone()
|
||||
const labelId = useId()
|
||||
if (!scan.isOpen) return null
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
@@ -21,6 +25,60 @@ export function ReceiptScanModal({ scan }: { scan: ReceiptScan }) {
|
||||
scan.choose(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
|
||||
const picker = (
|
||||
<input ref={inputRef} type="file" accept={RECEIPT_PHOTO_ACCEPT} hidden data-testid="receipt-scan-input"
|
||||
onChange={(e) => { scan.choose(e.target.files ? Array.from(e.target.files) : []); e.target.value = '' }} />
|
||||
)
|
||||
const dropHandlers = {
|
||||
onDragOver: (e: React.DragEvent) => { e.preventDefault(); setDragOver(true) },
|
||||
onDragEnter: (e: React.DragEvent) => { e.preventDefault(); setDragOver(true) },
|
||||
onDragLeave: (e: React.DragEvent) => { if (e.target === e.currentTarget) setDragOver(false) },
|
||||
onDrop,
|
||||
}
|
||||
|
||||
// The desktop opens it in the planner's dialog, like the costs editors; the phone keeps its own.
|
||||
if (!isPhone) return (
|
||||
<DialogShell
|
||||
onClose={scan.close}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><ScanLine size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={scan.close}
|
||||
title={t('costs.scan.title')}
|
||||
sub={t('costs.scan.accepted')}
|
||||
subWraps
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={scan.close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={scan.start} disabled={!scan.photo || scan.starting}>
|
||||
{scan.starting ? t('costs.scan.reading') : t('costs.scan.start')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
{picker}
|
||||
<button type="button" onClick={() => inputRef.current?.click()} {...dropHandlers}
|
||||
className={`flex min-h-[132px] w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-4 py-5 text-center transition-colors ${dragOver ? 'border-content-muted bg-surface-tertiary' : 'border-edge bg-surface-secondary hover:border-content-faint'}`}>
|
||||
<span className="pointer-events-none grid h-10 w-10 place-items-center rounded-full bg-surface-card shadow-sm">
|
||||
<ScanLine size={18} strokeWidth={1.9} className={dragOver ? 'text-content' : 'text-content-muted'} />
|
||||
</span>
|
||||
<span className={`pointer-events-none break-all font-semibold ${scan.photo ? 'text-content' : 'text-content-muted'}`} style={fs(13, 'body')}>
|
||||
{dragOver ? t('costs.scan.dropActive') : scan.photo ? scan.photo.name : t('costs.scan.dropHere')}
|
||||
</span>
|
||||
</button>
|
||||
{scan.error && (
|
||||
<div role="alert" className="rounded-[12px] bg-danger-soft px-3 py-2.5 text-danger" style={fs(12, 'body')}>{scan.error}</div>
|
||||
)}
|
||||
</DialogShell>
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
<div role="presentation" className="fixed inset-0 z-[99999] flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) scan.close() }}>
|
||||
<div role="dialog" aria-modal="true" aria-label={t('costs.scan.title')} className="flex max-h-[90vh] w-full max-w-[540px] flex-col rounded-2xl bg-surface-card p-6 shadow-2xl">
|
||||
|
||||
@@ -66,10 +66,10 @@ export function splitShareLabel(
|
||||
fx: ExpenseFx | null,
|
||||
participants: number,
|
||||
base: string,
|
||||
sym: (currency: string) => string,
|
||||
sym: ((currency: string) => string) | null,
|
||||
locale: string,
|
||||
): string {
|
||||
const entered = sym(currency) + each.toFixed(2)
|
||||
const entered = sym ? sym(currency) + each.toFixed(2) : formatMoney(each, currency, locale)
|
||||
const whole = fx ? (fx.shown ?? fx.inTrip) : null
|
||||
if (whole == null || participants <= 0) return entered
|
||||
return `${entered} → ${formatMoney(whole / participants, base, locale)}`
|
||||
|
||||
@@ -411,7 +411,7 @@ describe('CollabChat', () => {
|
||||
await screen.findByText('Reply to me');
|
||||
// Hover action buttons are always in DOM but hidden via pointer-events: none
|
||||
// Use fireEvent to bypass CSS pointer-events restrictions
|
||||
const replyBtn = screen.getByTitle('Reply');
|
||||
const replyBtn = screen.getByRole('button', { name: 'Reply' });
|
||||
fireEvent.click(replyBtn);
|
||||
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
|
||||
await waitFor(() => {
|
||||
@@ -436,7 +436,7 @@ describe('CollabChat', () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Cancel reply test');
|
||||
// Click reply button to show preview (bypassing pointer-events: none)
|
||||
fireEvent.click(screen.getByTitle('Reply'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reply' }));
|
||||
// Wait for reply preview <strong> to appear
|
||||
await waitFor(() => {
|
||||
const aliceEls = screen.queryAllByText('alice');
|
||||
@@ -621,7 +621,7 @@ describe('CollabChat', () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Delete me');
|
||||
// Delete button is in a hover-actions div with pointer-events: none — use fireEvent
|
||||
const deleteBtn = screen.getByTitle('Delete');
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(deleteBtn);
|
||||
// handleDelete uses a 400ms setTimeout before calling the API
|
||||
await waitFor(
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
|
||||
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
|
||||
borderLeft: '3px solid var(--accent)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
@@ -87,8 +87,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
{canEdit && (
|
||||
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: text.trim() || imageFiles.length ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: text.trim() || imageFiles.length ? 'var(--accent-text)' : 'var(--text-faint)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
|
||||
@@ -43,8 +43,8 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
border: own ? '1px solid color-mix(in srgb, var(--accent-text) 15%, transparent)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'color-mix(in srgb, var(--accent-text) 10%, transparent)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
@@ -56,17 +56,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: own ? 'color-mix(in srgb, var(--accent-text) 55%, transparent)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: own ? 'var(--accent-text)' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: own ? 'color-mix(in srgb, var(--accent-text) 75%, transparent)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('ChatMessages', () => {
|
||||
[buildMsg({ id: 1, text: 'one' }), buildMsg({ id: 2, text: 'two' })],
|
||||
{ hoveredId: 1 },
|
||||
)
|
||||
const actions = screen.getAllByTitle('collab.chat.reply').map(b => b.parentElement!)
|
||||
const actions = screen.getAllByRole('button', { name: 'collab.chat.reply' }).map(b => b.parentElement!)
|
||||
expect(actions[0].getAttribute('style')).toContain('opacity: 1')
|
||||
expect(actions[1].getAttribute('style')).toContain('opacity: 0')
|
||||
})
|
||||
@@ -243,7 +243,7 @@ describe('ChatMessages', () => {
|
||||
const { setReplyTo, handleDelete } = renderMessages([
|
||||
buildMsg({ id: 3, user_id: 1, username: 'me', text: 'mine' }),
|
||||
])
|
||||
const replyBtn = screen.getByTitle('collab.chat.reply')
|
||||
const replyBtn = screen.getByRole('button', { name: 'collab.chat.reply' })
|
||||
fireEvent.mouseEnter(replyBtn)
|
||||
expect(replyBtn.style.transform).toBe('scale(1.2)')
|
||||
fireEvent.mouseLeave(replyBtn)
|
||||
@@ -251,7 +251,7 @@ describe('ChatMessages', () => {
|
||||
fireEvent.click(replyBtn)
|
||||
expect(setReplyTo).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }))
|
||||
|
||||
const deleteBtn = screen.getByTitle('common.delete')
|
||||
const deleteBtn = screen.getByRole('button', { name: 'common.delete' })
|
||||
fireEvent.mouseEnter(deleteBtn)
|
||||
expect(deleteBtn.style.background).toBe('rgb(239, 68, 68)')
|
||||
fireEvent.mouseLeave(deleteBtn)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
@@ -52,14 +53,15 @@ export function ChatMessages(props: any) {
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
<span style={{ display: 'inline-flex', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
<span>{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'}</span>
|
||||
<span style={{ fontStyle: 'normal' }}>{formatTime(msg.created_at, is12h)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
@@ -78,7 +80,7 @@ export function ChatMessages(props: any) {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
@@ -144,8 +146,10 @@ export function ChatMessages(props: any) {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
// Own messages in the accent, the others as cards on the tinted panel.
|
||||
background: own ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
border: own ? 'none' : '1px solid var(--border-faint)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
@@ -153,7 +157,7 @@ export function ChatMessages(props: any) {
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
background: own ? 'color-mix(in srgb, var(--accent-text) 15%, transparent)' : 'var(--bg-secondary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}>
|
||||
@@ -174,16 +178,17 @@ export function ChatMessages(props: any) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
{/* Hover actions: above the reactions of the message before, which reach into this row */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
position: 'absolute', top: -14, zIndex: 3,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button type="button" onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
<Tooltip label={t('collab.chat.reply')}>
|
||||
<button type="button" onClick={() => setReplyTo(msg)} aria-label={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -194,8 +199,10 @@ export function ChatMessages(props: any) {
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{own && canEdit && (
|
||||
<button type="button" onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDelete(msg.id)} aria-label={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -206,6 +213,7 @@ export function ChatMessages(props: any) {
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('WebsiteThumbnail', () => {
|
||||
render(<WebsiteThumbnail url="https://www.liketrek.com/docs" tripId={4} color="#000" />)
|
||||
|
||||
expect(screen.getByText('liketrek.com')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'TREK'))
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('aria-label', 'TREK'))
|
||||
expect(linkPreview).toHaveBeenCalledWith(4, 'https://www.liketrek.com/docs')
|
||||
})
|
||||
|
||||
@@ -154,14 +154,15 @@ describe('WebsiteThumbnail', () => {
|
||||
|
||||
expect(screen.getByText('blocked.example')).toBeInTheDocument()
|
||||
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
|
||||
expect(screen.getByRole('link')).toHaveAttribute('title', 'https://blocked.example/a')
|
||||
expect(screen.getByRole('link')).toHaveAttribute('aria-label', 'https://blocked.example/a')
|
||||
})
|
||||
|
||||
it('FE-W4CCS-017: a url that is not http(s) is shown but neither linked nor previewed', async () => {
|
||||
const { unmount } = render(<WebsiteThumbnail url="javascript:alert(1)" tripId={4} color="#000" />)
|
||||
const { unmount, container } = render(<WebsiteThumbnail url="javascript:alert(1)" tripId={4} color="#000" />)
|
||||
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
expect(screen.getByTitle('javascript:alert(1)')).toBeInTheDocument()
|
||||
fireEvent.mouseEnter(container.firstElementChild!)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('javascript:alert(1)')
|
||||
unmount()
|
||||
|
||||
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
|
||||
@@ -174,13 +175,13 @@ describe('WebsiteThumbnail', () => {
|
||||
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
|
||||
const shared = 'https://example.com/shared'
|
||||
const first = render(<WebsiteThumbnail url={shared} tripId={4} color="#000" />)
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 4'))
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('aria-label', 'Trip 4'))
|
||||
first.unmount()
|
||||
|
||||
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
|
||||
render(<WebsiteThumbnail url={shared} tripId={9} color="#000" />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 9'))
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('aria-label', 'Trip 9'))
|
||||
expect(linkPreview).toHaveBeenCalledTimes(2)
|
||||
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('CollabLinks', () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
const heading = await screen.findByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent(/links/i);
|
||||
expect(heading).toHaveStyle({ textTransform: 'uppercase' });
|
||||
expect(heading).toHaveClass('uppercase');
|
||||
expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('CollabLinks', () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^link$|^reservations\.urlLabel$|https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => {
|
||||
@@ -90,7 +90,7 @@ describe('CollabLinks', () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
|
||||
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.type(screen.getByLabelText(/^link$|^reservations\.urlLabel$|https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' }));
|
||||
@@ -106,7 +106,7 @@ describe('CollabLinks', () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
|
||||
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.type(screen.getByLabelText(/^link$|^reservations\.urlLabel$|https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable');
|
||||
@@ -138,7 +138,7 @@ describe('CollabLinks', () => {
|
||||
expect(title).toHaveValue('Ferry timetable');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'Ferry timetable 2026');
|
||||
const url = screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i);
|
||||
const url = screen.getByLabelText(/^link$|^reservations\.urlLabel$|https|collab\.links\.urlPlaceholder/i);
|
||||
expect(url).toHaveValue('https://ferries.example/timetable');
|
||||
await user.clear(url);
|
||||
await user.type(url, 'https://ferries.example/2026');
|
||||
@@ -198,7 +198,7 @@ describe('CollabLinks', () => {
|
||||
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
|
||||
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
|
||||
const url = await screen.findByLabelText(/^link$|^reservations\.urlLabel$|https|collab\.links\.urlPlaceholder/i);
|
||||
await user.clear(url);
|
||||
await user.type(url, 'https://new.example/page');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { cloneElement, CSSProperties, FormEvent, ReactElement, useEffect, useId, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Link2, Pencil, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -8,6 +12,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
@@ -32,6 +37,11 @@ function LinkIcon({ url }: { url: string }) {
|
||||
}
|
||||
|
||||
/** The part of the address worth a chip's width: the host, without a leading www. */
|
||||
/** The desktop names a chip's action in the shared tooltip; the phone keeps its plain title. */
|
||||
function ChipTip({ label, phone, children }: { label: string; phone: boolean; children: ReactElement<{ title?: string }> }) {
|
||||
return phone ? cloneElement(children, { title: label }) : <Tooltip label={label}>{children}</Tooltip>
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
@@ -48,10 +58,12 @@ function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () =
|
||||
const [title, setTitle] = useState(link?.title ?? '')
|
||||
const [url, setUrl] = useState(link?.url ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const isPhone = useIsPhone()
|
||||
const labelId = useId()
|
||||
const valid = title.trim().length > 0 && url.trim().length > 0
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const submit = async (event?: FormEvent) => {
|
||||
event?.preventDefault()
|
||||
if (!valid || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
@@ -74,6 +86,44 @@ function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () =
|
||||
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
|
||||
}
|
||||
|
||||
// The desktop names the link in the head band like every planner dialog.
|
||||
if (!isPhone) return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
onSubmit={submit}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Link2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={t(link ? 'collab.links.edit' : 'collab.links.add')}
|
||||
titleInput={{
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
label: t('collab.links.titlePlaceholder'),
|
||||
placeholder: t('collab.links.titlePlaceholder'),
|
||||
autoFocus: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter') void submit() },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('collab.links.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => void submit()} disabled={!valid || busy}>{busy ? '...' : t('collab.links.save')}</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<EditorField label={t('reservations.urlLabel')} htmlFor="collab-link-url">
|
||||
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} className={INPUT} />
|
||||
</EditorField>
|
||||
</DialogShell>
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
|
||||
@@ -106,6 +156,7 @@ function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () =
|
||||
|
||||
export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const isPhone = useIsPhone()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const canEdit = useCanDo()('collab_edit', trip)
|
||||
const toast = useToast()
|
||||
@@ -205,7 +256,19 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
{/* Header: the desktop's head band; the phone keeps its own */}
|
||||
{!isPhone ? (
|
||||
<CollabPanelHead
|
||||
icon={Link2}
|
||||
title={t('collab.tabs.links')}
|
||||
count={links.length}
|
||||
actions={canEdit && (
|
||||
<button type="button" onClick={() => setShowForm(true)} className={HEAD_ACTION}>
|
||||
<Plus size={12} /> {t('collab.links.add')}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<Link2 size={14} color="var(--text-faint)" />
|
||||
@@ -221,17 +284,19 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is
|
||||
the link, with the host beside the title; the actions sit in its tail. */}
|
||||
<div className="chat-scroll collab-link-grid" style={{ flex: 1, overflowY: 'auto', padding: '2px 12px 12px' }}>
|
||||
<div className={isPhone ? 'chat-scroll collab-link-grid' : 'chat-scroll collab-link-grid collab-link-grid--desk'} style={{ flex: 1, overflowY: 'auto', padding: isPhone ? '2px 12px 12px' : 12 }}>
|
||||
{links.length === 0 ? (
|
||||
<EmptyState scene="links" title={t('collab.links.empty')} />
|
||||
) : (
|
||||
<div className="collab-link-grid__cells">
|
||||
{links.map(link => (
|
||||
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
|
||||
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
|
||||
<ChipTip label={t('collab.links.open')} phone={isPhone}>
|
||||
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer">
|
||||
{/* Keyed on the address: the icon remembers a favicon that failed, and
|
||||
an edit keeps the chip's id, so a corrected address got the glyph. */}
|
||||
<LinkIcon key={link.url} url={link.url} />
|
||||
@@ -240,17 +305,24 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ChipTip>
|
||||
{canEdit && (
|
||||
<span className="collab-link-chip__actions">
|
||||
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
|
||||
<Pencil size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
|
||||
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<ChipTip label={t('collab.links.edit')} phone={isPhone}>
|
||||
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')}>
|
||||
<Pencil size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</ChipTip>
|
||||
<ChipTip label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} phone={isPhone}>
|
||||
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
|
||||
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
</ChipTip>
|
||||
<ChipTip label={t('collab.links.delete')} phone={isPhone}>
|
||||
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')}>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</ChipTip>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Remove Me');
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
await noteAction(user, 'Delete');
|
||||
// Deleting now asks for confirmation first — the note stays until confirmed.
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove Me')).toBeInTheDocument();
|
||||
@@ -221,8 +221,9 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Pinned Note');
|
||||
// Unpin button is visible for pinned notes
|
||||
expect(screen.getByTitle('Unpin')).toBeInTheDocument();
|
||||
// A pinned note offers Unpin in its menu
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'More options' }));
|
||||
expect(screen.getByRole('button', { name: 'Unpin' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-015: clicking edit button opens the edit modal', async () => {
|
||||
@@ -240,7 +241,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Editable Note');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
expect(await screen.findByDisplayValue('Editable Note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -327,7 +328,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('My Note');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
await screen.findByDisplayValue('My Note');
|
||||
expect(screen.getByDisplayValue('Some content')).toBeInTheDocument();
|
||||
});
|
||||
@@ -354,7 +355,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Old Title');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
const titleInput = await screen.findByDisplayValue('Old Title');
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, 'New Title');
|
||||
@@ -387,18 +388,7 @@ describe('CollabNotes', () => {
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await screen.findByPlaceholderText('Note title');
|
||||
// Click the X button in the modal header
|
||||
const closeBtn = screen.getByRole('button', { name: '' });
|
||||
// There may be multiple, find the one in the modal (closest to the title input)
|
||||
const titleInput = screen.getByPlaceholderText('Note title');
|
||||
// The X button is the sibling button in the modal header
|
||||
const modal = titleInput.closest('form');
|
||||
const xBtn = modal?.parentElement?.querySelector('button[type="button"]') as HTMLElement | null;
|
||||
if (xBtn) {
|
||||
await user.click(xBtn);
|
||||
} else {
|
||||
// Fallback: click backdrop (the outer div)
|
||||
await user.keyboard('{Escape}');
|
||||
}
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -406,16 +396,16 @@ describe('CollabNotes', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
// The modal header renders "Category Settings" or similar
|
||||
expect(await screen.findByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Manage Categories', { selector: 'h2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-025: CategorySettingsModal shows no categories message when empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -423,7 +413,7 @@ describe('CollabNotes', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('No categories yet');
|
||||
const newCatInput = screen.getByPlaceholderText('New category...');
|
||||
await user.type(newCatInput, 'Transport');
|
||||
@@ -438,14 +428,10 @@ describe('CollabNotes', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('No categories yet');
|
||||
// Click the X button in the modal header
|
||||
const modal = screen.getByText('No categories yet').closest('div');
|
||||
const categoryModal = modal?.closest('[style*="position: fixed"]') as HTMLElement | null;
|
||||
if (categoryModal) {
|
||||
await user.click(categoryModal);
|
||||
}
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
||||
await waitFor(() => expect(screen.queryByText('No categories yet')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -498,7 +484,7 @@ describe('CollabNotes', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Expandable Note');
|
||||
// Expand button (Maximize2 icon) appears when note has content
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
// View modal shows the note title
|
||||
await waitFor(() => {
|
||||
const titles = screen.getAllByText('Expandable Note');
|
||||
@@ -521,7 +507,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('View Modal Note');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
// Modal is open — there are multiple instances of the title
|
||||
await waitFor(() => expect(screen.getAllByText('View Modal Note').length).toBeGreaterThan(1));
|
||||
// The view modal renders a pencil button to switch to edit mode
|
||||
@@ -577,9 +563,9 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Cat Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
// Food category appears in the settings modal
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
// The category "Food" is listed in the modal
|
||||
const modalFoodEntries = screen.getAllByText('Food');
|
||||
expect(modalFoodEntries.length).toBeGreaterThan(0);
|
||||
@@ -629,7 +615,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Pin Me');
|
||||
await user.click(screen.getByTitle('Pin'));
|
||||
await noteAction(user, 'Pin');
|
||||
await waitFor(() => expect(patchCalled).toBe(true));
|
||||
});
|
||||
|
||||
@@ -699,10 +685,8 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Website Note');
|
||||
// Website thumbnail shows domain name (example.com) — the domain label
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Link')).toBeInTheDocument();
|
||||
});
|
||||
// The link sits in the title row as a chip with its host
|
||||
expect(screen.getByRole('link', { name: /example\.com/ })).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-038: CategorySettingsModal Save button calls saveCategoryColors', async () => {
|
||||
@@ -725,8 +709,8 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Cat Save Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
// Change color: click first color swatch for "Travel" category
|
||||
const colorSwatches = screen.getAllByRole('button').filter(b => b.style.background && b.style.background.startsWith('#'));
|
||||
if (colorSwatches.length > 0) {
|
||||
@@ -735,7 +719,7 @@ describe('CollabNotes', () => {
|
||||
// Click Save button
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
// Modal should close
|
||||
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h2' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-039: NoteFormModal website field accepts URL input', async () => {
|
||||
@@ -778,13 +762,13 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Color Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
// "Food" appears in the modal; there are color swatches beside it
|
||||
// Find color swatch buttons (they have specific background colors from NOTE_COLORS)
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
|
||||
await user.click(saveBtn);
|
||||
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h2' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-041: note with image attachment shows thumbnail', async () => {
|
||||
@@ -856,10 +840,10 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Rename Cat Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
// Find the "Transport" category name span and click to edit
|
||||
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
|
||||
const categoryNameSpan = within(screen.getByRole('dialog')).getByRole('button', { name: 'Transport' });
|
||||
if (categoryNameSpan) {
|
||||
await user.click(categoryNameSpan);
|
||||
// Now an input with value "Transport" should appear
|
||||
@@ -890,25 +874,17 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Remove Cat Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
// Find the Trash2 SVG delete button in the modal — buttons containing lucide-trash-2 SVGs
|
||||
const trashButtons = [...document.querySelectorAll('button')].filter(
|
||||
b => b.querySelector('svg.lucide-trash-2')
|
||||
);
|
||||
if (trashButtons.length > 0) {
|
||||
// First trash button in the modal is for the 'Removable' category
|
||||
await user.click(trashButtons[0] as HTMLElement);
|
||||
// Removable category disappears from the modal
|
||||
await waitFor(() => {
|
||||
const fixedEls = document.querySelectorAll('[style*="position: fixed"]');
|
||||
let found = false;
|
||||
fixedEls.forEach(el => { if (el.textContent?.includes('Removable') && !el.textContent?.includes('Remove Cat Note')) found = true; });
|
||||
expect(found).toBe(false);
|
||||
});
|
||||
} else {
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
}
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
// A category no note uses leaves the list with its row's delete button;
|
||||
// 'Removable' stays, a note still carries it.
|
||||
const dialog = screen.getByRole('dialog');
|
||||
await user.type(within(dialog).getByPlaceholderText('New category...'), 'Temp');
|
||||
await user.keyboard('{Enter}');
|
||||
const tempRow = within(dialog).getByRole('button', { name: 'Temp' }).parentElement!;
|
||||
await user.click(within(tempRow).getByRole('button', { name: 'Delete' }));
|
||||
await waitFor(() => expect(within(dialog).queryByRole('button', { name: 'Temp' })).not.toBeInTheDocument());
|
||||
expect(within(dialog).getByRole('button', { name: 'Removable' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-045: expand note view modal displays full content with markdown', async () => {
|
||||
@@ -926,7 +902,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Full Content Note');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
// View modal shows the full content
|
||||
await waitFor(() => {
|
||||
const titles = screen.getAllByText('Full Content Note');
|
||||
@@ -951,7 +927,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Tagged Note');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
// View modal header shows the category name
|
||||
await waitFor(() => {
|
||||
const foodEls = screen.getAllByText('Food');
|
||||
@@ -977,11 +953,11 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Rename Flow Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
|
||||
// Find and click the "OldCat" category name span to enter edit mode
|
||||
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
|
||||
const oldCatSpan = within(screen.getByRole('dialog')).getByRole('button', { name: 'OldCat' });
|
||||
if (oldCatSpan) {
|
||||
await user.click(oldCatSpan);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
@@ -991,10 +967,10 @@ describe('CollabNotes', () => {
|
||||
await screen.findByText('NewCat');
|
||||
// Click Save — this triggers handleSave which calls onRenameCategory
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h2' })).not.toBeInTheDocument());
|
||||
} else {
|
||||
// If EditableCatName not found (unlikely), just close modal
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h2' })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1052,7 +1028,7 @@ describe('CollabNotes', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Attachment Note');
|
||||
// Open edit modal
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
await screen.findByDisplayValue('Attachment Note');
|
||||
// removable.pdf appears in the existing attachments list in the modal
|
||||
await screen.findByText('removable.pdf');
|
||||
@@ -1085,8 +1061,8 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('OG Image Note');
|
||||
// WebsiteThumbnail loads OG data — image is attempted, 'Link' label visible
|
||||
await waitFor(() => expect(screen.getByText('Link')).toBeInTheDocument());
|
||||
// The title row links the site by its host
|
||||
expect(screen.getByRole('link', { name: /trek-app\.example\.com/ })).toHaveAttribute('href', 'https://trek-app.example.com');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-051: view modal with PDF attachment renders attachment section code', async () => {
|
||||
@@ -1107,7 +1083,7 @@ describe('CollabNotes', () => {
|
||||
await screen.findByText('Attached View Note');
|
||||
// PDF badge is present in NoteCard
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
// View modal opens — title appears multiple times
|
||||
await waitFor(() => expect(screen.getAllByText('Attached View Note').length).toBeGreaterThan(1));
|
||||
// PDF badge appears in both card and view modal
|
||||
@@ -1131,7 +1107,7 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Image View Note');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
// View modal opens
|
||||
await waitFor(() => expect(screen.getAllByText('Image View Note').length).toBeGreaterThan(1));
|
||||
// The view modal code for image attachments executed (AuthedImg renders initially null, then img after async)
|
||||
@@ -1153,12 +1129,10 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Transition Note');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
await waitFor(() => expect(screen.getAllByText('Transition Note').length).toBeGreaterThan(1));
|
||||
// Click the Pencil button in the view modal (second-to-last button)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const pencilBtn = allButtons[allButtons.length - 2]; // Pencil is before X
|
||||
await user.click(pencilBtn);
|
||||
// The view modal's footer switches to the edit modal
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Edit' }));
|
||||
// Edit modal opens — title input should be pre-filled
|
||||
await screen.findByDisplayValue('Transition Note');
|
||||
});
|
||||
@@ -1221,10 +1195,10 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Escape Cat Note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
// Click on the category name to start editing
|
||||
const catNameSpan = screen.getAllByText('EscapeMe').find(el => el.title === 'Click to rename');
|
||||
const catNameSpan = within(screen.getByRole('dialog')).getByRole('button', { name: 'EscapeMe' });
|
||||
if (catNameSpan) {
|
||||
await user.click(catNameSpan);
|
||||
const editInput = screen.getByDisplayValue('EscapeMe');
|
||||
@@ -1254,8 +1228,9 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Alice Note');
|
||||
// The author username tooltip text is in the DOM (from data-tip div)
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
// The author's avatar names them in its tooltip
|
||||
fireEvent.mouseEnter(screen.getByRole('img', { name: 'alice' }));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('alice');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-023: notes are sorted with pinned notes first', async () => {
|
||||
@@ -1328,6 +1303,12 @@ function wsHandler(): (msg: Record<string, unknown>) => void {
|
||||
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
}
|
||||
|
||||
/** A note's actions sit behind its "More options" button; this opens the first card's menu and picks one. */
|
||||
async function noteAction(user: ReturnType<typeof userEvent.setup>, name: string): Promise<void> {
|
||||
await user.click(screen.getAllByRole('button', { name: 'More options' })[0]);
|
||||
await user.click(await screen.findByRole('button', { name }));
|
||||
}
|
||||
|
||||
function openedNoteBody(): Promise<HTMLElement> {
|
||||
return waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full');
|
||||
@@ -1367,9 +1348,8 @@ describe('CollabNotes details', () => {
|
||||
serveNotes({ notes: [buildNote({ category: 'Ideas', color: null })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('A note');
|
||||
// The card chip is a span, the filter pill above the grid is a button
|
||||
const chip = screen.getAllByText('Ideas').find(el => el.tagName === 'SPAN')!;
|
||||
expect(chip.style.color).toBe('rgb(99, 102, 241)');
|
||||
// The card chip's dot carries the category colour
|
||||
expect(screen.getByTestId('note-category-dot').style.background).toBe('rgb(99, 102, 241)');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-003: without a trip id nothing is fetched and the panel stays in its loading state', () => {
|
||||
@@ -1534,8 +1514,7 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Pin me');
|
||||
const pinBtn = screen.getAllByTitle('Pin')[0];
|
||||
await user.click(pinBtn);
|
||||
await noteAction(user, 'Pin');
|
||||
await screen.findByText('Pinned now');
|
||||
expect(screen.getByText('Leave me')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1546,9 +1525,10 @@ describe('CollabNotes details', () => {
|
||||
server.use(http.put('/api/trips/1/collab/notes/1', () => HttpResponse.json(null)));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Unchanged');
|
||||
await user.click(screen.getByTitle('Pin'));
|
||||
await noteAction(user, 'Pin');
|
||||
await waitFor(() => expect(screen.getByText('Unchanged')).toBeInTheDocument());
|
||||
expect(screen.getByTitle('Pin')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'More options' }));
|
||||
expect(screen.getByRole('button', { name: 'Pin' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-017: a failing edit reports an error and keeps the edit modal open', async () => {
|
||||
@@ -1559,7 +1539,7 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Edit me');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
const titleInput = await screen.findByDisplayValue('Edit me');
|
||||
await user.type(titleInput, ' v2');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
@@ -1579,10 +1559,9 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Sushi');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
const label = (await screen.findAllByText('Food')).find(el => el.title === 'Click to rename')!;
|
||||
const swatches = label.parentElement!.querySelectorAll('button');
|
||||
await user.click(swatches[3]);
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
const label = await within(await screen.findByRole('dialog')).findByRole('button', { name: 'Food' });
|
||||
await user.click(within(label.parentElement!).getByRole('button', { name: 'Emerald' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(bodies).toEqual([{ color: '#10b981' }]));
|
||||
});
|
||||
@@ -1605,7 +1584,7 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Edit me');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
await screen.findByDisplayValue('Edit me');
|
||||
pasteFile('attachment.png');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
@@ -1628,7 +1607,7 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Edit me');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
await screen.findByDisplayValue('Edit me');
|
||||
pasteFile('nope.png');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
@@ -1650,7 +1629,7 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Has file');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await noteAction(user, 'Edit');
|
||||
const chip = (await screen.findByText('plan.pdf')).closest('div')!;
|
||||
await user.click(chip.querySelector('button')!);
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
@@ -1707,30 +1686,20 @@ describe('CollabNotes details', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-W5CNT-025: the expanded note closes on a backdrop click and its buttons highlight on hover', async () => {
|
||||
it('FE-W5CNT-025: the expanded note closes on a backdrop click and offers edit and close', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 5, title: 'Long note', content: 'Full body', category: 'Food', color: '#ef4444' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Long note');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
const modal = await waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full')
|
||||
if (!md) throw new Error('view modal not open yet')
|
||||
return md.closest('div[style*="position: fixed"]') as HTMLElement
|
||||
});
|
||||
await noteAction(user, 'Expand');
|
||||
await openedNoteBody();
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(within(modal).getByText('Full body')).toBeInTheDocument();
|
||||
expect(within(modal).getByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||
expect(within(modal).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
|
||||
const [editBtn, closeBtn] = Array.from(modal.querySelectorAll('button'));
|
||||
fireEvent.mouseEnter(editBtn);
|
||||
expect(editBtn.style.color).toBe('var(--text-primary)');
|
||||
fireEvent.mouseLeave(editBtn);
|
||||
expect(editBtn.style.color).toBe('var(--text-faint)');
|
||||
fireEvent.mouseEnter(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-primary)');
|
||||
fireEvent.mouseLeave(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-faint)');
|
||||
|
||||
fireEvent.click(modal);
|
||||
// Only a press that starts and ends on the backdrop closes it.
|
||||
await user.click(modal.parentElement!);
|
||||
await waitFor(() => expect(document.querySelector('.collab-note-md-full')).toBeNull());
|
||||
});
|
||||
|
||||
@@ -1750,23 +1719,15 @@ describe('CollabNotes details', () => {
|
||||
});
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Trip docs');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
const modal = await waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full')
|
||||
if (!md) throw new Error('view modal not open yet')
|
||||
return md.closest('div[style*="position: fixed"]') as HTMLElement
|
||||
});
|
||||
await noteAction(user, 'Expand');
|
||||
await openedNoteBody();
|
||||
const modal = screen.getByRole('dialog');
|
||||
|
||||
// Unknown mime type and missing name fall back to a "?" tile
|
||||
expect(within(modal).getByText('?')).toBeInTheDocument();
|
||||
|
||||
const zipTile = within(modal).getByTitle('itinerary.zip');
|
||||
expect(zipTile.style.background).toBe('var(--bg-secondary)');
|
||||
expect(within(modal).getByText('ZIP')).toBeInTheDocument();
|
||||
fireEvent.mouseEnter(zipTile);
|
||||
expect(zipTile.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(zipTile);
|
||||
expect(zipTile.style.transform).toBe('scale(1)');
|
||||
const zipTile = within(modal).getByRole('button', { name: 'itinerary.zip' });
|
||||
expect(zipTile).toHaveTextContent('ZIP');
|
||||
fireEvent.click(zipTile);
|
||||
// FilePreviewPortal shows a download action for non-image files
|
||||
expect(await screen.findByText('Download itinerary.zip')).toBeInTheDocument();
|
||||
@@ -1777,12 +1738,8 @@ describe('CollabNotes details', () => {
|
||||
return img;
|
||||
});
|
||||
// The clickable thumbnail is a real button wrapped around the image, so the
|
||||
// hover transform lands on that button.
|
||||
const imageTile = image.closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(imageTile);
|
||||
expect(imageTile.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(imageTile);
|
||||
expect(imageTile.style.transform).toBe('scale(1)');
|
||||
// keyboard reaches it too.
|
||||
expect(image.closest('button')).not.toBeNull();
|
||||
fireEvent.click(image);
|
||||
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
|
||||
});
|
||||
@@ -1851,9 +1808,9 @@ describe('CollabNotes details', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('First note');
|
||||
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
const oldCat = within(screen.getByRole('dialog')).getByRole('button', { name: 'OldCat' });
|
||||
await user.click(oldCat);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
await user.clear(editInput);
|
||||
@@ -1865,10 +1822,9 @@ describe('CollabNotes details', () => {
|
||||
// half a rename is not a saved rename.
|
||||
await waitFor(() => expect(puts).toEqual(['1', '2']));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h2' })).toBeInTheDocument();
|
||||
// Re-read: the note the server refused is still shown under its old category.
|
||||
const header = screen.getByText('Manage Categories', { selector: 'h3' }).parentElement!;
|
||||
await user.click(within(header).getByRole('button'));
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
||||
await waitFor(() => expect(screen.getAllByText('NewCat').length).toBeGreaterThan(0));
|
||||
expect(screen.getAllByText('OldCat').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1882,7 +1838,7 @@ describe('CollabNotes details', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Stubborn note');
|
||||
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
await noteAction(user, 'Delete');
|
||||
const dialog = (await screen.findByText('Delete note?')).closest('div.trek-modal-enter') as HTMLElement;
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
@@ -1903,10 +1859,10 @@ describe('CollabNotes details', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('First note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
await user.click(screen.getByRole('button', { name: 'Manage Categories' }));
|
||||
await screen.findByText('Manage Categories', { selector: 'h2' });
|
||||
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
|
||||
const oldCat = within(screen.getByRole('dialog')).getByRole('button', { name: 'OldCat' });
|
||||
await user.click(oldCat);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
await user.clear(editInput);
|
||||
@@ -1917,7 +1873,7 @@ describe('CollabNotes details', () => {
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
// Nothing was renamed, so the modal must not act like it was saved…
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h2' })).toBeInTheDocument();
|
||||
// …and both rejected writes are one message, not one each.
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1927,7 +1883,7 @@ describe('CollabNotes details', () => {
|
||||
serveNotes({ notes: [buildNote({ id: 7, title: 'Hotel', content: 'Zimmernummer <TBD> bestaetigen' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
const full = await openedNoteBody();
|
||||
// Notes written before markdown rendering existed keep reading the way they
|
||||
// were typed (#2177).
|
||||
@@ -1939,7 +1895,7 @@ describe('CollabNotes details', () => {
|
||||
serveNotes({ notes: [buildNote({ id: 8, title: 'Booking', content: 'Hotel gebucht[^1]\n\n[^1]: Bestaetigung ABC123' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Booking');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
const full = await openedNoteBody();
|
||||
|
||||
const ref = within(full).getByRole('link', { name: '1' });
|
||||
@@ -1955,12 +1911,12 @@ describe('CollabNotes details', () => {
|
||||
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
const full = await openedNoteBody();
|
||||
|
||||
// A member without collab_edit never opens the edit modal, so this is the
|
||||
// only place the link is readable for them (#2222).
|
||||
const link = within(full).getByRole('link');
|
||||
const link = within(full.closest('[role="dialog"]') as HTMLElement).getByRole('link');
|
||||
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveTextContent('hotel.test');
|
||||
@@ -1971,7 +1927,7 @@ describe('CollabNotes details', () => {
|
||||
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await noteAction(user, 'Expand');
|
||||
const full = await openedNoteBody();
|
||||
|
||||
expect(within(full).queryAllByRole('link')).toHaveLength(0);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, useId, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
||||
import { Plus, Pencil, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
||||
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -294,60 +296,41 @@ function CollabNotesLoading({ t }: NotesState) {
|
||||
)
|
||||
}
|
||||
|
||||
function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: NotesState) {
|
||||
function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal, notes }: NotesState) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
|
||||
}}>
|
||||
<StickyNote size={14} color="var(--text-faint)" />
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{canEdit && <button type="button" onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Settings size={14} />
|
||||
</button>}
|
||||
{canEdit && <button type="button" onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
<CollabPanelHead
|
||||
icon={StickyNote}
|
||||
title={t('collab.notes.title')}
|
||||
count={notes.length}
|
||||
actions={canEdit && (
|
||||
<>
|
||||
<Tooltip label={t('collab.notes.categorySettings')}>
|
||||
<button type="button" onClick={() => setShowSettings(true)} aria-label={t('collab.notes.categorySettings')}
|
||||
className="grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content">
|
||||
<Settings size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button" onClick={() => setShowNewModal(true)} className={HEAD_ACTION}>
|
||||
<Plus size={12} /> {t('collab.notes.new')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
|
||||
/** The category filter, drawn like the filter tabs in the other trip tabs' bars. */
|
||||
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, getCategoryColor, t }: NotesState) {
|
||||
const pill = (active: boolean) =>
|
||||
`inline-flex flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 py-1 font-semibold transition-colors ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
|
||||
<button type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
|
||||
background: activeCategory === null ? 'var(--accent)' : 'transparent',
|
||||
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-none gap-1 overflow-x-auto px-3 pt-3" style={fs(12, 'body')}>
|
||||
<button type="button" onClick={() => setActiveCategory(null)} aria-pressed={activeCategory === null} className={pill(activeCategory === null)}>
|
||||
{t('collab.notes.all')}
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button type="button"
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
|
||||
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
|
||||
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}
|
||||
>
|
||||
<button type="button" key={cat} onClick={() => setActiveCategory(prev => prev === cat ? null : cat)} aria-pressed={activeCategory === cat} className={pill(activeCategory === cat)}>
|
||||
<span className="h-2 w-2 flex-none rounded-full" style={{ background: getCategoryColor(cat) }} />
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
@@ -369,8 +352,8 @@ function CollabNotesGrid(S: NotesState) {
|
||||
/* ── Notes grid — 2 columns ── */
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: 8,
|
||||
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 10,
|
||||
}}>
|
||||
{sortedNotes.map(note => (
|
||||
<NoteCard
|
||||
@@ -400,114 +383,91 @@ function linkHost(url: string): string {
|
||||
}
|
||||
|
||||
function ViewNoteModal(S: NotesState) {
|
||||
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
|
||||
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile, previewFile } = S
|
||||
const labelId = useId()
|
||||
if (!viewingNote) return null
|
||||
// A member without collab_edit only ever gets this modal, so the link has to
|
||||
// be here too (#2222). Allow-listed like the card tile: the field takes any
|
||||
// string, and a javascript: one would run in this origin.
|
||||
const websiteHref = safeExternalHref(viewingNote.website)
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10000, padding: 16,
|
||||
}}
|
||||
role="presentation"
|
||||
onClick={e => { if (e.target === e.currentTarget) setViewingNote(null) }}
|
||||
const close = () => setViewingNote(null)
|
||||
const color = viewingNote.category ? getCategoryColor(viewingNote.category) : null
|
||||
const attachments = viewingNote.attachments || []
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
// A file opened from here sits on top; Escape and the backdrop belong to it then.
|
||||
blocked={!!previewFile}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><StickyNote size={20} strokeWidth={1.9} style={{ color: color ?? 'var(--text-muted)' }} /></DialogTile>}
|
||||
tint={color ? `color-mix(in srgb, ${color} 12%, transparent)` : NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
title={viewingNote.title}
|
||||
pills={viewingNote.category && (
|
||||
<span className={PILL}>
|
||||
<span className="h-2 w-2 flex-none rounded-full" style={{ background: color }} />
|
||||
{viewingNote.category}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
footer={canEdit ? (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={() => { close(); setEditingNote(viewingNote) }} icon={<Pencil size={14} strokeWidth={2} />}>
|
||||
{t('common.edit')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
) : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
|
||||
{viewingNote.category && (
|
||||
<span style={{
|
||||
display: 'inline-block', marginTop: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: getCategoryColor(viewingNote.category),
|
||||
background: `${getCategoryColor(viewingNote.category)}18`,
|
||||
padding: '2px 8px', borderRadius: 6,
|
||||
}}>{viewingNote.category}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
{canEdit && <button type="button" onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={16} />
|
||||
</button>}
|
||||
<button type="button" onClick={() => setViewingNote(null)}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<DialogSection label={t('collab.notes.content')}>
|
||||
<div className="collab-note-md-full text-content" style={{ ...fs(14, 'body'), lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
|
||||
{websiteHref && (
|
||||
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
|
||||
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-primary)', textDecoration: 'none',
|
||||
}}>
|
||||
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
|
||||
</a>
|
||||
)}
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(viewingNote.attachments || []).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
|
||||
{isImage ? (
|
||||
<AuthedImg src={a.url} alt={a.original_name}
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => setPreviewFile(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<button type="button" title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
border: 'none', padding: 0, fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</DialogSection>
|
||||
{websiteHref && (
|
||||
<DialogSection label={t('collab.notes.website')}>
|
||||
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-secondary px-3 py-1.5 font-semibold text-content no-underline hover:opacity-80" style={fs(12, 'body')}>
|
||||
<ExternalLink size={13} className="flex-none text-content-muted" />
|
||||
<span className="truncate">{linkHost(websiteHref)}</span>
|
||||
</a>
|
||||
</DialogSection>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<DialogSection label={t('files.title')}>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{attachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const isPdf = a.mime_type === 'application/pdf'
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return (
|
||||
<div key={a.id} className="flex w-[72px] flex-col items-center gap-1">
|
||||
{isImage ? (
|
||||
<AuthedImg src={a.url} alt={a.original_name}
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 12, cursor: 'pointer' }}
|
||||
onClick={() => setPreviewFile(a)} />
|
||||
) : (
|
||||
<Tooltip label={a.original_name || ext}>
|
||||
<button type="button" onClick={() => setPreviewFile(a)} aria-label={a.original_name || ext}
|
||||
className={`grid h-16 w-16 place-items-center rounded-[12px] font-geist font-bold tracking-[.03em] transition-transform hover:scale-[1.06] ${isPdf ? 'bg-danger-soft text-danger' : 'bg-surface-secondary text-content-muted'}`}
|
||||
style={fs(10)}>
|
||||
{ext}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="w-full truncate text-center text-content-faint" style={fs(9.5)}>{a.original_name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)}
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../shared/markdownSanitize'
|
||||
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { ExternalLink, Maximize2, MoreHorizontal, Pencil, Pin, PinOff, Trash2 } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
import { Eyebrow, fs } from '../Planner/bookings/bookingParts'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
||||
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -27,11 +29,28 @@ interface NoteCardProps {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
/** The link chip shows the host: that is what people recognise, "www." is noise. */
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
const LINK_BTN = 'grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card shadow-sm'
|
||||
|
||||
/**
|
||||
* A note as the booking cards are drawn, kept compact: one head band tinted by
|
||||
* its category, carrying the category dot, the title, its link, the actions
|
||||
* behind "…" and who wrote it; under it the text and the files.
|
||||
*/
|
||||
export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, t }: NoteCardProps) {
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || avatarSrc(note.avatar) }
|
||||
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
||||
const attachments = note.attachments || []
|
||||
const shown = attachments.slice(0, 3)
|
||||
const hidden = attachments.length - shown.length
|
||||
// Allow-listed like everywhere else a note's link opens: the field takes any string.
|
||||
const websiteHref = safeExternalHref(note.website)
|
||||
const hasBody = !!note.content || attachments.length > 0
|
||||
const menu = useContextMenu()
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onUpdate(note.id, { pinned: !note.pinned })
|
||||
@@ -41,161 +60,99 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
onDelete(note.id)
|
||||
}, [note.id, onDelete])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${note.pinned ? color + '40' : color + '25'}`,
|
||||
background: note.pinned ? `${color}08` : 'var(--bg-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: FONT,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
...(hovered ? { transform: 'translateY(-1px)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Header bar — like reservation cards */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px',
|
||||
background: `${color}0d`,
|
||||
}}>
|
||||
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.category && (
|
||||
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||
{note.category}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
const menuItems = [
|
||||
...(note.content ? [{ label: t('common.expand'), icon: Maximize2, onClick: () => onView?.(note) }] : []),
|
||||
...(canEdit ? [
|
||||
{ label: note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin'), icon: note.pinned ? PinOff : Pin, onClick: handleTogglePin },
|
||||
{ label: t('collab.notes.edit'), icon: Pencil, onClick: () => onEdit?.(note) },
|
||||
{ divider: true },
|
||||
{ label: t('collab.notes.delete'), icon: Trash2, onClick: handleDelete, danger: true },
|
||||
] : []),
|
||||
]
|
||||
|
||||
{/* Hover actions in header */}
|
||||
{(
|
||||
<div style={{
|
||||
display: 'flex', gap: 2,
|
||||
}}>
|
||||
{note.content && (
|
||||
<button type="button" onClick={() => onView?.(note)} title={t('common.expand')} aria-label={t('common.expand')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
return (
|
||||
<article
|
||||
aria-label={note.title}
|
||||
className="group flex flex-col overflow-hidden rounded-2xl border bg-surface-card transition-shadow hover:shadow-md"
|
||||
// A pinned note keeps its category colour on the frame, the way a selected booking card does.
|
||||
style={{ borderColor: note.pinned ? `color-mix(in srgb, ${color} 45%, transparent)` : 'var(--border-faint)' }}
|
||||
>
|
||||
<div className={`flex items-center gap-2 py-1.5 pl-3 pr-1.5 ${hasBody ? 'border-b border-edge-faint' : ''}`} style={{ background: `color-mix(in srgb, ${color} 11%, transparent)` }}>
|
||||
{/* The category is only its colour here; the name is in the tooltip and the filter above. */}
|
||||
{note.category && (
|
||||
<Tooltip label={note.category}>
|
||||
<span role="img" aria-label={note.category} data-testid="note-category-dot" className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: color }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{note.title}</span>
|
||||
{!!note.pinned && <Pin size={12} strokeWidth={2.2} className="flex-none" style={{ color }} />}
|
||||
{/* The link rides in the title row as one round button; the host is in its tooltip. */}
|
||||
{note.website && (
|
||||
<Tooltip label={websiteHref ? linkHost(websiteHref) : note.website}>
|
||||
{websiteHref ? (
|
||||
<a href={websiteHref} target="_blank" rel="noopener noreferrer" aria-label={linkHost(websiteHref)} className={`${LINK_BTN} text-content-muted hover:text-content`}>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
</a>
|
||||
) : (
|
||||
<span role="img" aria-label={note.website} className={`${LINK_BTN} text-content-faint`}>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
</span>
|
||||
)}
|
||||
{canEdit && <button type="button" onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>}
|
||||
{canEdit && <button type="button" onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>}
|
||||
{canEdit && <button type="button" onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
|
||||
<UserAvatar user={author} size={16} />
|
||||
<div data-tip style={{
|
||||
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
||||
whiteSpace: 'nowrap', zIndex: 10,
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{author.username}
|
||||
</Tooltip>
|
||||
)}
|
||||
{menuItems.length > 0 && (
|
||||
<Tooltip label={t('files.menu')} disabled={!!menu.menu}>
|
||||
<button type="button" onClick={e => menu.open(e, menuItems, true)} aria-label={t('files.menu')} aria-haspopup="menu"
|
||||
className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-card hover:text-content">
|
||||
<MoreHorizontal size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={author.username}>
|
||||
<span role="img" aria-label={author.username} className="flex flex-none"><UserAvatar user={author} size={20} /></span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{hasBody && (
|
||||
<div className="flex flex-1 flex-col gap-2 px-3 pb-2.5 pt-2">
|
||||
{note.content && (
|
||||
<div className="collab-note-md line-clamp-3 break-words text-content-muted" style={{ ...fs(12, 'body'), lineHeight: 1.45 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<Eyebrow className="mb-[3px]">{t('files.title')}</Eyebrow>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{shown.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const isPdf = a.mime_type === 'application/pdf'
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 10, cursor: 'pointer' }}
|
||||
onClick={() => onPreviewFile?.(a)} />
|
||||
) : (
|
||||
<Tooltip key={a.id} label={a.original_name || ext}>
|
||||
<button type="button" aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
className={`grid h-10 w-10 place-items-center rounded-[10px] font-geist font-bold tracking-[.03em] transition-transform hover:scale-[1.06] ${isPdf ? 'bg-danger-soft text-danger' : 'bg-surface-secondary text-content-muted'}`}
|
||||
style={fs(9)}>
|
||||
{ext}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{hidden > 0 && (
|
||||
<span className="rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted" style={fs(10)}>+{hidden}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div style={{
|
||||
padding: '8px 12px 10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.content && (
|
||||
<div className="collab-note-md" style={{
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: website + attachment thumbnails */}
|
||||
{(note.website || (note.attachments?.length ?? 0) > 0) && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||
{/* Website */}
|
||||
{note.website && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
|
||||
</div>
|
||||
)}
|
||||
{/* Files */}
|
||||
{(note.attachments || []).length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<button type="button" key={a.id} title={a.original_name} aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
border: 'none', padding: 0, fontFamily: 'inherit',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2, X } from 'lucide-react'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
import { useId, useState } from 'react'
|
||||
import { Plus, Tags, Trash2 } from 'lucide-react'
|
||||
import { NOTE_COLORS } from './CollabNotes.constants'
|
||||
import { EditableCatName } from './CollabNotesEditableCatName'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { INPUT } from '../shared/dialogParts'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
// ── Category Settings Modal ──────────────────────────────────────────────────
|
||||
interface CategorySettingsModalProps {
|
||||
@@ -18,6 +20,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
const [localColors, setLocalColors] = useState({ ...categoryColors })
|
||||
const [renames, setRenames] = useState<Record<string, string>>({}) // { oldName: newName }
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const labelId = useId()
|
||||
|
||||
const handleColorChange = (cat, color) => {
|
||||
setLocalColors(prev => ({ ...prev, [cat]: color }))
|
||||
@@ -64,87 +67,83 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
// Merge existing categories from notes with saved colors
|
||||
const allCats = [...new Set([...categories, ...Object.keys(localColors)])]
|
||||
|
||||
return createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
|
||||
}} role="presentation" onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
|
||||
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
|
||||
}} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
{t('collab.notes.categorySettings') || 'Category Settings'}
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories list */}
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{allCats.length === 0 && (
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||
</p>
|
||||
)}
|
||||
{allCats.map(cat => (
|
||||
<div key={cat} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{/* Color swatches */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{NOTE_COLORS.map(c => (
|
||||
<button type="button" key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
|
||||
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
|
||||
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
|
||||
outlineOffset: 1, transition: 'transform 0.1s',
|
||||
transform: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? 'scale(1.1)' : 'scale(1)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{/* Category name — editable */}
|
||||
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
|
||||
{/* Delete */}
|
||||
<button type="button" onClick={() => handleRemoveCategory(cat)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new */}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('collab.notes.newCategory')}
|
||||
style={{
|
||||
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}} />
|
||||
<button type="button" onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Tags size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('collab.notes.categorySettings') || 'Category Settings'}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('collab.notes.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={handleSave}>{t('collab.notes.save')}</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<DialogSection label={t('collab.notes.category')}>
|
||||
{allCats.length === 0 ? (
|
||||
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-5 text-center text-content-faint" style={fs(12.5, 'body')}>
|
||||
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{allCats.map(cat => {
|
||||
const current = localColors[cat] || NOTE_COLORS[0].value
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 pl-3 pr-1.5">
|
||||
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: current }} />
|
||||
{/* Category name — editable */}
|
||||
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} renameLabel={t('common.rename')} />
|
||||
{/* Color swatches */}
|
||||
<div className="flex flex-none gap-1.5" role="group" aria-label={t('collab.notes.color')}>
|
||||
{NOTE_COLORS.map(c => {
|
||||
const on = current === c.value
|
||||
return (
|
||||
<button type="button" key={c.value} onClick={() => handleColorChange(cat, c.value)} aria-label={c.label} aria-pressed={on}
|
||||
className="h-5 w-5 rounded-full p-0 transition-transform"
|
||||
style={{
|
||||
background: c.value,
|
||||
outline: on ? '2px solid var(--text-primary)' : '2px solid transparent',
|
||||
outlineOffset: 1.5,
|
||||
transform: on ? 'scale(1.08)' : 'scale(1)',
|
||||
}} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Delete */}
|
||||
<Tooltip label={t('collab.notes.delete')}>
|
||||
<button type="button" onClick={() => handleRemoveCategory(cat)} aria-label={t('collab.notes.delete')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-danger">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<button type="button" onClick={handleSave} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
}}>
|
||||
{t('collab.notes.save')}
|
||||
)}
|
||||
{/* Add new */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('collab.notes.newCategory')}
|
||||
className={`${INPUT} flex-1`} />
|
||||
<button type="button" onClick={handleAddCategory} disabled={!newCatName.trim()} aria-label={t('collab.notes.newCategory')}
|
||||
className="grid w-10 flex-none place-items-center rounded-[10px] bg-accent text-accent-text disabled:cursor-default disabled:opacity-40">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</DialogSection>
|
||||
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
interface EditableCatNameProps {
|
||||
name: string
|
||||
onRename: (newName: string) => void
|
||||
/** The tooltip on the name, e.g. "Rename". */
|
||||
renameLabel?: string
|
||||
}
|
||||
|
||||
export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
export function EditableCatName({ name, onRename, renameLabel = 'Click to rename' }: EditableCatNameProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(name)
|
||||
const inputRef = useRef(null)
|
||||
@@ -20,15 +23,23 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
|
||||
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
onBlur={save}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') save()
|
||||
// Escape only drops the rename; the dialog around it stays open.
|
||||
if (e.key === 'Escape') { e.preventDefault(); setValue(name); setEditing(false) }
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-[8px] border border-edge bg-surface-input px-2 py-0.5 font-semibold text-content outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0', background: 'none', border: 'none', textAlign: 'left', fontFamily: 'inherit' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</button>
|
||||
<Tooltip label={renameLabel}>
|
||||
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
|
||||
className="min-w-0 flex-1 truncate py-0.5 text-left font-semibold text-content hover:underline hover:decoration-edge"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{name}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { useState, useEffect, useId } from 'react'
|
||||
import { X, ExternalLink, FileText, Loader2 } from 'lucide-react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import type { NoteFile } from './CollabNotes.types'
|
||||
|
||||
// ── File Preview Portal ─────────────────────────────────────────────────────
|
||||
@@ -11,7 +14,12 @@ interface FilePreviewPortalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** The round buttons over the dark image backdrop. */
|
||||
const LIGHTBOX_BTN = 'grid h-9 w-9 place-items-center rounded-full bg-[rgba(255,255,255,0.12)] text-[rgba(255,255,255,0.85)] hover:bg-[rgba(255,255,255,0.22)]' // theme-lint-disable: the lightbox is dark in every scheme
|
||||
|
||||
export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
@@ -20,54 +28,80 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||
}, [rawUrl])
|
||||
|
||||
const isImage = !!file?.mime_type?.startsWith('image/')
|
||||
// The dialog handles its own Escape; the image lightbox takes it here.
|
||||
useEffect(() => {
|
||||
if (!file || !isImage) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [file, isImage, onClose])
|
||||
|
||||
if (!file) return null
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} role="presentation" onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
if (isImage) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[rgba(0,0,0,0.88)] p-4" role="presentation" onClick={onClose}> {/* theme-lint-disable: the lightbox is dark in every scheme */}
|
||||
{/* Image lightbox — floating controls */}
|
||||
<div className="relative max-h-[90vh] max-w-[90vw]" role="presentation" onClick={e => e.stopPropagation()}>
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
? <img src={authUrl} alt={file.original_name} className="block max-h-[90vh] max-w-[90vw] rounded-[14px] object-contain" />
|
||||
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
|
||||
}
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
<div className="absolute inset-x-0 -top-12 flex items-center justify-between gap-3 px-1">
|
||||
<span className="truncate text-[rgba(255,255,255,0.8)]" style={fs(12, 'body')}>{file.original_name}</span>
|
||||
<div className="flex flex-none gap-2">
|
||||
<Tooltip label={t('files.openTab')}>
|
||||
<button type="button" onClick={openInNewTab} aria-label={t('files.openTab')} className={LIGHTBOX_BTN}><ExternalLink size={15} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')} className={LIGHTBOX_BTN}><X size={17} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Document viewer — card with header */
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/* Document viewer — the planner's dialog, the name on the head band */
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><FileText size={20} strokeWidth={1.9} className={isPdf ? 'text-danger' : 'text-content-muted'} /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={file.original_name}
|
||||
pills={(
|
||||
<button type="button" onClick={openInNewTab} className={`${PILL} hover:opacity-80`}>
|
||||
<ExternalLink size={13} strokeWidth={2.2} /> {t('files.openTab')}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} className="h-[74vh] w-full flex-1 border-0 bg-white" title={file.original_name}> {/* theme-lint-disable: a document page is white */}
|
||||
<p className="p-6 text-center text-content-muted">
|
||||
<button type="button" onClick={openInNewTab} className="text-content underline" style={fs(14, 'body')}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-10">
|
||||
<button type="button" onClick={openInNewTab} className="text-content underline" style={fs(14, 'body')}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,8 +146,7 @@ describe('NoteFormModal', () => {
|
||||
it('FE-W5CNF-007: the close button calls back without submitting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onClose, onSubmit } = renderModal()
|
||||
const header = screen.getByText('collab.notes.new').parentElement!
|
||||
await user.click(header.querySelector('button')!)
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -159,9 +158,10 @@ describe('NoteFormModal', () => {
|
||||
categoryColors: { Sights: '#6366f1' },
|
||||
})
|
||||
const sights = screen.getByRole('button', { name: 'Sights' })
|
||||
expect(sights.style.background).toBe('transparent')
|
||||
expect(sights).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.click(sights)
|
||||
expect(sights.style.background).toBe('rgba(99, 102, 241, 0.094)')
|
||||
expect(sights).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(sights.style.color).toBe('rgb(99, 102, 241)')
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Museum')
|
||||
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useId, useState, useRef } from 'react'
|
||||
import { Plus, StickyNote, X } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, TEXTAREA } from '../shared/dialogParts'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
|
||||
@@ -37,9 +38,10 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
const fileRef = useRef(null)
|
||||
|
||||
const finalCategory = category
|
||||
const labelId = useId()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!title.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
@@ -67,246 +69,119 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
|
||||
const canSubmit = title.trim() && !submitting
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: 16,
|
||||
fontFamily: FONT,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
role="presentation"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPaste={e => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) setPendingFiles(prev => [...prev, file])
|
||||
return
|
||||
}
|
||||
const tint = category ? `color-mix(in srgb, ${getCategoryColor(category)} 12%, transparent)` : NEUTRAL_TINT
|
||||
const chip = 'flex items-center gap-1 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[3px] text-content-muted'
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
// Only the close button takes a note away: a stray click or key must not lose what was written.
|
||||
blocked
|
||||
onPaste={e => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) setPendingFiles(prev => [...prev, file])
|
||||
return
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px 12px',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
padding: 2,
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><StickyNote size={20} strokeWidth={1.9} style={{ color: category ? getCategoryColor(category) : 'var(--text-muted)' }} /></DialogTile>}
|
||||
tint={tint}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
|
||||
titleInput={{
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
label: t('collab.notes.titlePlaceholder'),
|
||||
placeholder: t('collab.notes.titlePlaceholder'),
|
||||
autoFocus: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter') void handleSubmit() },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>
|
||||
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<EditorField label={t('collab.notes.content')} htmlFor={`${labelId}-content`}>
|
||||
<textarea id={`${labelId}-content`} value={content} onChange={e => setContent(e.target.value)} placeholder={t('collab.notes.contentPlaceholder')}
|
||||
rows={7} className={`${TEXTAREA} resize-y`} />
|
||||
</EditorField>
|
||||
|
||||
{/* Modal body */}
|
||||
<div style={{
|
||||
padding: '14px 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{t('collab.notes.title')}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={t('collab.notes.titlePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{t('collab.notes.contentPlaceholder')}
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder={t('collab.notes.contentPlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
minHeight: 180,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
|
||||
{t('collab.notes.category')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{allCategories.length > 0 && (
|
||||
<EditorField label={t('collab.notes.category')}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allCategories.map(cat => {
|
||||
const c = getCategoryColor(cat)
|
||||
const active = category === cat
|
||||
return (
|
||||
<button key={cat} type="button" onClick={() => setCategory(cat)}
|
||||
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
|
||||
<button key={cat} type="button" onClick={() => setCategory(cat)} aria-pressed={active}
|
||||
className="rounded-full px-3 py-1 font-semibold"
|
||||
style={{ ...fs(12, 'body'), border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `color-mix(in srgb, ${c} 12%, transparent)` : 'var(--bg-card)', color: active ? c : 'var(--text-muted)' }}>
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</EditorField>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.website')}
|
||||
</div>
|
||||
<input value={website} onChange={e => setWebsite(e.target.value)}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<EditorField label={t('collab.notes.website')} htmlFor={`${labelId}-website`}>
|
||||
<input id={`${labelId}-website`} value={website} onChange={e => setWebsite(e.target.value)} placeholder={t('collab.notes.websitePlaceholder')} className={INPUT} />
|
||||
</EditorField>
|
||||
|
||||
{/* File attachments */}
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
{canUploadFiles && (
|
||||
<EditorField label={t('collab.notes.attachFiles')}>
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div className="flex flex-wrap items-center gap-1.5" style={fs(11.5)}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
<div key={a.id} className={chip}>
|
||||
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 999 }} />}
|
||||
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} aria-label={t('common.delete')} className="flex text-danger">
|
||||
<X size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* New pending files */}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
<div key={`new-${i}`} className={chip}>
|
||||
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="flex text-content-faint hover:text-content">
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-edge px-3 py-[3px] font-semibold text-content-muted hover:text-content">
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 99,
|
||||
padding: '7px 14px',
|
||||
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
fontFamily: FONT,
|
||||
border: 'none',
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
|
||||
</button>
|
||||
</div>
|
||||
</EditorField>
|
||||
)}
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
|
||||
@@ -69,14 +70,16 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps)
|
||||
</>
|
||||
)
|
||||
|
||||
if (!href) return <span title={data?.title || url} style={tileStyle}>{tile}</span>
|
||||
if (!href) return <Tooltip label={data?.title || url}><span style={tileStyle}>{tile}</span></Tooltip>
|
||||
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" title={data?.title || url}
|
||||
<Tooltip label={data?.title || url}>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" aria-label={data?.title || url}
|
||||
style={tileStyle}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
{tile}
|
||||
</a>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import CollabLinks from './CollabLinks'
|
||||
import CollabPanelHead from './CollabPanelHead'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
@@ -18,7 +19,7 @@ function useIsDesktop(breakpoint = 1024) {
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const cardClass = 'flex flex-col bg-surface-card rounded-2xl border border-edge-faint overflow-hidden min-h-0'
|
||||
const cardClass = 'flex flex-col bg-surface-secondary rounded-2xl border border-edge-faint overflow-hidden min-h-0'
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
@@ -91,8 +92,17 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</>
|
||||
)
|
||||
// The chat carries the same head band as the panels beside it.
|
||||
const chatPanel = (
|
||||
<>
|
||||
<CollabPanelHead icon={MessageCircle} title={t('collab.tabs.chat') || 'Chat'} />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
const panelRow = (ids: string[]) => (
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 16, minHeight: 0 }}>
|
||||
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
||||
</div>
|
||||
)
|
||||
@@ -105,9 +115,9 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
if (chatOn && rightPanels.length === 0) {
|
||||
// Only chat
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className="flex h-full min-h-0 gap-4 overflow-hidden px-7 py-6">
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
{chatPanel}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -116,11 +126,11 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
if (chatOn) {
|
||||
// Chat left (380px) + right panels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className="flex h-full min-h-0 gap-4 overflow-hidden px-7 py-6">
|
||||
<div className={cardClass} style={{ flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
{chatPanel}
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
||||
{rightPanels.length >= 3 && (() => {
|
||||
// Two rows, split by kind rather than by a pair being present:
|
||||
@@ -142,7 +152,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className="flex h-full min-h-0 gap-4 overflow-hidden px-7 py-6">
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
/** A panel's add action: a soft card pill on the head band. */
|
||||
export const HEAD_ACTION = 'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content-muted shadow-sm hover:text-content'
|
||||
|
||||
/**
|
||||
* The head band every Collab panel opens with, the same one the cards of the
|
||||
* other trip tabs carry: its icon and name in small capitals, how many there
|
||||
* are, and its actions on the right.
|
||||
*/
|
||||
export default function CollabPanelHead({ icon: Icon, title, count, actions }: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
count?: number
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[50px] flex-none items-center gap-2 border-b border-edge-faint px-4 py-2" style={{ background: NEUTRAL_TINT }}>
|
||||
<Icon size={14} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<h3 className="m-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(11)}>{title}</h3>
|
||||
{count != null && count > 0 && (
|
||||
<span className="rounded-full bg-surface-card px-2 py-[2px] font-geist font-bold text-content-muted" style={fs(10)}>{count}</span>
|
||||
)}
|
||||
{actions && <div className="ml-auto flex items-center gap-1.5" style={fs(11.5)}>{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -219,7 +219,7 @@ describe('CollabPolls', () => {
|
||||
await screen.findByText('Best destination?');
|
||||
|
||||
// Delete button has a title with "delete"
|
||||
const deleteBtn = screen.getByTitle(/delete/i);
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i });
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
@@ -420,18 +420,13 @@ describe('CollabPolls details', () => {
|
||||
expect(screen.getByText('?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-012: hovering an open option scales it, a closed one stays put', async () => {
|
||||
it('FE-W5CPL-012: an open option can be voted on, a closed one cannot', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 1 }), buildPoll({ id: 2, question: 'Done?', is_closed: true })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Done?');
|
||||
const [openOption, closedOption] = screen.getAllByText('Paris').map(el => el.closest('button')!);
|
||||
fireEvent.mouseEnter(openOption);
|
||||
expect(openOption.style.transform).toBe('scale(1.01)');
|
||||
fireEvent.mouseLeave(openOption);
|
||||
expect(openOption.style.transform).toBe('scale(1)');
|
||||
|
||||
fireEvent.mouseEnter(closedOption);
|
||||
expect(closedOption.style.transform).toBe('');
|
||||
expect(openOption).toBeEnabled();
|
||||
expect(closedOption).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-013: the closed section heading only appears next to active polls', async () => {
|
||||
@@ -461,22 +456,15 @@ describe('CollabPolls details', () => {
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Stays open?');
|
||||
|
||||
// Both action buttons highlight on hover
|
||||
const closeBtn = screen.getAllByTitle('Close')[0];
|
||||
fireEvent.mouseEnter(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-primary)');
|
||||
fireEvent.mouseLeave(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-faint)');
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
fireEvent.mouseEnter(deleteBtn);
|
||||
expect(deleteBtn.style.color).toBe('rgb(239, 68, 68)');
|
||||
fireEvent.mouseLeave(deleteBtn);
|
||||
expect(deleteBtn.style.color).toBe('var(--text-faint)');
|
||||
// Both actions sit on each poll's head band
|
||||
const closeBtn = screen.getAllByRole('button', { name: 'Close' })[0];
|
||||
const deleteBtn = screen.getAllByRole('button', { name: 'Delete' })[0];
|
||||
expect(deleteBtn).toBeInTheDocument();
|
||||
|
||||
await user.click(closeBtn);
|
||||
await waitFor(() => expect(closeCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
|
||||
expect(screen.getAllByTitle('Close')).toHaveLength(1);
|
||||
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-027: a failing poll request falls back to the empty state', async () => {
|
||||
@@ -501,9 +489,9 @@ describe('CollabPolls details', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
await user.click(screen.getByTitle(/close/i));
|
||||
await user.click(screen.getByRole('button', { name: /close/i }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByTitle(/close/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-016: a failing delete shows an error and keeps the poll', async () => {
|
||||
@@ -514,7 +502,7 @@ describe('CollabPolls details', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
await user.click(screen.getByTitle(/delete/i));
|
||||
await user.click(screen.getByRole('button', { name: /delete/i }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByText('Best destination?')).toBeInTheDocument();
|
||||
});
|
||||
@@ -637,10 +625,10 @@ describe('CollabPolls details', () => {
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
const toggle = screen.getByText(/multiple|multi/i).previousElementSibling as HTMLElement;
|
||||
expect(toggle.style.background).toBe('var(--border-primary)');
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
await user.click(toggle);
|
||||
expect(toggle.style.background).toBe('rgb(0, 122, 255)');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Multi?');
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useId } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { RoundAction } from '../Planner/bookings/bookingParts'
|
||||
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, TEXTAREA } from '../shared/dialogParts'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -11,7 +16,6 @@ import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface PollVoter {
|
||||
@@ -107,8 +111,9 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
|
||||
const canSubmit = question.trim() && options.filter(o => o.trim()).length >= 2 && !submitting
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
const labelId = useId()
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
@@ -119,109 +124,87 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
} catch {} finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
|
||||
</div>
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
onSubmit={handleSubmit}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><BarChart3 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('collab.polls.new')}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>{submitting ? '...' : t('collab.polls.create')}</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<EditorField label={t('collab.polls.question')} hint={t('collab.polls.markdownHint')}>
|
||||
<textarea autoFocus rows={4} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} className={`${TEXTAREA} resize-y`} />
|
||||
</EditorField>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: FONT }}>
|
||||
<Plus size={12} /> {t('collab.polls.addOption')}
|
||||
</button>
|
||||
<EditorField label={t('collab.polls.options')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`} className={`${TEXTAREA} resize-y`} />
|
||||
{options.length > 2 && (
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => removeOption(i)} aria-label={t('common.delete')} className="mt-1.5 grid h-7 w-7 flex-none place-items-center rounded-full text-content-faint hover:text-danger">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi choice toggle */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer', border: 'none',
|
||||
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={!canSubmit} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
|
||||
}}>
|
||||
{submitting ? '...' : t('collab.polls.create')}
|
||||
</button>
|
||||
))}
|
||||
{/* The string starts with its own "+", and the button already draws one. */}
|
||||
<AddRowButton onClick={addOption}>{t('collab.polls.addOption').replace(/^\+\s*/, '')}</AddRowButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
</EditorField>
|
||||
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-[12px] bg-surface-secondary px-3 py-2.5">
|
||||
<span className="text-content" style={fs(13, 'body')}>{t('collab.polls.multiChoice')}</span>
|
||||
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)}
|
||||
className={`flex h-5 w-9 flex-none items-center rounded-full p-0.5 transition-colors ${multiChoice ? 'bg-accent' : 'bg-surface-tertiary'}`}>
|
||||
<span className={`block h-4 w-4 rounded-full bg-surface-card shadow-sm transition-transform ${multiChoice ? 'translate-x-4' : ''}`} />
|
||||
</button>
|
||||
</label>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
|
||||
// ── Voter Chip ───────────────────────────────────────────────────────────────
|
||||
interface VoterChipProps {
|
||||
voter: PollVoter
|
||||
offset: boolean
|
||||
}
|
||||
|
||||
function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const ref = React.useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
|
||||
}}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
{hover && createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{voter.username}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
<Tooltip label={voter.username || '?'}>
|
||||
<span className="grid h-[18px] w-[18px] flex-none place-items-center overflow-hidden rounded-full border-[1.5px] border-surface-card bg-surface-tertiary font-bold text-content-muted"
|
||||
style={{ ...fs(7), marginLeft: offset ? -5 : 0 }}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} className="h-full w-full object-cover" /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** A fact about the poll on its head band, set like the type chip on a booking card. */
|
||||
function PollChip({ icon, tone = 'muted', children }: { icon?: React.ReactNode; tone?: 'muted' | 'warning'; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-flex flex-none items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-geist font-bold uppercase tracking-[.06em] ${tone === 'warning' ? 'text-warning' : 'text-content-muted'}`} style={fs(9.5)}>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -233,7 +216,7 @@ interface PollCardProps {
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
@@ -245,65 +228,34 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const topCount = (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: 14, border: '1px solid var(--border-faint)', overflow: 'hidden',
|
||||
background: 'var(--bg-card)', fontFamily: FONT,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
<article className="group overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
|
||||
{/* Head band: the question, what kind of poll it is, and its actions */}
|
||||
<div className="flex items-start gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: isClosed ? 'var(--bg-secondary)' : NEUTRAL_TINT }}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="break-words font-bold text-content" style={{ ...fs(13.5, 'body'), lineHeight: 1.35 }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={pollMarkdownComponents}>
|
||||
{poll.question}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Lock size={8} /> {t('collab.polls.closed')}
|
||||
</span>
|
||||
)}
|
||||
{remaining && !isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multiple_choice && (
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
{t('collab.polls.multiChoice')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
|
||||
{total} {total === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{isClosed && <PollChip icon={<Lock size={9} strokeWidth={2.4} />}>{t('collab.polls.closed')}</PollChip>}
|
||||
{remaining && !isClosed && <PollChip tone="warning" icon={<Clock size={9} strokeWidth={2.4} />}>{remaining}</PollChip>}
|
||||
{poll.multiple_choice && <PollChip>{t('collab.polls.multiChoice')}</PollChip>}
|
||||
<PollChip>{t(total === 1 ? 'collab.polls.vote' : 'collab.polls.votes', { n: total })}</PollChip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
<div className="flex flex-none gap-1">
|
||||
{!isClosed && (
|
||||
<button type="button" onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
<RoundAction label={t('collab.polls.close')} onClick={() => onClose(poll.id)}><Lock size={12} strokeWidth={2} /></RoundAction>
|
||||
)}
|
||||
<button type="button" onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<RoundAction label={t('collab.polls.delete')} onClick={() => onDelete(poll.id)} danger><Trash2 size={12} strokeWidth={2} /></RoundAction>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div style={{ padding: '4px 12px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
{(poll.options || []).map((opt, idx) => {
|
||||
const count = opt.voters?.length || 0
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
||||
@@ -311,64 +263,44 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const isWinner = isClosed && count > 0 && count === topCount
|
||||
|
||||
return (
|
||||
// React dispatches no mouse events on a disabled control, so the
|
||||
// handlers below need no isClosed guard of their own.
|
||||
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.01)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
|
||||
className={`relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-left transition-colors disabled:cursor-default ${myVote ? 'border-accent' : 'border-edge-faint enabled:hover:border-edge'}`}
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0,
|
||||
width: `${pct}%`, borderRadius: 10,
|
||||
background: myVote ? '#007AFF20' : isWinner ? '#10b98118' : 'var(--bg-tertiary)',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
<span aria-hidden="true" className="absolute inset-y-0 left-0 transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: myVote ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : isWinner ? 'var(--success-soft)' : 'var(--bg-tertiary)',
|
||||
}} />
|
||||
|
||||
{/* Check circle */}
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, position: 'relative',
|
||||
border: myVote ? '2px solid #007AFF' : '2px solid var(--border-primary)',
|
||||
background: myVote ? '#007AFF' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className={`relative grid h-5 w-5 flex-none place-items-center rounded-full border-2 transition-colors ${myVote ? 'border-accent bg-accent text-accent-text' : 'border-edge bg-surface-card'}`}>
|
||||
{myVote && <Check size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
|
||||
{/* Label — options stay plain text, but long or multiline ones
|
||||
must wrap instead of being clipped by the button's
|
||||
overflow:hidden (#2177) */}
|
||||
<span style={{
|
||||
<span className="relative z-[1] text-content" style={{
|
||||
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
|
||||
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
lineHeight: 1.35, ...fs(13, 'body'), fontWeight: myVote || isWinner ? 600 : 400,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
</span>
|
||||
|
||||
{/* Voter avatars */}
|
||||
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
|
||||
<div style={{ display: 'flex', position: 'relative', zIndex: 1 }}>
|
||||
<span className="relative z-[1] flex">
|
||||
{(opt.voters || []).slice(0, 3).map((v, vi) => (
|
||||
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Percentage */}
|
||||
{(hasVoted || isClosed) && (
|
||||
<span style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
|
||||
}}>
|
||||
<span className={`relative z-[1] min-w-[32px] text-right font-geist font-bold tabular-nums ${myVote ? 'text-content' : 'text-content-muted'}`} style={fs(12, 'body')}>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
@@ -376,7 +308,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -497,24 +429,19 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button type="button" onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<CollabPanelHead
|
||||
icon={BarChart3}
|
||||
title={t('collab.polls.title')}
|
||||
count={polls.length}
|
||||
actions={canEdit && (
|
||||
<button type="button" onClick={() => setShowForm(true)} className={HEAD_ACTION}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{polls.length === 0 ? (
|
||||
<EmptyState scene="polls" title={t('collab.polls.empty')} />
|
||||
) : (
|
||||
@@ -525,7 +452,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
{activePolls.length > 0 && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
<div className="px-1 pb-0.5 pt-2 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{t('collab.polls.closedSection')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
|
||||
import { MapPin, Sparkles } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import CollabPanelHead from './CollabPanelHead'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { localToday } from '../Planner/today'
|
||||
|
||||
@@ -91,21 +93,14 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
<CollabPanelHead icon={Sparkles} title={t('collab.whatsNext.title') || "What's Next"} count={upcoming.length} />
|
||||
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{/* List: one card per stop, grouped under its day like the planner's days */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<EmptyState scene="guide" title={t('collab.whatsNext.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{upcoming.map((item, idx) => {
|
||||
const prevItem = upcoming[idx - 1]
|
||||
const showDayHeader = !prevItem || prevItem.date !== item.date
|
||||
@@ -113,34 +108,26 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
{formatDayLabel(item.date, t, locale)}
|
||||
{item.dayTitle ? ` — ${item.dayTitle}` : ''}
|
||||
<div className={`flex min-w-0 items-center gap-2 px-1 pb-0.5 ${idx === 0 ? '' : 'pt-2.5'}`}>
|
||||
<span className="flex-none font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{formatDayLabel(item.date, t, locale)}
|
||||
</span>
|
||||
{item.dayTitle && <span className="truncate font-semibold text-content-muted" style={fs(11)}>{item.dayTitle}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--bg-secondary)', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
>
|
||||
<div className="flex gap-3 rounded-[14px] border border-edge-faint bg-surface-card px-3 py-2.5 transition-shadow hover:shadow-md">
|
||||
{/* Time column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<div className="flex min-w-[44px] flex-none flex-col items-center justify-center font-geist tabular-nums">
|
||||
<span className="whitespace-nowrap font-bold leading-none text-content" style={fs(12, 'body')}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
<span className="my-[3px] font-bold uppercase tracking-[.06em] text-content-faint" style={fs(7.5)}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<span className="whitespace-nowrap font-bold leading-none text-content" style={fs(12, 'body')}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
@@ -148,43 +135,33 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
|
||||
<div className="my-0.5 w-px flex-none self-stretch bg-edge-faint" />
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-bold leading-snug text-content" style={fs(13, 'body')}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={10} className="flex-none text-content-faint" />
|
||||
<span className="truncate text-content-faint" style={fs(10.5)}>{item.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{item.participants.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{item.participants.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
|
||||
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
<span key={p.user_id} className="flex items-center gap-1 rounded-full border border-edge-faint bg-surface-secondary py-[2px] pl-[2px] pr-2">
|
||||
<span className="grid h-4 w-4 flex-none place-items-center overflow-hidden rounded-full bg-surface-tertiary font-bold text-content-muted" style={fs(7)}>
|
||||
{p.avatar
|
||||
? <img src={avatarSrc(p.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={avatarSrc(p.avatar)!} alt="" className="h-full w-full object-cover" />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span className="font-semibold text-content-muted" style={fs(10.5)}>{p.username}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-COLPICKER-001 to FE-COMP-COLPICKER-016
|
||||
// FE-COMP-COLPICKER-001 to FE-COMP-COLPICKER-021
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
|
||||
import type { Collection, CollectionDetailResponse, CollectionPlace } from '@trek/shared'
|
||||
@@ -177,7 +177,7 @@ describe('CollectionPicker', () => {
|
||||
it('FE-COMP-COLPICKER-010: the status dropdown filters by saved status', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^All$/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Status.*All/ }))
|
||||
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
|
||||
|
||||
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
|
||||
@@ -250,3 +250,51 @@ describe('CollectionPicker', () => {
|
||||
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* FE-COMP-COLPICKER-019..020 — the column in the place dialog's look: a labelled
|
||||
* search that says what it searches, and rows named by what they show.
|
||||
*/
|
||||
describe('CollectionPicker in the place dialog', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false })
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [listA, listB], incomingInvites: [] })
|
||||
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
|
||||
id === 1 ? detail(listA, [zebra]) : detail(listB, [alpha, noCoords]),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-019: the search has its own label and is named after the saved places', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
const search = screen.getByRole('textbox', { name: 'Search your saved places' })
|
||||
expect(search).toBe(screen.getByPlaceholderText('Search your saved places'))
|
||||
expect(screen.getByText('Search', { selector: 'label' })).toHaveAttribute('for', search.id)
|
||||
expect(screen.getByRole('heading', { name: 'Saved places' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-020: a row carries no native tooltip; its name and address say what it adds', async () => {
|
||||
setup()
|
||||
const row = await screen.findByRole('button', { name: /Zebra Cafe/ })
|
||||
expect(row).not.toHaveAttribute('title')
|
||||
expect(row).toHaveTextContent('Zebra CafeShibuya 1')
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-021: each filter has its own label, which names its trigger and its list', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
const lists = screen.getByRole('button', { name: 'Collections All lists' })
|
||||
const status = screen.getByRole('button', { name: 'Status All' })
|
||||
expect(screen.getByText('Collections', { selector: 'span' })).toHaveAttribute('id', lists.getAttribute('aria-labelledby')?.split(' ')[0])
|
||||
fireEvent.click(status)
|
||||
expect(screen.getByRole('listbox', { name: 'Status' })).toBeInTheDocument()
|
||||
// Picking changes the value half of the name, never the label half.
|
||||
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
|
||||
expect(screen.getByRole('button', { name: /^Status Visited/ })).toBe(status)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Search, Bookmark, Loader2, ChevronDown, Check, Layers } from 'lucide-react'
|
||||
import React, { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Search, Bookmark, Loader2, ChevronDown, Check, Layers, Plus, SearchX } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
|
||||
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
@@ -64,40 +66,55 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
* truncated at roughly ten characters. The row carries the `relative` this
|
||||
* needs; there is no other call site.
|
||||
*/
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
function FilterDropdown({ label, current, options, onSelect, lead }: {
|
||||
/** What the filter filters, as the small label over it and as the name of its list. */
|
||||
label: string
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
onSelect: (key: string | number) => void
|
||||
lead: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const labelId = useId()
|
||||
const valueId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
// Captured and marked handled, so the key closes this list and not the dialog around it.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey, true) }
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="min-w-0 flex-1" ref={ref}>
|
||||
<span id={labelId} className={LABEL}>{label}</span>
|
||||
{/* A white pill, like the pickers on the dialog's head band. Named by the
|
||||
label over it and the value it shows, so "All" says what it is all of. */}
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
<span className="flex-1 min-w-0 truncate text-left">{cur.label}</span>
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
className={`flex w-full items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 transition-colors hover:bg-surface-hover ${open ? 'ring-content-faint' : 'ring-edge-faint'}`}
|
||||
style={fs(12, 'body')}>
|
||||
<span className="flex flex-none text-content-faint">{cur.icon ?? lead}</span>
|
||||
<span id={valueId} className="min-w-0 flex-1 truncate text-left">{cur.label}</span>
|
||||
<ChevronDown size={13} strokeWidth={2.2} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
<div role="listbox" aria-label={label} className="absolute left-0 right-0 top-full z-30 mt-1 flex max-h-[240px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
<span className="shrink-0 text-content-faint">{o.icon}</span>
|
||||
<span className="flex-1 min-w-0 truncate">{o.label}</span>
|
||||
{o.count != null && <span className="shrink-0 text-[11px] text-content-faint tabular-nums">{o.count}</span>}
|
||||
{o.key === current && <Check size={13} className="shrink-0 text-accent" />}
|
||||
className={`flex items-center gap-2 rounded-[8px] px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'bg-surface-tertiary font-semibold text-content' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
<span className="flex flex-none text-content-faint">{o.icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{o.label}</span>
|
||||
{o.count != null && <span className="flex-none tabular-nums text-content-faint" style={fs(11)}>{o.count}</span>}
|
||||
{o.key === current && <Check size={13} className="flex-none text-content-muted" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -113,6 +130,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
* Desktop only — gated by the caller.
|
||||
*/
|
||||
export default function CollectionPicker({ bias, onSelect, t }: CollectionPickerProps): React.ReactElement {
|
||||
const searchId = useId()
|
||||
const [places, setPlaces] = useState<CollectionPlace[]>([])
|
||||
const [lists, setLists] = useState<ListMeta[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -170,7 +188,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
...lists.map(l => ({
|
||||
key: l.id,
|
||||
label: l.name,
|
||||
icon: <span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: l.color || '#6366f1' }} />,
|
||||
icon: <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: l.color || '#6366f1' }} />, // theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail)
|
||||
count: places.filter(p => p.collection_id === l.id).length,
|
||||
})),
|
||||
]
|
||||
@@ -186,64 +204,69 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
// Same 320px as the details column on the other side of the form: two panels
|
||||
// of different widths flanking one form read as a mistake rather than a
|
||||
// hierarchy, and neither of them is the more important one.
|
||||
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
</div>
|
||||
<div className="p-2.5 flex flex-col gap-2 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.picker.search')}
|
||||
className="w-full pl-8 pr-3 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<aside className={`${SIDE_COLUMN} sm:w-80`}>
|
||||
<ColumnHead icon={<Bookmark size={13} strokeWidth={2.2} />}>{t('collections.picker.title')}</ColumnHead>
|
||||
<div className="flex flex-none flex-col gap-2 px-3 pb-2.5">
|
||||
<EditorField label={t('common.search')} htmlFor={searchId}>
|
||||
<div className="relative">
|
||||
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
|
||||
<input
|
||||
id={searchId}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
// Named after what it searches: the form beside it has a search of its own.
|
||||
aria-label={t('collections.picker.search')}
|
||||
placeholder={t('collections.picker.search')}
|
||||
className={`${INPUT} pl-8`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{lists.length > 0 && (
|
||||
<div className="relative flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
<FilterDropdown label={t('collections.title')} current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown label={t('mobileCollections.status')} current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-content-faint">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<div className="px-1">
|
||||
<HintCard icon={<Loader2 size={15} className="animate-spin" />}>{t('common.loading')}</HintCard>
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
title={t('collections.picker.empty')}
|
||||
size={84}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<div className="px-1">
|
||||
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('collections.picker.empty')}</HintCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{page.map(place => (
|
||||
// Built like a row of the planner's places list: the round picture
|
||||
// or category disc, the name, and the address on one line under it.
|
||||
<button
|
||||
key={place.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(place)}
|
||||
title={t('collections.picker.use')}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors"
|
||||
className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-2 text-left outline-none transition-colors hover:bg-surface-card hover:shadow-sm focus-visible:bg-surface-card focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] active:bg-surface-selected"
|
||||
>
|
||||
<PlaceAvatar place={place} size={32} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-[12.5px] font-medium text-content truncate">{place.name}</span>
|
||||
{place.address && <span className="text-[11px] text-content-faint truncate">{place.address}</span>}
|
||||
<PlaceAvatar place={place} size={34} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{place.name}</span>
|
||||
{place.address && <span className="mt-0.5 truncate text-content-faint" style={fs(11)}>{place.address}</span>}
|
||||
</span>
|
||||
{/* What a click does, said the way the planner's rows say it. */}
|
||||
<span aria-hidden="true" className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted opacity-0 shadow-sm ring-1 ring-edge-faint transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
<Plus size={13} strokeWidth={2.4} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div className="pt-1 mt-1 border-t border-edge">
|
||||
<div className="mt-1.5 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShown(n => n + PAGE)}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-[12px] font-medium text-content-secondary hover:bg-surface-hover transition-colors"
|
||||
className={WHITE_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('collections.picker.showMore', { count: remaining })}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-038
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -165,7 +165,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Find the star button by its title
|
||||
const starBtn = screen.getByTitle(/star/i);
|
||||
const starBtn = screen.getByRole('button', { name: /star/i });
|
||||
await user.click(starBtn);
|
||||
|
||||
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
|
||||
@@ -182,7 +182,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Click trash toggle button
|
||||
const trashBtn = screen.getByText(/trash/i);
|
||||
const trashBtn = screen.getByRole('button', { name: /trash/i });
|
||||
await user.click(trashBtn);
|
||||
|
||||
// Trashed file should appear
|
||||
@@ -199,12 +199,12 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open trash
|
||||
const trashBtn = screen.getByText(/trash/i);
|
||||
const trashBtn = screen.getByRole('button', { name: /trash/i });
|
||||
await user.click(trashBtn);
|
||||
await screen.findByText('old.pdf');
|
||||
|
||||
// Click restore button
|
||||
const restoreBtn = screen.getByTitle(/restore/i);
|
||||
const restoreBtn = screen.getByRole('button', { name: /restore/i });
|
||||
await user.click(restoreBtn);
|
||||
|
||||
expect(filesApi.restore).toHaveBeenCalledWith(1, 5);
|
||||
@@ -220,11 +220,11 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open trash
|
||||
await user.click(screen.getByText(/trash/i));
|
||||
await user.click(screen.getByRole('button', { name: /trash/i }));
|
||||
await screen.findByText('old.pdf');
|
||||
|
||||
// Click permanent delete (the Trash2 icon button in trash view)
|
||||
const deleteBtn = screen.getByTitle(/delete/i);
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i });
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 5);
|
||||
@@ -240,7 +240,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open trash
|
||||
await user.click(screen.getByText(/trash/i));
|
||||
await user.click(screen.getByRole('button', { name: /trash/i }));
|
||||
await screen.findByText('old.pdf');
|
||||
|
||||
// Click "Empty Trash" button
|
||||
@@ -295,7 +295,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// The delete (trash) button on a non-trash row is titled 'Delete'
|
||||
const deleteBtn = screen.getByTitle(/delete/i);
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i });
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith(1);
|
||||
@@ -399,12 +399,12 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Pencil/assign button
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
const assignBtn = screen.getByRole('button', { name: /assign/i });
|
||||
await user.click(assignBtn);
|
||||
|
||||
// Assign modal should appear (it has a title and a close button)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/assign/i, { selector: 'div' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: /assign/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,7 +414,7 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
const assignBtn = screen.getByRole('button', { name: /assign/i });
|
||||
await user.click(assignBtn);
|
||||
|
||||
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
|
||||
@@ -440,9 +440,9 @@ describe('FileManager', () => {
|
||||
});
|
||||
|
||||
// Close via X button in the modal (second X button — first might be something else)
|
||||
const closeButtons = screen.getAllByRole('button', { name: '' });
|
||||
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
|
||||
// Find a close button near the modal header — click the last X-like button
|
||||
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
|
||||
const xBtn = closeButtons.find(btn => btn.closest('[role="dialog"]'));
|
||||
if (xBtn) await user.click(xBtn);
|
||||
});
|
||||
|
||||
@@ -452,7 +452,7 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
const assignBtn = screen.getByRole('button', { name: /assign/i });
|
||||
await user.click(assignBtn);
|
||||
|
||||
expect(await screen.findByText('Hotel Paris')).toBeInTheDocument();
|
||||
@@ -467,7 +467,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open assign modal
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await user.click(screen.getByRole('button', { name: /assign/i }));
|
||||
await screen.findByText('Louvre Museum');
|
||||
|
||||
// Click on the place button to link it
|
||||
@@ -484,7 +484,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open assign modal
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await user.click(screen.getByRole('button', { name: /assign/i }));
|
||||
await screen.findByText('Train Ticket');
|
||||
|
||||
// Click on the reservation button to link it
|
||||
@@ -500,7 +500,7 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await user.click(screen.getByRole('button', { name: /assign/i }));
|
||||
await screen.findByText('Notre Dame');
|
||||
expect(await screen.findByText('Airbnb')).toBeInTheDocument();
|
||||
});
|
||||
@@ -552,7 +552,7 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await user.click(screen.getByRole('button', { name: /assign/i }));
|
||||
expect(await screen.findByText('Arc de Triomphe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -577,11 +577,11 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open assign modal
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Venice Beach');
|
||||
await user.click(screen.getByRole('button', { name: /assign/i }));
|
||||
await within(await screen.findByRole('dialog')).findByText('Venice Beach');
|
||||
|
||||
// Clicking the linked place should unlink it
|
||||
await user.click(screen.getByText('Venice Beach'));
|
||||
await user.click(within(screen.getByRole('dialog')).getByText('Venice Beach'));
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
|
||||
});
|
||||
|
||||
@@ -594,11 +594,11 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Museum Pass');
|
||||
await user.click(screen.getByRole('button', { name: /assign/i }));
|
||||
await within(await screen.findByRole('dialog')).findByText('Museum Pass');
|
||||
|
||||
// Clicking the linked reservation should unlink it
|
||||
await user.click(screen.getByText('Museum Pass'));
|
||||
await user.click(within(screen.getByRole('dialog')).getByText('Museum Pass'));
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
|
||||
});
|
||||
|
||||
@@ -615,7 +615,7 @@ describe('FileManager', () => {
|
||||
});
|
||||
|
||||
// Click the backdrop to close
|
||||
const backdrop = document.querySelector('[style*="z-index: 10000"]') as HTMLElement;
|
||||
const backdrop = document.querySelector('.trek-modal-backdrop') as HTMLElement;
|
||||
if (backdrop) await user.click(backdrop);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -644,7 +644,7 @@ describe('FileManager', () => {
|
||||
|
||||
describe('document sync button', () => {
|
||||
const paperless = { id: 'paperless', name: 'Paperless-ngx', description: null, icon: 'paperless', available: true, fields: [] };
|
||||
const trashButton = () => screen.getByText('Trash').closest('button') as HTMLButtonElement;
|
||||
const trashButton = () => screen.getByRole('button', { name: 'Trash' });
|
||||
|
||||
it('FE-COMP-FILEMANAGER-036: with no provider on and nothing bound there is no sync button, and the trash sits flush right', async () => {
|
||||
const asked: string[] = [];
|
||||
@@ -657,8 +657,8 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(asked).toContain('links'));
|
||||
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
|
||||
expect(trashButton()).toHaveStyle({ marginLeft: 'auto' });
|
||||
expect(screen.queryByRole('button', { name: 'Document sync' })).not.toBeInTheDocument();
|
||||
expect(trashButton()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-037: somebody who may bind the trip gets it once a provider is on, and it opens the panel', async () => {
|
||||
@@ -666,9 +666,8 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const button = await screen.findByTitle('Document sync');
|
||||
expect(button).toHaveStyle({ marginLeft: 'auto' });
|
||||
expect(trashButton().style.marginLeft).toBe('');
|
||||
const button = await screen.findByRole('button', { name: 'Document sync' });
|
||||
expect(trashButton()).toBeInTheDocument();
|
||||
|
||||
await user.click(button);
|
||||
expect(await screen.findByText('Connect a provider')).toBeInTheDocument();
|
||||
@@ -681,7 +680,7 @@ describe('FileManager', () => {
|
||||
http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([{ id: 1, providerId: 'paperless' }])),
|
||||
);
|
||||
const { unmount } = render(<FileManager {...defaultProps} />);
|
||||
expect(await screen.findByTitle('Document sync')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Document sync' })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
useDocSyncOfferStore.setState({ bound: {}, providers: null });
|
||||
@@ -694,7 +693,7 @@ describe('FileManager', () => {
|
||||
);
|
||||
render(<FileManager {...defaultProps} />);
|
||||
await waitFor(() => expect(asked).toContain('links'));
|
||||
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Document sync' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,11 +69,13 @@ describe('AssignModal shell', () => {
|
||||
|
||||
it('FE-W5ASG-002: the backdrop closes the modal but the card swallows the click', () => {
|
||||
render(<AssignModal {...state()} />)
|
||||
const card = screen.getByText('files.assignTitle').closest('div[style*="border-radius: 16px"]') as HTMLElement
|
||||
const card = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.click(card)
|
||||
expect(setAssignFileId).not.toHaveBeenCalled()
|
||||
|
||||
// A press that starts and ends on the backdrop closes it.
|
||||
fireEvent.mouseDown(card.parentElement!)
|
||||
fireEvent.click(card.parentElement!)
|
||||
expect(setAssignFileId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, MapPin, Ticket, Check } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { MapPin, Ticket, Check, Paperclip } from 'lucide-react'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, Day } from '../../types'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { TRANSPORT_TYPES } from './FileManager.constants'
|
||||
import { transportIcon } from './FileManager.helpers'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
|
||||
export function AssignModal(S: FileManagerState) {
|
||||
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
|
||||
return createPortal(
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setAssignFileId(null)}>
|
||||
<div role="presentation" style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px 0' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.noteLabel') || 'Note'}
|
||||
</div>
|
||||
const labelId = useId()
|
||||
const close = () => setAssignFileId(null)
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Paperclip size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
eyebrow={t('files.assignTitle')}
|
||||
title={files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<EditorField label={t('files.noteLabel') || 'Note'}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('files.notePlaceholder')}
|
||||
@@ -42,14 +40,10 @@ export function AssignModal(S: FileManagerState) {
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', padding: 8 }}>
|
||||
</EditorField>
|
||||
<div className="rounded-[16px] bg-surface-secondary p-2">
|
||||
{(() => {
|
||||
const file = files.find(f => f.id === assignFileId)
|
||||
if (!file) return null
|
||||
@@ -211,8 +205,6 @@ export function AssignModal(S: FileManagerState) {
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,7 @@ export function FilesView(S: FileManagerState) {
|
||||
{/* Upload zone */}
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||
}}
|
||||
className={`mx-7 mt-4 cursor-pointer rounded-2xl border-2 border-dashed px-4 py-5 text-center transition-colors max-md:mx-4 ${isDragActive ? 'border-content-muted bg-surface-tertiary' : 'border-edge bg-surface-secondary hover:border-content-faint'}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||
@@ -35,8 +30,9 @@ export function FilesView(S: FileManagerState) {
|
||||
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||
<p style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')}
|
||||
</p>
|
||||
<span className="mt-1.5 inline-block rounded-full bg-surface-card px-2 py-[2px] font-geist font-semibold text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>Max 50 MB</span>
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
@@ -143,10 +143,10 @@ describe('ImageLightbox', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle(/open/i))
|
||||
fireEvent.click(screen.getByRole('button', { name: /open/i }))
|
||||
expect(openFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
|
||||
|
||||
fireEvent.click(screen.getByTitle(/download/i))
|
||||
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||
expect(downloadFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
@@ -7,6 +7,10 @@ import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isVideo } from './FileManager.helpers'
|
||||
import VideoPlayer from '../Journey/VideoPlayerLazy'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
/** The round buttons on the dark backdrop, the same as the note preview's. */
|
||||
const LIGHTBOX_BTN = 'grid h-9 w-9 place-items-center rounded-full bg-[rgba(255,255,255,0.12)] text-[rgba(255,255,255,0.85)] hover:bg-[rgba(255,255,255,0.22)]' // theme-lint-disable: the lightbox is dark in every scheme
|
||||
|
||||
// Image lightbox with gallery navigation
|
||||
interface ImageLightboxProps {
|
||||
@@ -94,21 +98,25 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button type="button"
|
||||
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.download') || 'Download'}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<Tooltip label={t('files.openTab')}>
|
||||
<button type="button"
|
||||
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||
aria-label={t('files.openTab')} className={LIGHTBOX_BTN}>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('files.download') || 'Download'}>
|
||||
<button type="button"
|
||||
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||
aria-label={t('files.download') || 'Download'} className={LIGHTBOX_BTN}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')} className={LIGHTBOX_BTN}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X } from 'lucide-react'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { ExternalLink, Download, FileText } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -8,6 +7,7 @@ import rehypeSanitize from 'rehype-sanitize'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* Inline preview for uploaded Markdown files (#1345). Fetches the file's text via
|
||||
@@ -19,6 +19,9 @@ export function MarkdownPreviewModal(S: FileManagerState) {
|
||||
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
|
||||
const [text, setText] = useState('')
|
||||
const [err, setErr] = useState(false)
|
||||
const labelId = useId()
|
||||
const close = () => setPreviewFile(null)
|
||||
const openInTab = () => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewFileUrl) return
|
||||
@@ -32,43 +35,35 @@ export function MarkdownPreviewModal(S: FileManagerState) {
|
||||
return () => { cancelled = true }
|
||||
}, [previewFileUrl])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
bodyClassName="collab-note-md min-h-0 flex-1 overflow-y-auto px-7 py-6 leading-relaxed text-content [word-break:break-word]"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><FileText size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
title={previewFile.original_name}
|
||||
pills={(
|
||||
<>
|
||||
<button type="button" onClick={openInTab} className={`${PILL} hover:opacity-80`}>
|
||||
<ExternalLink size={13} strokeWidth={2.2} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button type="button" onClick={() => triggerDownload(previewFile.url, previewFile.original_name)} className={`${PILL} hover:opacity-80`}>
|
||||
<Download size={13} strokeWidth={2.2} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button type="button"
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md" style={{ flex: 1, overflowY: 'auto', padding: '20px 28px', color: 'var(--text-primary)', lineHeight: 1.6, wordBreak: 'break-word' }}>
|
||||
{err
|
||||
? <p style={{ color: 'var(--text-muted)' }}>{t('files.openError')}</p>
|
||||
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
{err
|
||||
? <p className="text-content-muted">{t('files.openError')}</p>
|
||||
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,52 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { ExternalLink, Download, FileText } from 'lucide-react'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../shared/DialogShell'
|
||||
|
||||
/** A PDF read in place, in the planner's dialog: the name on the head band, open and download beside it. */
|
||||
export function PdfPreviewModal(S: FileManagerState) {
|
||||
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button type="button"
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
const labelId = useId()
|
||||
const close = () => setPreviewFile(null)
|
||||
const openInTab = () => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><FileText size={20} strokeWidth={1.9} className="text-danger" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button type="button" onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
pills={(
|
||||
<>
|
||||
<button type="button" onClick={openInTab} className={`${PILL} hover:opacity-80`}>
|
||||
<ExternalLink size={13} strokeWidth={2.2} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button type="button" onClick={() => triggerDownload(previewFile.url, previewFile.original_name)} className={`${PILL} hover:opacity-80`}>
|
||||
<Download size={13} strokeWidth={2.2} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<object
|
||||
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
className="h-[74vh] w-full flex-1 border-0"
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p className="p-6 text-center text-content-muted">
|
||||
<button type="button" onClick={openInTab} className="text-content underline">{t('files.downloadPdf')}</button>
|
||||
</p>
|
||||
</object>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,15 +86,16 @@ describe('PdfPreviewModal', () => {
|
||||
|
||||
it('FE-W4FPM-006: the close button and the backdrop clear the preview, the card does not', () => {
|
||||
const { baseElement } = render(<PdfPreviewModal {...state()} />)
|
||||
const backdrop = baseElement.querySelector('div[style*="rgba(0, 0, 0, 0.85)"]') as HTMLElement
|
||||
const backdrop = baseElement.querySelector('.trek-modal-backdrop') as HTMLElement
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(null)
|
||||
|
||||
setPreviewFile.mockClear()
|
||||
fireEvent.click(backdrop.firstElementChild!)
|
||||
fireEvent.click(screen.getByRole('dialog'))
|
||||
expect(setPreviewFile).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.mouseDown(backdrop)
|
||||
fireEvent.click(backdrop)
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
@@ -107,15 +108,13 @@ describe('PdfPreviewModal', () => {
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
|
||||
})
|
||||
|
||||
it('FE-W4FPM-014: the toolbar buttons brighten on hover and dim again', () => {
|
||||
it('FE-W4FPM-014: the head band carries the name, open, download and close', () => {
|
||||
render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
for (const button of screen.getAllByRole('button').slice(0, 3)) {
|
||||
fireEvent.mouseEnter(button)
|
||||
expect(button.style.color).toBe('var(--text-primary)')
|
||||
fireEvent.mouseLeave(button)
|
||||
expect(button.style.color).toBe(button === screen.getAllByRole('button')[2] ? 'var(--text-faint)' : 'var(--text-muted)')
|
||||
}
|
||||
expect(screen.getByRole('dialog', { name: 'ticket.pdf' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /openTab/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'files.download' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -173,7 +172,7 @@ describe('MarkdownPreviewModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/notes.md', 'notes.md')
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(null)
|
||||
await waitFor(() => expect(screen.getByText('hi')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TRANSPORT_TYPES } from './FileManager.constants'
|
||||
import { getFileIcon, isImage, formatSize, formatDateWithLocale, transportIcon, triggerDownload } from './FileManager.helpers'
|
||||
import { AuthedImg } from './FileManagerAuthedImg'
|
||||
import { AvatarChip } from './FileManagerAvatarChip'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { SourceBadge } from './FileManagerSourceBadge'
|
||||
|
||||
export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolean }) {
|
||||
@@ -23,15 +24,8 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
transition: 'border-color 0.12s',
|
||||
opacity: isTrash ? 0.7 : 1,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||
className="group"
|
||||
<div key={file.id} className="group flex items-start gap-2.5 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5 transition-colors hover:border-edge"
|
||||
style={{ opacity: isTrash ? 0.7 : 1 }}
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<button
|
||||
@@ -40,8 +34,8 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
aria-label={file.original_name}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8, padding: 0,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 10, padding: 0,
|
||||
background: 'var(--bg-card)', boxShadow: '0 1px 2px rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
@@ -51,8 +45,8 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
return (
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? 'var(--danger-soft)' : 'var(--bg-card)' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? 'var(--danger)' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
@@ -85,12 +79,12 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
<SourceBadge key={p.id} icon={MapPin} kind={t('files.sourcePlan')} label={p.name} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
TRANSPORT_TYPES.has(r.type)
|
||||
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
? <SourceBadge key={r.id} icon={transportIcon(r.type)} kind={t('files.sourceTransport')} label={r.title || t('files.sourceTransport')} />
|
||||
: <SourceBadge key={r.id} icon={Ticket} kind={t('files.sourceBooking')} label={r.title || t('files.sourceBooking')} />
|
||||
))}
|
||||
{!!file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
@@ -102,40 +96,38 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{isTrash ? (
|
||||
<>
|
||||
{can('file_delete', trip) && <button type="button" onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<RotateCcw size={14} />
|
||||
</button>}
|
||||
{can('file_delete', trip) && <button type="button" onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>}
|
||||
{can('file_delete', trip) && <RowAction label={t('files.restore') || 'Restore'} tone="success" onClick={() => handleRestore(file.id)}><RotateCcw size={14} /></RowAction>}
|
||||
{can('file_delete', trip) && <RowAction label={t('common.delete')} tone="danger" onClick={() => handlePermanentDelete(file.id)}><Trash2 size={14} /></RowAction>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||
</button>
|
||||
{can('file_edit', trip) && <button type="button" onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={14} />
|
||||
</button>}
|
||||
<button type="button" onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
{can('file_delete', trip) && <button type="button" onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>}
|
||||
<RowAction label={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} tone="star" active={!!file.starred} onClick={() => handleStar(file.id)}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} /* theme-lint-disable: the star keeps its gold */ />
|
||||
</RowAction>
|
||||
{can('file_edit', trip) && <RowAction label={t('files.assign') || 'Assign'} onClick={() => setAssignFileId(file.id)}><Pencil size={14} /></RowAction>}
|
||||
<RowAction label={t('common.open')} onClick={() => openFile(file)}><ExternalLink size={14} /></RowAction>
|
||||
<RowAction label={t('files.download') || 'Download'} onClick={() => triggerDownload(file.url, file.original_name)}><Download size={14} /></RowAction>
|
||||
{can('file_delete', trip) && <RowAction label={t('common.delete')} tone="danger" onClick={() => handleDelete(file.id)}><Trash2 size={14} /></RowAction>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STAR_ON = 'text-[#facc15]' // theme-lint-disable: the star keeps its gold
|
||||
const TONE_HOVER = { plain: 'hover:text-content', success: 'hover:text-success', danger: 'hover:text-danger', star: 'hover:text-[#facc15]' } as const // theme-lint-disable: the star keeps its gold
|
||||
|
||||
/** A small action at the end of a file row, named by its tooltip. */
|
||||
function RowAction({ label, onClick, tone = 'plain', active = false, children }: {
|
||||
label: string; onClick: () => void; tone?: keyof typeof TONE_HOVER; active?: boolean; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button type="button" onClick={onClick} aria-label={label}
|
||||
className={`flex rounded-md p-1.5 transition-colors ${active ? STAR_ON : 'text-content-faint'} ${TONE_HOVER[tone]}`}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Source badge
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
/** What the badge links to (plan, transport, booking); its tooltip. */
|
||||
kind?: string
|
||||
}
|
||||
|
||||
export function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: '#4b5563',
|
||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 6, padding: '2px 7px',
|
||||
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
||||
}}>
|
||||
/** Where a file is attached: a place, a booking, a note. */
|
||||
export function SourceBadge({ icon: Icon, label, kind }: SourceBadgeProps) {
|
||||
const badge = (
|
||||
<span className="inline-flex max-w-full items-center gap-1 overflow-hidden rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-medium text-content-secondary"
|
||||
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>
|
||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
return kind ? <Tooltip label={kind}>{badge}</Tooltip> : badge
|
||||
}
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { FolderSync, Star, Trash2 } from 'lucide-react'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
/** A 36 px icon control on the bar; open, it is raised like the active filter tab beside it. */
|
||||
const BAR_BTN = 'relative grid h-9 w-9 flex-none place-items-center rounded-[10px]'
|
||||
const BODY = { fontSize: 'calc(13px * var(--fs-scale-body, 1))' }
|
||||
|
||||
/**
|
||||
* The bar on top of Files, in the shape the other trip tabs open with: the
|
||||
* tab's name, the kinds of file to narrow to, and the tools on the right.
|
||||
*/
|
||||
export function FileManagerToolbar(S: FileManagerState) {
|
||||
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync, docSyncOffered } = S
|
||||
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync, docSyncOffered, trashFiles, can, trip, handleEmptyTrash } = S
|
||||
const trashLabel = t('files.trash') || 'Trash'
|
||||
return (
|
||||
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||
<div className="flex-none px-7 pt-6 max-md:px-4 max-md:pt-4">
|
||||
<div className="flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 pl-[22px] pr-3">
|
||||
<h2 className="m-0 shrink-0 text-subtitle font-semibold tracking-[-0.01em] text-content">
|
||||
{showTrash ? trashLabel : t('files.title')}
|
||||
</h2>
|
||||
|
||||
{!showTrash && (
|
||||
<>
|
||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
<div className="mx-1.5 hidden h-[22px] w-px shrink-0 bg-edge-faint md:block" />
|
||||
<div className="hidden min-w-0 flex-1 flex-wrap gap-1 md:inline-flex">
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
||||
@@ -36,64 +42,51 @@ export function FileManagerToolbar(S: FileManagerState) {
|
||||
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
||||
: 0
|
||||
return (
|
||||
<button type="button" key={tab.id} onClick={() => setFilterType(tab.id)}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
<button type="button" key={tab.id} onClick={() => setFilterType(tab.id)} aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-3 py-1.5 ${active ? 'bg-surface-card font-medium text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={BODY}
|
||||
>
|
||||
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
||||
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> /* theme-lint-disable: the star keeps its gold */ : null}
|
||||
{'label' in tab && tab.label}
|
||||
<span style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{count}</span>
|
||||
<span className="min-w-4 rounded-full bg-surface-tertiary px-1.5 text-center font-geist font-bold text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showTrash && <span className="flex-1" />}
|
||||
|
||||
{/* Opens the sync panel. Placed next to the trash rather than in a menu
|
||||
because the question it answers, "where do these documents live",
|
||||
belongs on the same screen as the documents. Only where there is
|
||||
something behind it: see useDocSyncOffered. */}
|
||||
{docSyncOffered && (
|
||||
<button type="button" onClick={() => setShowDocSync(true)} title={t('docsync.title')} style={{
|
||||
appearance: 'none', border: '1px solid var(--edge)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 12px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
background: 'transparent', color: 'var(--content-muted)', flexShrink: 0, marginLeft: 'auto',
|
||||
}}>
|
||||
<FolderSync size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
|
||||
<Tooltip label={t('docsync.title')}>
|
||||
<button type="button" onClick={() => setShowDocSync(true)} aria-label={t('docsync.title')}
|
||||
className="inline-flex h-9 flex-none items-center gap-1.5 rounded-[10px] bg-surface-card px-3 font-medium text-content-secondary hover:text-content" style={BODY}>
|
||||
<FolderSync size={14} strokeWidth={2.2} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Emptying the trash is the one thing to do in it, so it sits on the bar. */}
|
||||
{showTrash && trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<button type="button" onClick={handleEmptyTrash}
|
||||
className="inline-flex h-9 flex-none items-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 font-medium text-danger hover:opacity-90" style={BODY}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
{t('files.emptyTrash') || 'Empty Trash'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The first of the two takes the free space, so without the sync
|
||||
button the trash sits flush right the way it always has. */}
|
||||
<button type="button" onClick={toggleTrash} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
flexShrink: 0, marginLeft: docSyncOffered ? undefined : 'auto',
|
||||
opacity: showTrash ? 1 : 0.88,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
||||
</button>
|
||||
{/* The trash is a place to look, not the tab's main action: an icon on
|
||||
the bar, raised while it is open. */}
|
||||
<Tooltip label={trashLabel}>
|
||||
<button type="button" onClick={toggleTrash} aria-label={trashLabel} aria-pressed={showTrash}
|
||||
className={`${BAR_BTN} ${showTrash ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,20 +3,9 @@ import type { FileManagerState } from './useFileManager'
|
||||
import { FileRow } from './FileManagerRow'
|
||||
|
||||
export function TrashView(S: FileManagerState) {
|
||||
const { trashFiles, can, trip, handleEmptyTrash, loadingTrash, t } = S
|
||||
const { trashFiles, loadingTrash, t } = S
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button type="button" onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
background: '#fef2f2', color: '#dc2626', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('files.emptyTrash') || 'Empty Trash'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto px-7 pb-4 pt-4 max-md:px-4">
|
||||
{loadingTrash ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('DocSyncBinding: what it says about itself', () => {
|
||||
renderCard()
|
||||
|
||||
expect(screen.queryByText('In sync')).not.toBeInTheDocument()
|
||||
const dot = screen.getByTitle('In sync')
|
||||
const dot = screen.getByRole('img', { name: 'In sync' })
|
||||
expect(dot).toBeInTheDocument()
|
||||
expect(dot).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
|
||||
/**
|
||||
* The small shared pieces of the document-sync dialog.
|
||||
@@ -32,15 +33,13 @@ export function Badge({
|
||||
children: ReactNode
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-caption font-medium ${TONES[tone]}`}
|
||||
>
|
||||
const pill = (
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-caption font-medium ${TONES[tone]}`}>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return title ? <Tooltip label={title}>{pill}</Tooltip> : pill
|
||||
}
|
||||
|
||||
/** The tone each sync state carries, in one place. */
|
||||
@@ -66,7 +65,14 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
|
||||
: tone === 'danger' ? 'bg-danger'
|
||||
: 'bg-content-faint'
|
||||
|
||||
if (compact) return <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} title={t(`docsync.linkState.${state}`)} />
|
||||
if (compact) {
|
||||
const label = t(`docsync.linkState.${state}`)
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span role="img" aria-label={label} className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge tone={tone} icon={<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />}>
|
||||
@@ -81,7 +87,7 @@ export function LastRun({ at }: { at: string | null }) {
|
||||
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
|
||||
const ms = Date.parse(at)
|
||||
if (Number.isNaN(ms)) return null
|
||||
return <span title={new Date(ms).toLocaleString(locale)}>{relativeTime(ms, language)}</span>
|
||||
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, ShieldAlert } from 'lucide-react'
|
||||
import Modal from '../../shared/Modal'
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
@@ -35,46 +35,38 @@ export default function DocSyncConnectModal({
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
|
||||
const save = async () => {
|
||||
if (await form.save()) onConnected(provider.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
{Icon && <Icon className="h-5 w-5 text-content" />}
|
||||
<span>{provider.name}</span>
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
labelledBy={labelId}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={provider.name}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void test()}
|
||||
disabled={!form.complete || sync.busy === 'test'}
|
||||
className="rounded-lg border border-edge px-3.5 py-2 text-body text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
{t('docsync.test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={!form.complete || sync.busy === 'save'}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{sync.busy === 'save' && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('docsync.connect.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
|
||||
{t('docsync.test')}
|
||||
</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => void save()} disabled={!form.complete || sync.busy === 'save'}
|
||||
icon={sync.busy === 'save' ? <Loader2 size={14} className="animate-spin" /> : undefined}>
|
||||
{t('docsync.connect.submit')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
|
||||
@@ -108,7 +100,7 @@ export default function DocSyncConnectModal({
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, ArrowRight, Check, Loader2, Plus } from 'lucide-react'
|
||||
import Modal from '../../shared/Modal'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { AlertCircle, ArrowRight, Check, FolderSync, Loader2, Plus } from 'lucide-react'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { StateBadge } from './DocSyncBits'
|
||||
@@ -62,21 +62,26 @@ export default function DocSyncPanel({
|
||||
}, [sync.links])
|
||||
|
||||
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
|
||||
const labelId = useId()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
size="4xl"
|
||||
title={
|
||||
<span className="flex flex-col">
|
||||
<span>{t('docsync.title')}</span>
|
||||
{tripTitle && (
|
||||
<span className="mt-0.5 truncate text-caption font-normal text-content-muted">{tripTitle}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
// A connect or scope question opened from here takes Escape for itself.
|
||||
blocked={!!connecting || !!scopeFor || !!reconnecting}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><FolderSync size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('docsync.title')}
|
||||
sub={tripTitle || undefined}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{sync.loading ? (
|
||||
<div className="grid place-items-center py-20">
|
||||
@@ -127,7 +132,7 @@ export default function DocSyncPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
|
||||
{connecting && (
|
||||
<DocSyncConnectModal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { FolderPlus, Loader2, Search } from 'lucide-react'
|
||||
import Modal from '../../shared/Modal'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync'
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function DocSyncScopeModal({
|
||||
onBound: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -92,7 +93,20 @@ export default function DocSyncScopeModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} size="md" title={t('docsync.scope.title', { provider: providerName })}>
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><FolderPlus size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('docsync.scope.title', { provider: providerName })}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
|
||||
|
||||
@@ -176,6 +190,6 @@ export default function DocSyncScopeModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -572,7 +572,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
@@ -717,8 +717,8 @@ const MemoMarker = memo(function MemoMarker({
|
||||
},
|
||||
remove: () => { cleanupRef.current?.(); cleanupRef.current = null },
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseover: (e: any) => onHover({ ...place, photo: placePhotoFull(place) }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover({ ...place, photo: placePhotoFull(place) }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
@@ -1009,9 +1009,7 @@ export const MapView = memo(function MapView({
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = photoCacheKey(place)
|
||||
// A custom uploaded image wins over the auto-fetched thumb; otherwise fall back.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const photoUrl = placePhotoUrl(place, photoUrls)
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
return (
|
||||
<MemoMarker
|
||||
@@ -1369,6 +1367,7 @@ export const MapView = memo(function MapView({
|
||||
categoryColor={hoveredPlace.category_color}
|
||||
address={hoveredPlace.address}
|
||||
rating={hoveredPlace.rating_avg}
|
||||
photo={hoveredPlace.photo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
@@ -1849,9 +1849,7 @@ export function MapViewGL({
|
||||
|
||||
visiblePlaces.forEach(place => {
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = photoCacheKey(place)
|
||||
// A custom image wins over the auto-fetched thumb; otherwise fall back to it.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const photoUrl = placePhotoUrl(place, photoUrls)
|
||||
const selected = place.id === selectedPlaceId
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
|
||||
@@ -1870,7 +1868,7 @@ export function MapViewGL({
|
||||
el.addEventListener('mouseenter', (ev) => {
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
hoverIdRef.current = place.id
|
||||
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
|
||||
setHoverPlace({ ...(place as Place & { category_color?: string; category_icon?: string; category_name?: string }), photo: placePhotoFull(place) })
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mousemove', (ev) => {
|
||||
@@ -2447,7 +2445,8 @@ export function MapViewGL({
|
||||
categoryIcon={hoverPlace.category_icon}
|
||||
categoryColor={hoverPlace.category_color}
|
||||
address={hoverPlace.address}
|
||||
rating={hoverPlace.rating_avg}
|
||||
rating={hoverPlace.rating_avg}
|
||||
photo={hoverPlace.photo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Star } from 'lucide-react'
|
||||
import { MapPin, Star } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { tintOf } from '../Planner/planParts'
|
||||
|
||||
/**
|
||||
* What a place is, shown at the cursor while the pointer rests on its marker.
|
||||
@@ -24,48 +26,41 @@ export interface PlaceHoverCardProps {
|
||||
address?: string | null
|
||||
/** Average across everyone who rated it, when the surface tracks ratings. */
|
||||
rating?: number | null
|
||||
/** The picture its marker wears; the card shows it in place of the category tile. */
|
||||
photo?: string | null
|
||||
}
|
||||
|
||||
export default function PlaceHoverCard({ x, y, name, categoryName, categoryIcon, categoryColor, address, rating }: PlaceHoverCardProps): React.ReactElement {
|
||||
const CatIcon = categoryName ? getCategoryIcon(categoryIcon) : null
|
||||
export default function PlaceHoverCard({ x, y, name, categoryName, categoryIcon, categoryColor, address, rating, photo }: PlaceHoverCardProps): React.ReactElement {
|
||||
// The tile wears the category the way the planner's rows do; without one it is a quiet pin.
|
||||
const CatIcon = categoryName ? getCategoryIcon(categoryIcon) : MapPin
|
||||
const tone = categoryName ? categoryColor || 'var(--text-muted)' : null
|
||||
return (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: x + 14,
|
||||
top: y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'var(--font-system)',
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{name}
|
||||
<div data-testid="tooltip"
|
||||
className="flex max-w-[300px] items-center gap-3 rounded-[18px] border border-edge-faint bg-surface-card p-1.5 pr-4 font-system shadow-popover"
|
||||
style={{ position: 'fixed', left: x + 14, top: y - 10, zIndex: 9999, pointerEvents: 'none' }}>
|
||||
{/* Twelve inside eighteen with six of padding: the picture's corners follow the card's. */}
|
||||
{photo ? (
|
||||
<img src={photo} alt="" className="block h-14 w-14 flex-none rounded-[12px] bg-surface-tertiary object-cover" />
|
||||
) : (
|
||||
<span className="grid h-14 w-14 flex-none place-items-center rounded-[12px]"
|
||||
style={{ background: tone ? tintOf(tone, 14) : 'var(--bg-tertiary)' }}>
|
||||
<CatIcon size={18} strokeWidth={2} style={{ color: tone || 'var(--text-faint)' }} />
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(13, 'body')}>{name}</span>
|
||||
{typeof rating === 'number' && rating > 0 && (
|
||||
<span className="inline-flex flex-none items-center gap-0.5 font-geist tabular-nums text-content-secondary" style={fs(11)}>
|
||||
<Star size={10} strokeWidth={2} className="fill-current text-warning" aria-hidden />
|
||||
{/* One decimal only when it earns it: "4" reads faster than "4.0". */}
|
||||
{Number.isInteger(rating) ? rating : rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof rating === 'number' && rating > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, flexShrink: 0, fontSize: 11, fontWeight: 600, color: '#111827' }}>
|
||||
<Star size={10} style={{ fill: '#f59e0b', color: '#f59e0b' }} aria-hidden />
|
||||
{/* One decimal only when it earns it: "4" reads faster than "4.0". */}
|
||||
{Number.isInteger(rating) ? rating : rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{categoryName && <span className="truncate text-content-muted" style={fs(11)}>{categoryName}</span>}
|
||||
{address && <span className="truncate text-content-faint" style={fs(11)}>{address}</span>}
|
||||
</div>
|
||||
{categoryName && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: categoryColor || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{categoryName}</span>
|
||||
</div>
|
||||
)}
|
||||
{address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ export interface MapHoverInfo {
|
||||
/** Everyone's average, where the surface keeps ratings. On the collections map it
|
||||
* is the one thing a round photo cannot tell you: which of these you liked. */
|
||||
rating_avg?: number | null
|
||||
/** The picture its marker wears, so the hover card can show the same one. */
|
||||
photo?: string | null
|
||||
routeVia?: RouteVia
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { getCached } from '../../services/photoService'
|
||||
|
||||
const PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'
|
||||
|
||||
@@ -49,6 +50,30 @@ export function photoSourcesKey(places: Array<PhotoSource & { id: number }>): st
|
||||
return places.map(p => `${p.id}:${isCustomPlaceImage(p.image_url) ? 'own' : photoCacheKey(p)}`).join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture a place wears on the map: a custom uploaded image wins over the
|
||||
* auto-fetched thumb, which wins over whatever image_url says. The marker and the
|
||||
* hover card both ask this, so they never show two different pictures.
|
||||
*/
|
||||
export function placePhotoUrl(place: PhotoSource, thumbs: Record<string, string>): string | null {
|
||||
if (isCustomPlaceImage(place.image_url)) return place.image_url!
|
||||
const key = photoCacheKey(place)
|
||||
return (key && thumbs[key]) || place.image_url || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same picture at full size, for the hover card. The marker's thumb is a 48px
|
||||
* circle cut for a round pin, which blurs and keeps its round cut-out when it is
|
||||
* shown any larger, so the card asks the photo cache for the picture it came from.
|
||||
*/
|
||||
export function placePhotoFull(place: PhotoSource): string | null {
|
||||
if (isCustomPlaceImage(place.image_url)) return place.image_url!
|
||||
const key = photoCacheKey(place)
|
||||
const full = key ? getCached(key)?.photoUrl : null
|
||||
if (full) return full
|
||||
return place.image_url?.startsWith(PHOTO_PROXY_PREFIX) ? place.image_url : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture inside a round place marker, for both the Leaflet and the GL map.
|
||||
*
|
||||
|
||||
@@ -901,8 +901,13 @@ describe('PackingListPanel', () => {
|
||||
|
||||
// Picker is open - find "Trolley" button inside the dropdown. The bag
|
||||
// sidebar carries the same name on its own button, so scope the query to
|
||||
// the picker (the dropdown is a sibling of the package button).
|
||||
const trolleyBtn = await within(packageBtn!.parentElement!).findByRole('button', { name: /Trolley/ });
|
||||
// the picker, which opens in a portal on the body.
|
||||
const picker = await waitFor(() => {
|
||||
const el = document.body.querySelector<HTMLElement>(':scope > .trek-menu-enter');
|
||||
if (!el) throw new Error('bag picker not open yet');
|
||||
return el;
|
||||
});
|
||||
const trolleyBtn = await within(picker).findByRole('button', { name: /Trolley/ });
|
||||
fireEvent.click(trolleyBtn);
|
||||
|
||||
await waitFor(() => expect(putBody).toMatchObject({ bag_id: 7 }));
|
||||
@@ -934,10 +939,10 @@ describe('PackingListPanel', () => {
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
// Click on the backdrop (the outer div that closes the modal)
|
||||
// The backdrop div has no specific identifier so we use the document.body portal
|
||||
const backdrop = document.querySelector('[style*="backdrop-filter"]') as HTMLElement;
|
||||
// A press that starts and ends on the backdrop closes the dialog.
|
||||
const backdrop = document.querySelector('.trek-modal-backdrop') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.mouseDown(backdrop!);
|
||||
fireEvent.click(backdrop!);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument());
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, useDismissOnOutside } from './packingPopoverStyles'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
interface BagCardProps {
|
||||
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
|
||||
@@ -17,8 +19,11 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
const [nameVal, setNameVal] = useState(bag.name)
|
||||
const [showUserPicker, setShowUserPicker] = useState(false)
|
||||
const membersRef = useRef<HTMLDivElement>(null)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const closePicker = useCallback(() => setShowUserPicker(false), [])
|
||||
useDismissOnOutside(membersRef, showUserPicker, closePicker)
|
||||
useDismissOnOutside(membersRef, showUserPicker, closePicker, pickerRef)
|
||||
// The bag sidebar scrolls and would clip the list, so it hangs off the body under the faces.
|
||||
const pickerBox = useAnchoredPosition(membersRef, showUserPicker, { estimatedHeight: 260, offset: 6 })
|
||||
useEffect(() => setNameVal(bag.name), [bag.name])
|
||||
|
||||
const saveName = () => {
|
||||
@@ -92,8 +97,12 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
<Plus size={sz.avatar * 0.5} />
|
||||
</button>
|
||||
)}
|
||||
{showUserPicker && (
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50, width: 200 }}>
|
||||
{showUserPicker && pickerBox && createPortal(
|
||||
<div ref={pickerRef} className="trek-menu-enter" style={{
|
||||
...POPOVER, position: 'fixed', zIndex: 'var(--z-toast)', width: 200, overflowY: 'auto',
|
||||
top: pickerBox.top, bottom: pickerBox.bottom, maxHeight: pickerBox.maxHeight,
|
||||
left: Math.max(8, pickerBox.left + pickerBox.width - 200),
|
||||
}}>
|
||||
<div style={POPOVER_CAPTION}>{t('packing.assignMembers')}</div>
|
||||
{tripMembers.map(m => (
|
||||
<PopoverItem key={m.id} label={m.username} active={memberIds.includes(m.id)} onClick={() => toggleMember(m.id)}
|
||||
@@ -106,7 +115,8 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
)} />
|
||||
))}
|
||||
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
{canEdit && <button type="button" onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
CheckCheck, Luggage, Package, FolderPlus, Download, Trash2,
|
||||
CheckCheck, Luggage, Package, FolderPlus, Download, Trash2, ListPlus, BookmarkPlus,
|
||||
} from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import NameDialog from '../shared/NameDialog'
|
||||
@@ -202,6 +202,7 @@ export function PackingHeader(S: PackingState) {
|
||||
<NameDialog
|
||||
open={canEdit && addingCategory}
|
||||
title={t('packing.addCategory')}
|
||||
icon={ListPlus}
|
||||
placeholder={t('packing.newCategoryPlaceholder')}
|
||||
confirmLabel={t('common.add')}
|
||||
value={newCatName}
|
||||
@@ -212,6 +213,7 @@ export function PackingHeader(S: PackingState) {
|
||||
<NameDialog
|
||||
open={canEdit && isAdmin && items.length > 0 && showSaveTemplate}
|
||||
title={t('packing.saveAsTemplate')}
|
||||
icon={BookmarkPlus}
|
||||
placeholder={t('packing.templateName')}
|
||||
confirmLabel={t('common.save')}
|
||||
value={saveTemplateName}
|
||||
|
||||
@@ -1,75 +1,62 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { Download, FileDown } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { PACKING_IMPORT_ACCEPT } from './packingListPanel.constants'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
/** Pasting a list or loading a file: one item per line, numbered as it is typed. */
|
||||
export function BulkImportModal(S: PackingState) {
|
||||
const { setShowImportModal, t, importText, setImportText, csvInputRef, handleCsvFile, handleBulkImport, parseImportLines } = S
|
||||
return createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowImportModal(false) }}>
|
||||
<div style={{
|
||||
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', lineHeight: 1.5 }}>
|
||||
{t('packing.importHint')}
|
||||
<br />
|
||||
{t('packing.importHintMarkdown')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
|
||||
<div style={{
|
||||
padding: '10px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', lineHeight: 1.5,
|
||||
color: 'var(--text-faint)', textAlign: 'right', userSelect: 'none',
|
||||
background: 'var(--bg-hover)', borderRight: '1px solid var(--border-faint)',
|
||||
minWidth: 32, flexShrink: 0,
|
||||
}}>
|
||||
{(importText || ' ').split('\n').map((_, i) => (
|
||||
<div key={i} style={{ padding: '0 6px' }}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
style={{
|
||||
flex: 1, border: 'none', padding: '10px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||
background: 'transparent', resize: 'vertical', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<input ref={csvInputRef} type="file" accept={PACKING_IMPORT_ACCEPT} style={{ display: 'none' }} onChange={handleCsvFile} />
|
||||
<button type="button" onClick={() => csvInputRef.current?.click()} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<FileDown size={11} /> {t('packing.importCsv')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => setShowImportModal(false)} style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={handleBulkImport} disabled={!importText.trim()} style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
|
||||
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
|
||||
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
|
||||
</div>
|
||||
const labelId = useId()
|
||||
const close = () => setShowImportModal(false)
|
||||
const text = fs(13, 'body')
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Download size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
title={t('packing.importTitle')}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<input ref={csvInputRef} type="file" accept={PACKING_IMPORT_ACCEPT} className="hidden" onChange={handleCsvFile} />
|
||||
<DialogButton icon={<FileDown size={14} strokeWidth={2} />} onClick={() => csvInputRef.current?.click()}>{t('packing.importCsv')}</DialogButton>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={handleBulkImport} disabled={!importText.trim()}>
|
||||
{t('packing.importAction', { count: parseImportLines(importText).length })}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<p className="m-0 leading-normal text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('packing.importHint')}
|
||||
<br />
|
||||
{t('packing.importHintMarkdown')}
|
||||
</p>
|
||||
<div className="flex overflow-hidden rounded-[10px] border border-edge bg-surface-input focus-within:ring-2 focus-within:ring-[color:var(--text-primary)]">
|
||||
<div aria-hidden className="min-w-8 flex-none select-none border-r border-edge-faint bg-surface-tertiary py-2.5 text-right font-mono leading-normal text-content-faint" style={text}>
|
||||
{(importText || ' ').split('\n').map((_, i) => (
|
||||
<div key={i} className="px-1.5">{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
aria-label={t('packing.importTitle')}
|
||||
className="min-w-0 flex-1 resize-y border-0 bg-transparent px-3 py-2.5 font-mono leading-normal text-content outline-none placeholder:text-content-faint dark:bg-transparent"
|
||||
style={text}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,8 @@ describe('ArtikelZeile — basics', () => {
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(screen.getByRole('button', { name: 'Trolley' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('div[role="presentation"]')!)
|
||||
// The picker and its click catcher open in a portal on the body.
|
||||
fireEvent.click(Array.from(document.body.children).find(el => el.getAttribute('role') === 'presentation')!)
|
||||
expect(screen.queryByRole('button', { name: 'Trolley' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRef, useState, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -55,6 +57,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
// that only shows on hover is also there for someone tabbing through.
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||
// The picker opens in a portal: inside the category card it was cut off by the card's edge.
|
||||
const bagBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const bagBox = useAnchoredPosition(bagBtnRef, showBagPicker, { estimatedHeight: 260, offset: 6 })
|
||||
const [showItemMenu, setShowItemMenu] = useState(false)
|
||||
const [showMenuCategories, setShowMenuCategories] = useState(false)
|
||||
const [bagInlineCreate, setBagInlineCreate] = useState(false)
|
||||
@@ -248,6 +253,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button"
|
||||
ref={bagBtnRef}
|
||||
onClick={() => canEdit && setShowBagPicker(p => !p)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -258,10 +264,14 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
>
|
||||
{!item.bag_id && <Package size={9} className="text-content-faint" />}
|
||||
</button>
|
||||
{showBagPicker && (
|
||||
{showBagPicker && bagBox && createPortal(
|
||||
<>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 49 }} onClick={() => { setShowBagPicker(false); setBagInlineCreate(false); setBagInlineName('') }} />
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50, width: 210 }}>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 'var(--z-toast)' }} onClick={() => { setShowBagPicker(false); setBagInlineCreate(false); setBagInlineName('') }} />
|
||||
<div className="trek-menu-enter" style={{
|
||||
...POPOVER, position: 'fixed', zIndex: 'var(--z-toast)', width: 210, overflowY: 'auto',
|
||||
top: bagBox.top, bottom: bagBox.bottom, maxHeight: bagBox.maxHeight,
|
||||
left: Math.max(8, bagBox.left + bagBox.width - 210),
|
||||
}}>
|
||||
<div style={POPOVER_CAPTION}>{t('packing.bags')}</div>
|
||||
{item.bag_id && (
|
||||
<PopoverItem icon={<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', display: 'inline-block' }} />} label={t('packing.noBag')}
|
||||
@@ -300,7 +310,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<PopoverItem icon={<Plus size={13} />} label={t('packing.addBag')} muted onClick={() => setBagInlineCreate(true)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -34,15 +34,17 @@ export const composerConfirm = (enabled: boolean): CSSProperties => ({
|
||||
* is mounted but not displayed (the bag sidebar below its breakpoint) has no
|
||||
* layout box, and must not close the visible copy's twin.
|
||||
*/
|
||||
export function useDismissOnOutside(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
|
||||
export function useDismissOnOutside(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void, panelRef?: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const el = ref.current
|
||||
if (!el || el.getClientRects().length === 0 || el.contains(e.target as Node)) return
|
||||
// A panel portalled to the body counts as inside too.
|
||||
if (panelRef?.current?.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [ref, open, onClose])
|
||||
}, [ref, open, onClose, panelRef])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-019
|
||||
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-022
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -254,3 +254,37 @@ describe('BookingCostsSection', () => {
|
||||
expect(screen.getByRole('button', { name: /Tram pass/ })).toHaveTextContent(money(9, 'EUR'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingCostsSection with white buttons', () => {
|
||||
it('FE-PLANNER-COSTSEC-020: whiteButtons draws the create button and the expense rows white', () => {
|
||||
renderSection({ whiteButtons: true });
|
||||
const create = screen.getByRole('button', { name: /Create expense/i });
|
||||
expect(create).toHaveClass('bg-surface-card');
|
||||
expect(create).not.toHaveClass('bg-surface-secondary');
|
||||
expect(rowOf('Flight LH 400')).toHaveClass('bg-surface-card');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-021: without it the block keeps its grey fill', () => {
|
||||
renderSection();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toHaveClass('bg-surface-secondary');
|
||||
expect(rowOf('Flight LH 400')).toHaveClass('bg-surface-secondary');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-022: the row buttons turn white on a hairline too, and only with it', () => {
|
||||
const handlers = { onCreate: vi.fn(), onEdit: vi.fn(), onRemove: vi.fn() };
|
||||
const white = render(<BookingCostsSection reservationId={9} {...handlers} whiteButtons />);
|
||||
const buttons = within(rowOf('Flight LH 400')).getAllByRole('button');
|
||||
expect(buttons).toHaveLength(3);
|
||||
for (const button of buttons) {
|
||||
expect(button).toHaveClass('bg-surface-card', 'shadow-sm', 'ring-edge-faint');
|
||||
expect(button).not.toHaveClass('border-edge');
|
||||
}
|
||||
white.unmount();
|
||||
|
||||
renderSection();
|
||||
for (const button of within(rowOf('Flight LH 400')).getAllByRole('button')) {
|
||||
expect(button).toHaveClass('border-edge');
|
||||
expect(button).not.toHaveClass('shadow-sm');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Plus, Pencil, Trash2, Link2Off } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { catMeta } from '../Budget/costsCategories'
|
||||
import { useExpenseLinks } from './useRecordLinks'
|
||||
@@ -22,7 +25,7 @@ import type { BudgetItem } from '../../types'
|
||||
* Exactly one of reservationId / placeId is set — they are the two sides of the
|
||||
* same link, and the block behaves identically on both.
|
||||
*/
|
||||
export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: {
|
||||
export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove, labelClassName, customTooltips = false, whiteButtons = false }: {
|
||||
reservationId: number | null
|
||||
/** Set instead of reservationId when the block sits in the place form (#1298). */
|
||||
placeId?: number | null
|
||||
@@ -33,6 +36,15 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
onRemove: (item: BudgetItem) => void
|
||||
/** Replaces the block's own label style, so a host form can match its other fields. */
|
||||
labelClassName?: string
|
||||
/** Shows the row buttons' hints in the shared Tooltip instead of the native title. */
|
||||
customTooltips?: boolean
|
||||
/**
|
||||
* Draws the create button and the expense rows white on a hairline, the look
|
||||
* of the dialog frame's secondary buttons, instead of the grey fill.
|
||||
*/
|
||||
whiteButtons?: boolean
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const tripCurrency = useTripStore(s => s.trip?.currency)
|
||||
@@ -44,12 +56,24 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
const ownCurrency = (currency: string | null | undefined) => currency || tripCurrency || base
|
||||
const { targetId, linked, unlinked, link, unlink } = useExpenseLinks(reservationId, placeId)
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
const rowCls = 'bg-surface-secondary border border-edge'
|
||||
const labelCls = labelClassName ?? 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
const rowCls = whiteButtons ? 'bg-surface-card border border-edge-faint' : 'bg-surface-secondary border border-edge'
|
||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 } as const
|
||||
const iconButton = 'text-content-muted border border-edge bg-surface-card transition-colors hover:bg-surface-tertiary hover:text-content'
|
||||
const deleteButton = 'text-content-muted border border-edge bg-surface-card transition-colors hover:bg-danger-soft hover:text-danger'
|
||||
// White, the row's buttons trade the border for the hairline ring and shadow of
|
||||
// the create button under them; the border stays, transparent, to keep their size.
|
||||
const iconButton = whiteButtons
|
||||
? 'border border-transparent bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:bg-surface-hover hover:text-content'
|
||||
: 'text-content-muted border border-edge bg-surface-card transition-colors hover:bg-surface-tertiary hover:text-content'
|
||||
const deleteButton = whiteButtons
|
||||
? 'border border-transparent bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:bg-danger-soft hover:text-danger'
|
||||
: 'text-content-muted border border-edge bg-surface-card transition-colors hover:bg-danger-soft hover:text-danger'
|
||||
const iconButtonStyle = { display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' } as const
|
||||
const rowButton = (label: string, className: string, onClick: () => void, icon: ReactNode) => {
|
||||
const button = (
|
||||
<button type="button" onClick={onClick} title={customTooltips ? undefined : label} aria-label={label} className={className} style={iconButtonStyle}>{icon}</button>
|
||||
)
|
||||
return customTooltips ? <Tooltip label={label}>{button}</Tooltip> : button
|
||||
}
|
||||
|
||||
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
|
||||
if (linked.length === 0 && pendingExpense && pendingExpense.total_price > 0) {
|
||||
@@ -61,10 +85,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className={rowCls} style={rowStyle}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
|
||||
<div className="text-content" style={{ ...fs(14, 'body'), fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={fs(12, 'body')}>{t(hintKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
|
||||
<span className="text-content" style={{ ...fs(14, 'body'), fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -83,13 +107,13 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div key={item.id} className={rowCls} style={rowStyle}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content" style={{ ...fs(14, 'body'), fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</div>
|
||||
<div className="text-content-faint" style={fs(12, 'body')}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, ownCurrency(item.currency), locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(item)} title={t('common.edit')} aria-label={t('common.edit')} className={iconButton} style={iconButtonStyle}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => void unlink(item)} title={t('reservations.unlinkExpense')} aria-label={t('reservations.unlinkExpense')} className={iconButton} style={iconButtonStyle}><Link2Off size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(item)} title={t('reservations.removeExpense')} aria-label={t('reservations.removeExpense')} className={deleteButton} style={iconButtonStyle}><Trash2 size={13} /></button>
|
||||
<span className="text-content" style={{ ...fs(14, 'body'), fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, ownCurrency(item.currency), locale)}</span>
|
||||
{rowButton(t('common.edit'), iconButton, () => onEdit(item), <Pencil size={13} />)}
|
||||
{rowButton(t('reservations.unlinkExpense'), iconButton, () => void unlink(item), <Link2Off size={13} />)}
|
||||
{rowButton(t('reservations.removeExpense'), deleteButton, () => onRemove(item), <Trash2 size={13} />)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -98,9 +122,12 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: targetId ? '1fr 1fr' : '1fr', gap: 8 }}>
|
||||
<button type="button" onClick={onCreate}
|
||||
className="bg-surface-secondary border border-edge text-content transition-colors hover:bg-surface-tertiary"
|
||||
className={whiteButtons
|
||||
// The transparent border keeps it the height of the bordered select beside it.
|
||||
? 'border border-transparent bg-surface-card text-content shadow-sm ring-1 ring-edge-faint transition-colors hover:bg-surface-hover'
|
||||
: 'bg-surface-secondary border border-edge text-content transition-colors hover:bg-surface-tertiary'}
|
||||
// Sized like the select beside it, so the two sit as one row.
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '8px 13px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '8px 13px', borderRadius: 10, ...fs(13, 'body'), fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={14} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
{/* Linking needs the record to exist, so it waits for the first save. */}
|
||||
@@ -128,7 +155,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
)}
|
||||
</div>
|
||||
{linked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
|
||||
<div className="text-content-faint" style={{ ...fs(11), marginTop: 6 }}>{t(hintKey)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { CalendarPlus, Plus } from 'lucide-react'
|
||||
import { DialogButton, FooterSpacer, fs } from '../shared/DialogShell'
|
||||
import { dayDate } from '../../utils/dayLabel'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
|
||||
@@ -12,23 +13,23 @@ interface DayAddFooterProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
/** One button size for the whole foot of the reorder dialog, the delete question's included. */
|
||||
export const FOOTER_BTN =
|
||||
'inline-flex min-h-9 flex-shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-body font-medium transition-colors disabled:cursor-default disabled:opacity-40'
|
||||
/** Cancel, Close and the quiet add: one outline, one text colour, so none of them looks switched off beside another. */
|
||||
export const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover hover:text-content disabled:hover:bg-transparent`
|
||||
const CLOSE_BTN = `${SECONDARY_BTN} ml-auto`
|
||||
/** The add that matters most: drawn in the accent, not filled with it, so it leads without weighing on the row. */
|
||||
const MAIN_BTN = `${FOOTER_BTN} border border-accent-on bg-transparent font-semibold text-accent-on hover:bg-accent-subtle disabled:hover:bg-transparent`
|
||||
/**
|
||||
* The bar of a dialog with a line over its buttons. DialogFooter is one row;
|
||||
* this is the same bar (border, inset) holding the line and the row under it.
|
||||
*/
|
||||
const FOOTER_COLUMN = 'flex flex-none flex-col gap-2.5 border-t border-edge-faint px-6 py-3.5'
|
||||
const HINT = 'm-0 text-content-muted'
|
||||
/** Should a language run long, the row wraps between buttons, never inside one. */
|
||||
const ROW = 'flex flex-wrap items-center gap-2'
|
||||
|
||||
/**
|
||||
* The foot of the reorder dialog: close it, or add a day.
|
||||
*
|
||||
* On a trip with dates there are two ways to add one, in one row with Close. The
|
||||
* next calendar day, named on its button, extends the trip by one; a day without
|
||||
* a date goes to the end and leaves the trip dates alone. The line above them
|
||||
* says what the button in view does, and switches while the pointer or the focus
|
||||
* rests on the other one. When no day can be added it says why instead.
|
||||
* On a trip with dates there are two ways to add one. The next calendar day,
|
||||
* named on its button, extends the trip by one; a day without a date goes to
|
||||
* the end and leaves the trip dates alone. The line above them says what the
|
||||
* button in view does, and switches while the pointer or the focus rests on
|
||||
* the other one. When no day can be added it says why instead.
|
||||
*/
|
||||
export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) {
|
||||
const [aboutUndated, setAboutUndated] = useState(false)
|
||||
@@ -37,67 +38,57 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo
|
||||
const blocked = dayAdd?.blocked ?? null
|
||||
const addOff = busy || !!blocked
|
||||
|
||||
const closeButton = (
|
||||
<button type="button" onClick={onClose} className={CLOSE_BTN}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)
|
||||
const closeButton = <DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
|
||||
|
||||
if (!dayAdd?.nextDate) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{blocked && <p className="m-0 text-caption text-content-muted">{blocked}</p>}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={onAddDay} disabled={addOff} className={MAIN_BTN}>
|
||||
<Plus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
<footer className={FOOTER_COLUMN}>
|
||||
{blocked && <p className={HINT} style={fs(12)}>{blocked}</p>}
|
||||
<div className={ROW}>
|
||||
<FooterSpacer />
|
||||
{closeButton}
|
||||
<DialogButton variant="primary" onClick={onAddDay} disabled={addOff} icon={<Plus size={14} strokeWidth={2} aria-hidden="true" />}>
|
||||
{t('dayplan.addDay')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
const date = dayDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
|
||||
const hint = blocked
|
||||
?? (aboutUndated ? t('dayplan.addUndatedDayHint') : dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }))
|
||||
const pointAtUndated = {
|
||||
onMouseEnter: () => setAboutUndated(true),
|
||||
onMouseLeave: () => setAboutUndated(false),
|
||||
onFocus: () => setAboutUndated(true),
|
||||
onBlur: () => setAboutUndated(false),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p id={hintId} aria-live="polite" className="m-0 text-caption text-content-muted">
|
||||
<footer className={FOOTER_COLUMN}>
|
||||
<p id={hintId} aria-live="polite" className={HINT} style={fs(12)}>
|
||||
{hint}
|
||||
</p>
|
||||
{/* Should a language run long, the row wraps between buttons, never inside one. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dayAdd.onAddDated}
|
||||
disabled={addOff || !!dayAdd.datedBlocked}
|
||||
aria-describedby={hintId}
|
||||
className={MAIN_BTN}
|
||||
>
|
||||
<CalendarPlus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.addDatedDay', { date })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<div className={ROW}>
|
||||
<DialogButton
|
||||
onClick={onAddDay}
|
||||
disabled={addOff}
|
||||
aria-describedby={hintId}
|
||||
className={SECONDARY_BTN}
|
||||
{...pointAtUndated}
|
||||
icon={<Plus size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
onMouseEnter={() => setAboutUndated(true)}
|
||||
onMouseLeave={() => setAboutUndated(false)}
|
||||
onFocus={() => setAboutUndated(true)}
|
||||
onBlur={() => setAboutUndated(false)}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.addUndatedDay')}
|
||||
</button>
|
||||
</DialogButton>
|
||||
<FooterSpacer />
|
||||
{closeButton}
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
onClick={dayAdd.onAddDated}
|
||||
disabled={addOff || !!dayAdd.datedBlocked}
|
||||
aria-describedby={hintId}
|
||||
icon={<CalendarPlus size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
>
|
||||
{t('dayplan.addDatedDay', { date })}
|
||||
</DialogButton>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-085
|
||||
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-098
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -44,6 +44,12 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The stay picker filters by category through a pill that opens its list; this picks one entry of it. */
|
||||
async function pickStayCategory(user: ReturnType<typeof userEvent.setup> | typeof userEvent, name: string) {
|
||||
await user.click(screen.getByRole('button', { name: /^Category:/ }))
|
||||
await user.click(within(screen.getByRole('group', { name: 'Category' })).getByRole('button', { name }))
|
||||
}
|
||||
|
||||
describe('DayDetailPanel', () => {
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
@@ -123,12 +129,12 @@ describe('DayDetailPanel', () => {
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-007: close button calls onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} onClose={onClose} />);
|
||||
// The header X button — the one outside the hotel picker
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
// Second button is the header X close (first is collapse toggle)
|
||||
await userEvent.click(closeButtons[1]);
|
||||
const onToggleCollapse = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} onClose={onClose} onToggleCollapse={onToggleCollapse} />);
|
||||
// The round X on the head band, named by its tooltip; it does not fold the card on the way.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(onToggleCollapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Weather ──────────────────────────────────────────────────────────────────
|
||||
@@ -137,7 +143,7 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} lat={null} lng={null} />);
|
||||
await waitFor(() => expect(screen.queryByText(/No weather/i)).toBeNull());
|
||||
// No loading spinner either
|
||||
expect(document.querySelector('[style*="border-top-color"]')).toBeNull();
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-009: weather loading state shown briefly', async () => {
|
||||
@@ -145,11 +151,9 @@ describe('DayDetailPanel', () => {
|
||||
http.get('/api/weather/detailed', () => new Promise(() => {})), // never resolves
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
|
||||
// Spinner div has border + borderTopColor
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector('[style*="border-radius: 50%"]');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
// The spinner announces itself as a loading status
|
||||
const spinner = await screen.findByRole('status', { name: 'Loading...' });
|
||||
expect(spinner.querySelector('.animate-spin')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-010: weather data renders temperature in Celsius', async () => {
|
||||
@@ -360,13 +364,13 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
await screen.findByText('Grand Hotel');
|
||||
// The pencil beside the stay opens the picker in edit mode.
|
||||
await userEvent.click(document.querySelector('.lucide-pencil')!.closest('button')!);
|
||||
const picker = await waitFor(() => document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit accommodation' }));
|
||||
const picker = await screen.findByRole('dialog');
|
||||
await userEvent.click(within(picker).getByText('Save'));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Stay overlaps', 'error', undefined));
|
||||
// The picker stays put so the entered values are not lost.
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-022: accommodation edit/remove buttons hidden when canEditDays=false', async () => {
|
||||
@@ -407,7 +411,7 @@ describe('DayDetailPanel', () => {
|
||||
await userEvent.click(addButton);
|
||||
// Hotel picker portal renders into document.body
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,7 +443,7 @@ describe('DayDetailPanel', () => {
|
||||
// Find the element containing the confirmation number
|
||||
await waitFor(() => {
|
||||
const el = screen.getByText(/#SECRET/);
|
||||
expect(el).toHaveStyle({ filter: 'blur(4px)' });
|
||||
expect(isBlurred(el)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -499,13 +503,13 @@ describe('DayDetailPanel', () => {
|
||||
await userEvent.click(addButton);
|
||||
// Picker opened
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
// Click cancel button inside picker
|
||||
const cancelButton = screen.getByText(/Cancel/i);
|
||||
await userEvent.click(cancelButton);
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -558,7 +562,7 @@ describe('DayDetailPanel', () => {
|
||||
const addButton = await screen.findByText(/Add accommodation/i);
|
||||
await userEvent.click(addButton);
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('[style*="z-index: 99999"]');
|
||||
const portal = screen.queryByRole('dialog');
|
||||
expect(portal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -577,14 +581,11 @@ describe('DayDetailPanel', () => {
|
||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
await screen.findByText('Edit Hotel');
|
||||
// All buttons: header collapse (0), header close (1), pencil (2), X/remove (3)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
// Pencil is third button (index 2)
|
||||
const pencilButton = allButtons[2];
|
||||
await userEvent.click(pencilButton);
|
||||
// The pencil on the stay's card, named by its tooltip
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit accommodation' }));
|
||||
// Edit picker should open with "Edit accommodation" title
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('[style*="z-index: 99999"]');
|
||||
const portal = screen.queryByRole('dialog');
|
||||
expect(portal?.textContent).toMatch(/Edit accommodation/i);
|
||||
});
|
||||
});
|
||||
@@ -596,7 +597,7 @@ describe('DayDetailPanel', () => {
|
||||
const addButton = await screen.findByText(/Add accommodation/i);
|
||||
await userEvent.click(addButton);
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('[style*="z-index: 99999"]');
|
||||
const portal = screen.queryByRole('dialog');
|
||||
expect(portal?.textContent).toMatch(/Day in Paris|Day 2|Day 3/i);
|
||||
});
|
||||
});
|
||||
@@ -607,7 +608,7 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} days={[day, day2, day3]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('[style*="z-index: 99999"]');
|
||||
const portal = screen.queryByRole('dialog');
|
||||
// Closed selects render only their chosen label: check-in on the opened
|
||||
// day, check-out on the following one — nobody stays a few hours.
|
||||
expect(portal?.textContent).toContain('Day in Paris');
|
||||
@@ -620,8 +621,9 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} days={[day]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('[style*="z-index: 99999"]');
|
||||
expect(portal?.textContent?.match(/Day in Paris/g)).toHaveLength(2);
|
||||
const portal = screen.queryByRole('dialog');
|
||||
// Both day selects, and the range pill in the head band.
|
||||
expect(portal?.textContent?.match(/Day in Paris/g)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -711,10 +713,8 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} places={[place]} categories={[cat]} />);
|
||||
const addButton = await screen.findByText(/Add accommodation/i);
|
||||
await userEvent.click(addButton);
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('[style*="z-index: 99999"]');
|
||||
expect(portal?.textContent).toMatch(/Hotels/);
|
||||
});
|
||||
await userEvent.click(await screen.findByRole('button', { name: /^Category:/ }));
|
||||
expect(within(screen.getByRole('group', { name: 'Category' })).getByRole('button', { name: 'Hotels' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-039: add another accommodation button visible when accommodations exist', async () => {
|
||||
@@ -762,7 +762,7 @@ describe('DayDetailPanel', () => {
|
||||
await userEvent.click(saveButton);
|
||||
// Picker should close after save
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -785,10 +785,8 @@ describe('DayDetailPanel', () => {
|
||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
await screen.findByText('Hotel To Remove');
|
||||
// Buttons: collapse (0), close header (1), pencil (2), X/remove (3)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const removeButton = allButtons[3];
|
||||
await userEvent.click(removeButton);
|
||||
// The X on the stay's card, named by its tooltip
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
await waitFor(() => {
|
||||
expect(deleteWasCalled).toBe(true);
|
||||
});
|
||||
@@ -875,12 +873,11 @@ describe('DayDetailPanel', () => {
|
||||
const place = buildPlace({ id: 5, name: 'Edit Me Hotel' });
|
||||
render(<DayDetailPanel {...defaultProps} places={[place]} />);
|
||||
await screen.findByText('Edit Me Hotel');
|
||||
// Click the pencil/edit button (index 2, after collapse and close buttons)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await userEvent.click(allButtons[2]);
|
||||
// Click the pencil on the stay's card
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit accommodation' }));
|
||||
// Picker opens in edit mode
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
// Click Save in the edit picker
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
@@ -914,26 +911,28 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} reservations={[linkedReservation]} />);
|
||||
await screen.findByText('Blurred Hotel');
|
||||
const codeEl = await screen.findByText(/#REVEAL123/);
|
||||
// Initially blurred
|
||||
expect(codeEl).toHaveStyle({ filter: 'blur(4px)' });
|
||||
// Fire mouse events to cover the event handler code paths
|
||||
// Initially blurred; hovering lifts it through a class, a click reveals it for good
|
||||
expect(isBlurred(codeEl)).toBe(true);
|
||||
expect(codeEl.className).toContain('hover:blur-none');
|
||||
await userEvent.hover(codeEl);
|
||||
await userEvent.unhover(codeEl);
|
||||
await userEvent.click(codeEl);
|
||||
expect(isBlurred(codeEl)).toBe(false);
|
||||
});
|
||||
|
||||
// ── Collapse behavior ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-048: collapse button has title "Collapse" when expanded', () => {
|
||||
it('FE-PLANNER-DAYDETAIL-048: collapse button is named "Collapse" when expanded', () => {
|
||||
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
|
||||
const collapseBtn = screen.getByTitle('Collapse');
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
const collapseBtn = screen.getByRole('button', { name: 'Collapse' });
|
||||
expect(collapseBtn).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(collapseBtn.getAttribute('title')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-049: collapse button has title "Expand" when collapsed', () => {
|
||||
it('FE-PLANNER-DAYDETAIL-049: collapse button is named "Expand" when collapsed', () => {
|
||||
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
|
||||
const expandBtn = screen.getByTitle('Expand');
|
||||
expect(expandBtn).toBeInTheDocument();
|
||||
const expandBtn = screen.getByRole('button', { name: 'Expand' });
|
||||
expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-050: content area is hidden when collapsed=true', async () => {
|
||||
@@ -949,25 +948,29 @@ describe('DayDetailPanel', () => {
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
|
||||
await waitFor(() => {
|
||||
const content = document.querySelector('[style*="overflow-y: auto"]');
|
||||
const content = screen.getByTestId('day-detail-scroll');
|
||||
expect(content).toHaveStyle({ display: 'none' });
|
||||
});
|
||||
// Still mounted, so the stay loaded while folded is there on unfolding.
|
||||
expect(await screen.findByText('Visible Hotel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-051: content area is visible when collapsed=false', async () => {
|
||||
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
|
||||
await waitFor(() => {
|
||||
const content = document.querySelector('[style*="overflow-y: auto"]');
|
||||
expect(content).toHaveStyle({ display: 'block' });
|
||||
const content = screen.getByTestId('day-detail-scroll');
|
||||
expect(content).not.toHaveStyle({ display: 'none' });
|
||||
expect(content.className).toContain('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-052: clicking the collapse button calls onToggleCollapse', async () => {
|
||||
const onToggleCollapse = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
|
||||
const collapseBtn = screen.getByTitle('Collapse');
|
||||
const collapseBtn = screen.getByRole('button', { name: 'Collapse' });
|
||||
await userEvent.click(collapseBtn);
|
||||
expect(onToggleCollapse).toHaveBeenCalled();
|
||||
// Once: the button keeps its click from reaching the head band as well.
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-053: clicking the header row calls onToggleCollapse', async () => {
|
||||
@@ -1198,9 +1201,8 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||
await screen.findByText('Span Hotel');
|
||||
|
||||
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await userEvent.click(allButtons[2]);
|
||||
// The pencil on the stay's card
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit accommodation' }));
|
||||
|
||||
// Extend end picker to Day 16 (id=7)
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
@@ -1291,16 +1293,16 @@ describe('DayDetailPanel', () => {
|
||||
|
||||
// ── Header buttons ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-069: the collapse and close buttons reset their hover background on leave', async () => {
|
||||
it('FE-PLANNER-DAYDETAIL-069: the collapse and close buttons show their names as tooltips', async () => {
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const collapse = buttons.find(b => b.getAttribute('title') === 'Collapse')!;
|
||||
const close = buttons[buttons.indexOf(collapse) + 1];
|
||||
for (const btn of [collapse, close]) {
|
||||
act(() => { btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(btn.style.background).toBe('var(--bg-hover)');
|
||||
act(() => { btn.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(btn.style.background).toBe('var(--bg-secondary)');
|
||||
for (const name of ['Collapse', 'Close']) {
|
||||
const btn = screen.getByRole('button', { name });
|
||||
// Hover looks are token classes, not inline styles swapped by handlers.
|
||||
expect(btn.getAttribute('style')).toBeNull();
|
||||
fireEvent.mouseEnter(btn);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(name);
|
||||
fireEvent.mouseLeave(btn);
|
||||
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1364,7 +1366,7 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
await screen.findByText('First Hotel');
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await waitFor(() => expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
// ── Hotel picker ────────────────────────────────────────────────────────────
|
||||
@@ -1373,16 +1375,22 @@ describe('DayDetailPanel', () => {
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
const open = async () => {
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
return await waitFor(() => document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement);
|
||||
return await screen.findByRole('dialog');
|
||||
};
|
||||
let overlay = await open();
|
||||
// The X sits in the picker header, right after the title.
|
||||
await userEvent.click(within(overlay).getByText(/Add accommodation/i).parentElement!.querySelector('button')!);
|
||||
await waitFor(() => expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull());
|
||||
// The X sits in the picker's head band.
|
||||
await userEvent.click(within(overlay).getByRole('button', { name: 'Close' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
|
||||
overlay = await open();
|
||||
await userEvent.click(overlay);
|
||||
await waitFor(() => expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull());
|
||||
// The dimmed backdrop around the dialog closes it too.
|
||||
await userEvent.click(overlay.parentElement as HTMLElement);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
|
||||
// And so does Escape.
|
||||
await open();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-075: check-in, check-in-until, check-out and confirmation feed the saved accommodation', async () => {
|
||||
@@ -1401,7 +1409,7 @@ describe('DayDetailPanel', () => {
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Pension Anna/i }));
|
||||
|
||||
const overlay = document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement;
|
||||
const overlay = screen.getByRole('dialog');
|
||||
const timeInputs = within(overlay).getAllByPlaceholderText(/^(14:00|22:00|11:00)$/);
|
||||
await userEvent.type(timeInputs[0], '15:00');
|
||||
await userEvent.type(timeInputs[1], '20:00');
|
||||
@@ -1413,7 +1421,7 @@ describe('DayDetailPanel', () => {
|
||||
expect(body).toMatchObject({ place_id: 70, check_in: '15:00', check_in_end: '20:00', check_out: '10:00', confirmation: 'ZZ-9' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-076: the category filter narrows the picker list and "All days" clears it', async () => {
|
||||
it('FE-PLANNER-DAYDETAIL-076: the category filter narrows the picker list and "All Categories" clears it', async () => {
|
||||
const categories = [
|
||||
{ id: 1, name: 'Hotels', color: '#8b5cf6' },
|
||||
{ id: 2, name: 'Sights', color: '#ef4444' },
|
||||
@@ -1424,30 +1432,31 @@ describe('DayDetailPanel', () => {
|
||||
];
|
||||
render(<DayDetailPanel {...defaultProps} places={places} categories={categories} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
const overlay = document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement;
|
||||
const overlay = screen.getByRole('dialog');
|
||||
|
||||
await userEvent.click(within(overlay).getByRole('button', { name: 'Hotels' }));
|
||||
await pickStayCategory(userEvent, 'Hotels');
|
||||
expect(within(overlay).getByText('Hotel Adlon')).toBeInTheDocument();
|
||||
expect(within(overlay).queryByText('Brandenburg Gate')).not.toBeInTheDocument();
|
||||
|
||||
// A category with no places falls back to the empty hint.
|
||||
await userEvent.click(within(overlay).getByRole('button', { name: 'Sights' }));
|
||||
await pickStayCategory(userEvent, 'Sights');
|
||||
expect(within(overlay).queryByText('Hotel Adlon')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(overlay).getAllByRole('button', { name: /^All$/ })[1]);
|
||||
await pickStayCategory(userEvent, 'All Categories');
|
||||
expect(within(overlay).getByText('Hotel Adlon')).toBeInTheDocument();
|
||||
expect(within(overlay).getByText('Brandenburg Gate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-077: hovering an unselected place row highlights it and clears again', async () => {
|
||||
it('FE-PLANNER-DAYDETAIL-077: an unselected place row highlights on hover only, the picked one stays marked', async () => {
|
||||
const places = [buildPlace({ id: 90, name: 'Hostel One' })];
|
||||
render(<DayDetailPanel {...defaultProps} places={places} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
const row = (await screen.findByText('Hostel One')).closest('button') as HTMLButtonElement;
|
||||
act(() => { row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(row.style.background).toBe('var(--bg-hover)');
|
||||
act(() => { row.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(row.style.background).toBe('none');
|
||||
expect(row).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(row.className).toContain('hover:bg-surface-card');
|
||||
expect(row.className).not.toContain('ring-accent');
|
||||
await userEvent.click(row);
|
||||
expect(row).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(row.className).toContain('ring-accent');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-078: the picker reports when the trip has no places to pick from', async () => {
|
||||
@@ -1506,7 +1515,7 @@ describe('DayDetailPanel remaining branches', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('Open Ticket')).closest('div[style*="border-radius: 8px"]') as HTMLElement;
|
||||
const row = (await screen.findByText('Open Ticket')).closest('li') as HTMLElement;
|
||||
expect(row.textContent).toBe('Open Ticket');
|
||||
});
|
||||
|
||||
@@ -1524,7 +1533,7 @@ describe('DayDetailPanel remaining branches', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('Deep Dive')).closest('div[style*="border-radius: 8px"]') as HTMLElement;
|
||||
const row = (await screen.findByText('Deep Dive')).closest('li') as HTMLElement;
|
||||
expect(row.querySelector('svg')?.getAttribute('class')).toMatch(/file-text/);
|
||||
expect(row).toHaveTextContent('Opera House');
|
||||
// hotels are shown in the accommodation block, other days are not shown at all
|
||||
@@ -1558,17 +1567,17 @@ describe('DayDetailPanel remaining branches', () => {
|
||||
);
|
||||
|
||||
const code = await screen.findByText('#XY99');
|
||||
expect(code.style.filter).toBe('blur(4px)');
|
||||
|
||||
fireEvent.mouseEnter(code);
|
||||
expect(code.style.filter).toBe('none');
|
||||
fireEvent.mouseLeave(code);
|
||||
expect(code.style.filter).toBe('blur(4px)');
|
||||
expect(isBlurred(code)).toBe(true);
|
||||
// The pointer lifts the blur through a hover class, as in the bookings list.
|
||||
expect(code.className).toContain('hover:blur-none');
|
||||
|
||||
fireEvent.click(code);
|
||||
expect(code.style.filter).toBe('none');
|
||||
expect(isBlurred(code)).toBe(false);
|
||||
expect(code).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(code);
|
||||
expect(code.style.filter).toBe('blur(4px)');
|
||||
expect(isBlurred(code)).toBe(true);
|
||||
// Enter and Space reach it too: it is a real button.
|
||||
expect(code.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-006: saving the edit picker updates the accommodation and reloads the list', async () => {
|
||||
@@ -1616,12 +1625,11 @@ describe('DayDetailPanel remaining branches', () => {
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
expect(screen.getByRole('button', { name: /Chez Nous/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Museums' }));
|
||||
await pickStayCategory(user, 'Museums');
|
||||
expect(screen.queryByRole('button', { name: /Chez Nous/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Maison Blanche/ })).toBeInTheDocument();
|
||||
|
||||
const filterRow = screen.getByRole('button', { name: 'Museums' }).parentElement as HTMLElement;
|
||||
await user.click(within(filterRow).getByRole('button', { name: /^All$/ }));
|
||||
await pickStayCategory(user, 'All Categories');
|
||||
expect(screen.getByRole('button', { name: /Chez Nous/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1635,7 +1643,7 @@ describe('DayDetailPanel remaining branches', () => {
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
// open the "from" select — the option rows carry the badges
|
||||
const range = screen.getByText('Apply to days').parentElement as HTMLElement;
|
||||
const range = screen.getByText('Apply to days').closest('section') as HTMLElement;
|
||||
await user.click(within(range).getAllByRole('button')[0]);
|
||||
|
||||
expect(screen.getAllByText('Jun 15').length).toBeGreaterThanOrEqual(1);
|
||||
@@ -1698,13 +1706,13 @@ describe('DayDetailPanel remaining branches', () => {
|
||||
const onToggleCollapse = vi.fn();
|
||||
const { rerender } = render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
|
||||
|
||||
await user.click(screen.getByTitle('Collapse'));
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
expect(onToggleCollapse).toHaveBeenCalled();
|
||||
|
||||
rerender(<DayDetailPanel {...defaultProps} collapsed onToggleCollapse={onToggleCollapse} />);
|
||||
const header = screen.getByText('Day in Paris').closest('div') as HTMLElement;
|
||||
expect(header).toHaveTextContent(/Sunday, June 15/);
|
||||
expect(screen.getByTitle('Expand')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1728,13 +1736,14 @@ describe('DayDetailPanel remaining branches, part two', () => {
|
||||
);
|
||||
|
||||
const code = await screen.findByText('#XY99');
|
||||
expect(code.style.filter).toBe('none');
|
||||
expect(code.style.cursor).toBe('default');
|
||||
expect(isBlurred(code)).toBe(false);
|
||||
// With nothing to reveal the code is plain text, not a control.
|
||||
expect(code.closest('button')).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(code);
|
||||
fireEvent.click(code);
|
||||
fireEvent.mouseLeave(code);
|
||||
expect(code.style.filter).toBe('none');
|
||||
expect(isBlurred(code)).toBe(false);
|
||||
expect(screen.getByText('Pending')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1772,7 +1781,7 @@ describe('DayDetailPanel remaining branches, part two', () => {
|
||||
render(<DayDetailPanel {...defaultProps} day={d1} days={[d1, d2]} places={[buildPlace({ id: 10, name: 'Maison Blanche' })]} />);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
const range = screen.getByText('Apply to days').parentElement as HTMLElement;
|
||||
const range = screen.getByText('Apply to days').closest('section') as HTMLElement;
|
||||
const [fromSelect, toSelect] = within(range).getAllByRole('button');
|
||||
|
||||
await user.click(fromSelect);
|
||||
@@ -1802,15 +1811,17 @@ describe('DayDetailPanel remaining branches, part two', () => {
|
||||
|
||||
const picked = screen.getByRole('button', { name: /Maison Blanche/ });
|
||||
await user.click(picked);
|
||||
expect(picked.style.background).toBe('var(--bg-hover)');
|
||||
expect(picked).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
fireEvent.mouseEnter(picked);
|
||||
fireEvent.mouseLeave(picked);
|
||||
expect(picked.style.background).toBe('var(--bg-hover)');
|
||||
expect(picked).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(picked.className).toContain('ring-accent');
|
||||
expect(screen.getByRole('button', { name: /Chez Nous/ })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(document.querySelector('img[src="/uploads/places/mb.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-019: a category without a colour falls back to the default filter highlight', async () => {
|
||||
it('FE-W5DDP-019: a category without a colour gets a neutral dot and can still be picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DayDetailPanel
|
||||
@@ -1821,9 +1832,13 @@ describe('DayDetailPanel remaining branches, part two', () => {
|
||||
);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
const chip = screen.getByRole('button', { name: 'Uncoloured' });
|
||||
await user.click(chip);
|
||||
expect(chip.style.background).toBe('var(--text-primary)');
|
||||
await user.click(screen.getByRole('button', { name: /^Category:/ }));
|
||||
const option = within(screen.getByRole('group', { name: 'Category' })).getByRole('button', { name: 'Uncoloured' });
|
||||
// Its dot falls back to a neutral token instead of a colour it does not have.
|
||||
expect((option.querySelector('span span') as HTMLElement).style.background).toBe('var(--text-faint)');
|
||||
await user.click(option);
|
||||
// The pill now names the category in force.
|
||||
expect(screen.getByRole('button', { name: /^Category:/ })).toHaveTextContent('Uncoloured');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-022: a plugin column for this day is appended above the reservations', async () => {
|
||||
@@ -1929,10 +1944,10 @@ describe('DayDetailPanel blur booking codes (#2457)', () => {
|
||||
);
|
||||
const openHotelEditor = async () => {
|
||||
await screen.findByText('Code Hotel');
|
||||
// header collapse (0), header close (1), pencil (2), remove (3), as in DAYDETAIL-032
|
||||
await userEvent.click(screen.getAllByRole('button')[2]);
|
||||
// the pencil on the stay's card, as in DAYDETAIL-032
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit accommodation' }));
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[style*="z-index: 99999"]')?.textContent).toMatch(/Edit accommodation/i);
|
||||
expect(screen.getByRole('dialog').textContent).toMatch(/Edit accommodation/i);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1983,3 +1998,227 @@ describe('DayDetailPanel blur booking codes (#2457)', () => {
|
||||
expect(body!.confirmation).toBe('HOTEL-SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Redesign fixes: the right stay is removed, failed writes speak up ──────────
|
||||
|
||||
describe('DayDetailPanel accommodation writes', () => {
|
||||
const stay = (id: number, name: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id, place_id: 100 + id, place_name: name, place_address: 'Paris',
|
||||
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-086: with two hotels on the day, the X of the second deletes the second', async () => {
|
||||
// The X used to select its stay and then call a remove that still held the
|
||||
// stay selected before, so on a day with two hotels it deleted the first.
|
||||
const deleted: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [stay(1, 'First Hotel'), stay(2, 'Second Hotel')] })),
|
||||
http.delete('/api/trips/1/accommodations/:id', ({ params }) => {
|
||||
deleted.push(String(params.id));
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const onAccommodationChange = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} onAccommodationChange={onAccommodationChange} />);
|
||||
const second = (await screen.findByText('Second Hotel')).closest('article') as HTMLElement;
|
||||
await userEvent.click(within(second).getByRole('button', { name: 'Remove' }));
|
||||
|
||||
await waitFor(() => expect(deleted).toEqual(['2']));
|
||||
await waitFor(() => expect(screen.queryByText('Second Hotel')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('First Hotel')).toBeInTheDocument();
|
||||
expect(onAccommodationChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The first one is still the one its own X and pencil act on.
|
||||
const first = screen.getByText('First Hotel').closest('article') as HTMLElement;
|
||||
await userEvent.click(within(first).getByRole('button', { name: 'Remove' }));
|
||||
await waitFor(() => expect(deleted).toEqual(['2', '1']));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-087: a failing new stay keeps the picker open and says why', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', () => HttpResponse.json({ error: 'Place is not in this trip' }, { status: 400 })),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 10, name: 'Maison Blanche' })]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Maison Blanche/ }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Place is not in this trip', 'error', undefined));
|
||||
// Still open, still holding the picked place.
|
||||
expect(screen.getByRole('dialog', { name: 'Add accommodation' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Maison Blanche/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-088: a failing removal keeps the stay and says so', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [stay(1, 'Sticky Hotel')] })),
|
||||
http.delete('/api/trips/1/accommodations/1', () => HttpResponse.json({ error: 'Locked by a booking' }, { status: 409 })),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
await userEvent.click(within((await screen.findByText('Sticky Hotel')).closest('article') as HTMLElement).getByRole('button', { name: 'Remove' }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Locked by a booking', 'error', undefined));
|
||||
expect(screen.getByText('Sticky Hotel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-093: a stay requested from the day\'s "+" opens the editor once', async () => {
|
||||
const onStayPickerOpened = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} openStayPicker onStayPickerOpened={onStayPickerOpened} />);
|
||||
expect(await screen.findByRole('dialog', { name: 'Add accommodation' })).toBeInTheDocument();
|
||||
expect(onStayPickerOpened).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-094: without edit rights the request is handed back and nothing opens', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 999, role: 'user' }), isAuthenticated: true });
|
||||
seedStore(usePermissionsStore, { permissions: { day_edit: 'admin' } });
|
||||
const onStayPickerOpened = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} openStayPicker onStayPickerOpened={onStayPickerOpened} />);
|
||||
await waitFor(() => expect(onStayPickerOpened).toHaveBeenCalled());
|
||||
expect(screen.queryByRole('dialog', { name: 'Add accommodation' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-089: the stay picker is a named dialog', async () => {
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Add accommodation' });
|
||||
// Every field of the editor has its own label.
|
||||
for (const label of ['Apply to days', 'Start', 'End', 'Check-in', 'Until', 'Check-out', 'Confirmation']) {
|
||||
expect(within(dialog).getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
// The list's label, and the title that asks for a pick until there is one.
|
||||
expect(within(dialog).getAllByText('Select accommodation')).toHaveLength(2);
|
||||
expect(within(dialog).getByLabelText('Confirmation')).toHaveAttribute('placeholder', 'ABC-12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DayDetailPanel weather and reservation rows', () => {
|
||||
it('FE-PLANNER-DAYDETAIL-090: the weather chips carry their labels, the hourly strip is labelled and tints rainy hours', async () => {
|
||||
server.use(
|
||||
http.get('/api/weather/detailed', () =>
|
||||
HttpResponse.json({
|
||||
main: 'Rain', temp: 15, temp_min: 12, temp_max: 18, description: 'rainy', type: 'forecast',
|
||||
precipitation_probability_max: 80, precipitation_sum: 5.2, wind_max: 30, sunrise: '06:30', sunset: '20:15',
|
||||
hourly: [
|
||||
{ hour: 8, main: 'Rain', temp: 14, precipitation_probability: 70 },
|
||||
{ hour: 9, main: 'Rain', temp: 14, precipitation_probability: 70 },
|
||||
{ hour: 10, main: 'Clouds', temp: 16, precipitation_probability: 20 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} lat={48.85} lng={2.35} weatherPlaceName="Louvre" />);
|
||||
expect(await screen.findByText('Forecast for Louvre')).toBeInTheDocument();
|
||||
// Screen readers hear the label with each value …
|
||||
for (const label of ['Rain probability', 'Precipitation', 'Wind', 'Sunrise', 'Sunset']) {
|
||||
expect(screen.getByText(`${label}:`)).toHaveClass('sr-only');
|
||||
}
|
||||
// … and the pointer gets it as a tooltip.
|
||||
fireEvent.mouseEnter(screen.getByText('80%'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Rain probability');
|
||||
fireEvent.mouseLeave(screen.getByText('80%'));
|
||||
|
||||
expect(screen.getByText('Hourly Forecast')).toBeInTheDocument();
|
||||
expect((screen.getByText('08').parentElement as HTMLElement).className).toContain('bg-info-soft');
|
||||
expect((screen.getByText('10').parentElement as HTMLElement).className).not.toContain('bg-info-soft');
|
||||
// every second hour only
|
||||
expect(screen.queryByText('09')).toBeNull();
|
||||
expect(screen.getByText('12° / 18°')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-091: a reservation row names its status and shows its type icon', async () => {
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
reservations={[
|
||||
buildReservation({ id: 9, type: 'restaurant', title: 'Dinner', day_id: 1, status: 'confirmed' }),
|
||||
buildReservation({ id: 10, type: 'event', title: 'Show', day_id: 1, status: 'pending' }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const dinner = (await screen.findByText('Dinner')).closest('li') as HTMLElement;
|
||||
expect(within(dinner).getByRole('img', { name: 'Confirmed' })).toBeInTheDocument();
|
||||
expect(dinner.querySelector('svg')?.getAttribute('class')).toMatch(/utensils/);
|
||||
const show = screen.getByText('Show').closest('li') as HTMLElement;
|
||||
expect(within(show).getByRole('img', { name: 'Pending' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Reservations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-092: the stay card marks the check-out day in the danger tone', async () => {
|
||||
const lastDay = buildDay({ id: 3, trip_id: 1, date: '2025-06-17', title: 'Leaving Day' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () =>
|
||||
HttpResponse.json({
|
||||
accommodations: [{
|
||||
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
|
||||
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
|
||||
}],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} day={lastDay} days={[day, lastDay]} />);
|
||||
const card = (await screen.findByText('Grand Hotel')).closest('article') as HTMLElement;
|
||||
const pill = within(card).getAllByText('Check-out').find(el => el.className.includes('rounded-full')) as HTMLElement;
|
||||
expect(pill.className).toContain('text-danger');
|
||||
expect(pill.querySelector('.lucide-log-out')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The desktop plan passes onOpenBooking, so a booking in the day's card opens its
|
||||
// detail; the narrow layout does not, and the rows stay a read-only summary.
|
||||
describe('DayDetailPanel opening a booking', () => {
|
||||
const stay = {
|
||||
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
|
||||
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
|
||||
};
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-095: with a detail to show, a booking of the day opens it', async () => {
|
||||
const onOpenBooking = vi.fn();
|
||||
const show = buildReservation({ id: 9, type: 'event', title: 'Night Show', day_id: 1, reservation_time: '20:00', status: 'confirmed' });
|
||||
render(<DayDetailPanel {...defaultProps} reservations={[show]} onOpenBooking={onOpenBooking} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Night Show/ }));
|
||||
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(show);
|
||||
});
|
||||
|
||||
it("FE-PLANNER-DAYDETAIL-096: without one, the day's bookings stay a read-only list", async () => {
|
||||
const show = buildReservation({ id: 9, type: 'event', title: 'Night Show', day_id: 1, reservation_time: '20:00', status: 'confirmed' });
|
||||
render(<DayDetailPanel {...defaultProps} reservations={[show]} />);
|
||||
|
||||
expect(await screen.findByText('Night Show')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Night Show/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("FE-PLANNER-DAYDETAIL-097: a stay's booking is one button that opens it, with its blurred code no second button inside", async () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true } });
|
||||
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [stay] })));
|
||||
const onOpenBooking = vi.fn();
|
||||
const booking = buildReservation({ id: 40, type: 'hotel', title: 'Grand Hotel Booking', accommodation_id: 1, status: 'confirmed', confirmation_number: 'XY99' });
|
||||
render(<DayDetailPanel {...defaultProps} reservations={[booking]} onOpenBooking={onOpenBooking} />);
|
||||
|
||||
const box = await screen.findByRole('button', { name: /Grand Hotel Booking/ });
|
||||
// The code is read in the booking it opens, so it is neither a button nor part of this one's name.
|
||||
expect(within(box).queryByRole('button')).toBeNull();
|
||||
expect(box).not.toHaveAccessibleName(/XY99/);
|
||||
|
||||
await userEvent.click(box);
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(booking);
|
||||
});
|
||||
|
||||
it("FE-PLANNER-DAYDETAIL-098: without one, a stay's booking is no button and its blurred code reveals itself", async () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true } });
|
||||
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [stay] })));
|
||||
const booking = buildReservation({ id: 40, type: 'hotel', title: 'Grand Hotel Booking', accommodation_id: 1, status: 'confirmed', confirmation_number: 'XY99' });
|
||||
render(<DayDetailPanel {...defaultProps} reservations={[booking]} />);
|
||||
|
||||
expect(await screen.findByText('Grand Hotel Booking')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Grand Hotel Booking/ })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '#XY99' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DAYPLAN-001 to FE-PLANNER-DAYPLAN-155
|
||||
// FE-PLANNER-DAYPLAN-001 to FE-PLANNER-DAYPLAN-231
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -164,12 +164,23 @@ function dragRow(el: HTMLElement | null) {
|
||||
}
|
||||
|
||||
/** Transport and note rows share the same card margin; legs are not draggable. */
|
||||
/** The day head's "+" opens a menu of what the day can be given; this picks one entry. */
|
||||
async function pickDayAdd(user: ReturnType<typeof userEvent.setup>, entry: string, dayTitle = 'Day 1') {
|
||||
await user.click(within(dayHeader(dayTitle)).getByRole('button', { name: 'Add to day' }))
|
||||
await user.click(await screen.findByRole('button', { name: entry }))
|
||||
}
|
||||
|
||||
/** The day head's expand/collapse chevron. */
|
||||
function dayChevron(dayTitle = 'Day 1') {
|
||||
return within(dayHeader(dayTitle)).getByRole('button', { name: /^(Collapse|Expand)$/ })
|
||||
}
|
||||
|
||||
function cardRow(el: HTMLElement | null) {
|
||||
return el!.closest('[style*="margin: 1px 8px"]') as HTMLElement
|
||||
return el!.closest('[data-dp="transport-row"], [data-dp="note-row"]') as HTMLElement
|
||||
}
|
||||
|
||||
function lockToggle(row: HTMLElement) {
|
||||
return row.querySelector('[style*="cursor: pointer"][style*="position: relative"]') as HTMLElement
|
||||
return row.querySelector('[data-dp="lock"]') as HTMLElement
|
||||
}
|
||||
|
||||
/** The context menu is portalled to body and shares labels with the route tools. */
|
||||
@@ -374,11 +385,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const assignments = { '10': [assignment] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
// The chevron button immediately follows the "Add Note" button (which has a title attribute)
|
||||
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
|
||||
expect(chevron).toBeTruthy()
|
||||
await user.click(chevron)
|
||||
await user.click(dayChevron())
|
||||
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -389,7 +396,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const assignments = { '10': [assignment] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement
|
||||
const getChevron = () => dayChevron()
|
||||
await user.click(getChevron()) // collapse
|
||||
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
||||
await user.click(getChevron()) // re-expand
|
||||
@@ -477,7 +484,7 @@ describe('DayPlanSidebar', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onPlanTransit = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit })} />)
|
||||
await user.click(screen.getByLabelText('Public transit'))
|
||||
await pickDayAdd(user, 'Public transit')
|
||||
expect(onPlanTransit).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
@@ -555,7 +562,7 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
|
||||
// The day's own chevron carries the same accessible name, so pick the one
|
||||
// that is not in the day's action block.
|
||||
const rowCollapse = screen.getAllByLabelText('Collapse').find(el => !el.closest('.dp-day-actions'))!
|
||||
const rowCollapse = screen.getAllByLabelText('Collapse').find(el => !el.closest('.dp-day-tools'))!
|
||||
await user.click(rowCollapse)
|
||||
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -912,7 +919,7 @@ describe('DayPlanSidebar', () => {
|
||||
days: [day], places: [place1, place2], assignments: assigns, selectedDayId: 10,
|
||||
})} />)
|
||||
// The ExternalLink button is the Google Maps icon-only button (sibling of Optimize button)
|
||||
const routeSection = document.querySelector('[style*="flex-direction: column"]')
|
||||
const routeSection = document.querySelector('[data-dp="route-tools"]')
|
||||
const externalLinkBtn = screen.getAllByRole('button').find(btn => {
|
||||
const parent = btn.closest('[style*="flex"]')
|
||||
return btn.querySelector('svg') && !btn.textContent?.trim() && parent?.textContent?.includes('optimize')
|
||||
@@ -1062,14 +1069,13 @@ describe('DayPlanSidebar', () => {
|
||||
// Click on the PlaceAvatar wrapper (the lock toggle div) — it's a div with cursor: pointer that wraps the avatar
|
||||
const placeEl = screen.getByText('Arc de Triomphe')
|
||||
// The lock div is the parent of PlaceAvatar, which is a sibling of the GripVertical div
|
||||
const row = placeEl.closest('[style*="display: flex"][style*="gap: 8"]')
|
||||
const lockDiv = row?.querySelector('[style*="cursor: pointer"][style*="position: relative"]')
|
||||
const row = placeEl.closest('.dp-row')
|
||||
const lockDiv = row?.querySelector('[data-dp="lock"]')
|
||||
if (lockDiv) {
|
||||
await user.click(lockDiv as HTMLElement)
|
||||
// After lock: the row should have red border
|
||||
// After lock: the row takes the danger tint
|
||||
await waitFor(() => {
|
||||
const rowEl = placeEl.closest('[style*="border-left"]')
|
||||
expect(rowEl).toBeTruthy()
|
||||
expect((placeEl.closest('.dp-row') as HTMLElement).style.background).toContain('--danger')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1286,8 +1292,7 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||
await user.click(addNoteBtn)
|
||||
await pickDayAdd(user, 'Add Note')
|
||||
expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1461,8 +1466,10 @@ describe('DayPlanSidebar', () => {
|
||||
expect(document.querySelector('.note-edit-buttons')).toBeNull()
|
||||
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
|
||||
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
|
||||
// Positional, not just by class: the reorder column owns the row's right edge.
|
||||
expect(row.lastElementChild).toHaveClass('reorder-buttons')
|
||||
// Positional, not just by class: the reorder column sits at the row's right edge,
|
||||
// with only the row's "…" after it.
|
||||
expect(row.lastElementChild).toHaveAccessibleName('More options')
|
||||
expect(row.lastElementChild?.previousElementSibling).toHaveClass('reorder-buttons')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
|
||||
@@ -1755,14 +1762,13 @@ describe('DayPlanSidebar', () => {
|
||||
days: [day], places: [place], assignments: { '10': [assignment] },
|
||||
})} />)
|
||||
const placeEl = screen.getByText('Hovered Place')
|
||||
const row = placeEl.closest('[style*="display: flex"][style*="gap: 8"]')
|
||||
const lockDiv = row?.querySelector('[style*="cursor: pointer"][style*="position: relative"]')
|
||||
const row = placeEl.closest('.dp-row')
|
||||
const lockDiv = row?.querySelector('[data-dp="lock"]')
|
||||
if (lockDiv) {
|
||||
fireEvent.mouseEnter(lockDiv as Element)
|
||||
// Lock overlay should appear
|
||||
await waitFor(() => {
|
||||
const overlays = document.querySelectorAll('[style*="position: absolute"][style*="inset: 0"]')
|
||||
expect(overlays.length).toBeGreaterThan(0)
|
||||
expect((lockDiv as HTMLElement).querySelector('span.absolute')).toBeTruthy()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1904,8 +1910,8 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// Lock the first assignment by clicking its lock area
|
||||
const placeEl = screen.getByText('Place Lock')
|
||||
const row = placeEl.closest('[style*="display: flex"][style*="gap: 8"]')
|
||||
const lockDiv = row?.querySelector('[style*="cursor: pointer"][style*="position: relative"]')
|
||||
const row = placeEl.closest('.dp-row')
|
||||
const lockDiv = row?.querySelector('[data-dp="lock"]')
|
||||
if (lockDiv) fireEvent.click(lockDiv as Element)
|
||||
|
||||
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
||||
@@ -1989,7 +1995,7 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// The first note should have a down arrow (not at bottom)
|
||||
const noteEl = screen.getByText('Note One')
|
||||
const noteCard = noteEl.closest('[style*="display: flex"][style*="gap: 8"]')
|
||||
const noteCard = noteEl.closest('.dp-row')
|
||||
const buttons = noteCard?.querySelectorAll('.reorder-buttons button')
|
||||
if (buttons && buttons.length >= 2) {
|
||||
await user.click(buttons[1] as HTMLButtonElement) // down arrow
|
||||
@@ -2030,8 +2036,7 @@ describe('DayPlanSidebar', () => {
|
||||
})} />)
|
||||
|
||||
// The expanded content wrapper is the div with background: var(--bg-hover) paddingTop:6
|
||||
const expandedArea = document.querySelector('[style*="padding-top: 6"]') ||
|
||||
document.querySelector('[style*="paddingTop: 6"]')
|
||||
const expandedArea = document.querySelector('[data-dp="day-body"]')
|
||||
|
||||
if (expandedArea) {
|
||||
;(window as any).__dragData = { placeId: '99' }
|
||||
@@ -2117,7 +2122,7 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// Click down arrow on 'Early Place' (a1) — would move it after a2, breaking order
|
||||
const earlyEl = screen.getByText('Early Place')
|
||||
const row = earlyEl.closest('[style*="display: flex"][style*="gap: 8"]')
|
||||
const row = earlyEl.closest('.dp-row')
|
||||
const reorderBtns = row?.querySelectorAll('.reorder-buttons button')
|
||||
if (reorderBtns && reorderBtns.length >= 2) {
|
||||
await user.click(reorderBtns[1] as HTMLButtonElement) // down button
|
||||
@@ -2852,7 +2857,7 @@ describe('DayPlanSidebar', () => {
|
||||
localStorage.setItem('day-expanded-1', JSON.stringify([]))
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
|
||||
expect(screen.queryByText('Hidden Place')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByLabelText('Add Note'))
|
||||
await pickDayAdd(user, 'Add Note')
|
||||
expect(await screen.findByText('Hidden Place')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -3057,10 +3062,10 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] }, pushUndo })} />)
|
||||
const row = dragRow(screen.getByText('Locked Place'))
|
||||
await user.click(lockToggle(row))
|
||||
await waitFor(() => expect(row.style.borderLeftColor).toBe('rgb(220, 38, 38)'))
|
||||
await waitFor(() => expect(row.style.background).toContain('--danger'))
|
||||
const undo = pushUndo.mock.calls[0][1] as () => void
|
||||
undo()
|
||||
await waitFor(() => expect(dragRow(screen.getByText('Locked Place')).style.borderLeftColor).toBe('transparent'))
|
||||
await waitFor(() => expect(dragRow(screen.getByText('Locked Place')).style.background).not.toContain('--danger'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-129: optimising a day with fewer than three stops does nothing', async () => {
|
||||
@@ -3119,7 +3124,7 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [a] }, onAddTransport: vi.fn(), onPlanTransit: vi.fn(),
|
||||
})} />)
|
||||
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument()
|
||||
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
|
||||
expect(screen.getByText('A note')).toBeInTheDocument()
|
||||
// A viewer must not reach the edit modal they could never save (#2249).
|
||||
@@ -3143,7 +3148,7 @@ describe('DayPlanSidebar', () => {
|
||||
// ── Drops onto the expanded day body ─────────────────────────────────────
|
||||
|
||||
function expandedBody() {
|
||||
return document.querySelector('[style*="padding-top: 6px"]') as HTMLElement
|
||||
return document.querySelector('[data-dp="day-body"]') as HTMLElement
|
||||
}
|
||||
|
||||
function dayWithBusAndPlaces() {
|
||||
@@ -3214,7 +3219,7 @@ describe('DayPlanSidebar', () => {
|
||||
fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer })
|
||||
fireEvent.dragOver(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer })
|
||||
// The second day's body is the one holding the bus row.
|
||||
const bodies = document.querySelectorAll('[style*="padding-top: 6px"]')
|
||||
const bodies = document.querySelectorAll('[data-dp="day-body"]')
|
||||
fireEvent.drop(bodies[1], { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 })
|
||||
})
|
||||
@@ -3233,7 +3238,7 @@ describe('DayPlanSidebar', () => {
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] }
|
||||
const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, places: [place], assignments, reservations: [taxi] })} />)
|
||||
const target = () => document.querySelectorAll('[style*="padding-top: 6px"]')[1]
|
||||
const target = () => document.querySelectorAll('[data-dp="day-body"]')[1]
|
||||
|
||||
fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer })
|
||||
fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
@@ -3295,7 +3300,7 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAssignToDay })} />)
|
||||
const placeholder = screen.getByText('No places planned for this day').parentElement as HTMLElement
|
||||
fireEvent.dragOver(placeholder, { dataTransfer: emptyDataTransfer })
|
||||
expect(placeholder.className).toContain('bg-[rgba(17,24,39,0.05)]')
|
||||
expect(placeholder.style.outline).toContain('dashed')
|
||||
fireEvent.drop(placeholder, { dataTransfer: { getData: (k: string) => (k === 'placeId' ? '88' : '') } })
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(88, 10)
|
||||
})
|
||||
@@ -3456,7 +3461,7 @@ describe('DayPlanSidebar', () => {
|
||||
it('FE-PLANNER-DAYPLAN-215: dropped on the day body, it lands between the stops its start falls between', () => {
|
||||
const moveAssignment = setup()
|
||||
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
|
||||
fireEvent.drop(document.querySelectorAll('[style*="padding-top: 6px"]')[1], { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
fireEvent.drop(document.querySelectorAll('[data-dp="day-body"]')[1], { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
// 10:00 sits between 09:00 (index 0) and 11:00, so index 1, not the end of the day.
|
||||
expect(moveAssignment).toHaveBeenCalledWith(1, 21, 10, 11, 1)
|
||||
})
|
||||
@@ -3842,14 +3847,14 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
|
||||
const row = dragRow(screen.getByText('Pin me'))
|
||||
const toggle = lockToggle(row)
|
||||
expect(toggle).toHaveAccessibleName('Keep position during route optimization')
|
||||
fireEvent.mouseEnter(toggle)
|
||||
expect(screen.getByText('Keep position during route optimization')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Keep position during route optimization')
|
||||
await user.click(toggle)
|
||||
expect(screen.getByText('Click to unlock')).toBeInTheDocument()
|
||||
expect(toggle).toHaveAccessibleName('Click to unlock')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(row.style.borderLeftColor).toBe('transparent'))
|
||||
fireEvent.mouseLeave(toggle)
|
||||
expect(screen.queryByText('Click to unlock')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(row.style.background).not.toContain('--danger'))
|
||||
expect(toggle).toHaveAccessibleName('Keep position during route optimization')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-167: the place context menu opens the website, maps and collection actions', async () => {
|
||||
@@ -4259,7 +4264,7 @@ describe('DayPlanSidebar', () => {
|
||||
]
|
||||
const onPlaceClick = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, accommodations, onPlaceClick })} />)
|
||||
const badges = dayHeader('Day 2').querySelectorAll('.bg-surface-hover')
|
||||
const badges = dayHeader('Day 2').querySelectorAll('[data-dp="day-pill"]')
|
||||
expect(badges[0].textContent).toBe('Check-out Hotel')
|
||||
expect(badges[1].textContent).toBe('Check-in Hotel')
|
||||
await user.click(badges[0])
|
||||
@@ -4275,28 +4280,151 @@ describe('DayPlanSidebar', () => {
|
||||
]
|
||||
const car = buildReservation({ id: 530, type: 'car', title: 'Renault Clio', day_id: 10, end_day_id: 12, location: 'Gare du Nord' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [car] })} />)
|
||||
const badge = dayHeader('Day 2').querySelector('.bg-surface-hover') as HTMLElement
|
||||
const badge = dayHeader('Day 2').querySelector('[data-dp="day-pill"]') as HTMLElement
|
||||
await user.click(badge)
|
||||
expect(await screen.findByText('Gare du Nord')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── The desktop plan's booking detail ──────────────────────────────────
|
||||
// Given onOpenBooking (only the desktop plan passes it), a booking opens its detail
|
||||
// first; the editor is behind that detail's Edit. Without it the tests above pin the
|
||||
// old paths.
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-226: with a detail to show, a transport row opens it instead of the editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const onOpenBooking = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' })
|
||||
const flight = buildReservation({ id: 200, type: 'flight', title: 'Air France 123', reservation_time: '2025-06-01T08:00:00', day_id: 10 })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [flight], onEditTransport, onOpenBooking })} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Air France 123/ }))
|
||||
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(flight)
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-227: a transit journey opens the same detail, not its journey view', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenTransit = vi.fn()
|
||||
const onOpenBooking = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const journey = {
|
||||
...buildReservation({ id: 300, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', day_id: 10 }),
|
||||
metadata: { transit: { provider: 'transitous', duration: 1800, transfers: 0, legs: [{ mode: 'SUBWAY', line: 'U2', duration: 1440, from: { name: 'Alexanderplatz' }, to: { name: 'Zoo' } }] } },
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [journey as unknown as Reservation], onOpenTransit, onOpenBooking })} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Fernsehturm/ }))
|
||||
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(expect.objectContaining({ id: 300 }))
|
||||
expect(onOpenTransit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-228: a viewer without day rights can still open a booking row, by click or by keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPermissions.canEdit = false
|
||||
const onOpenBooking = vi.fn()
|
||||
const onEditReservation = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const dinner = buildReservation({ id: 210, type: 'restaurant', title: 'Dinner at Kikunoi', reservation_time: '2025-06-01T19:00:00', day_id: 10 })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [dinner], onEditReservation, onOpenBooking })} />)
|
||||
|
||||
const row = cardRow(screen.getByText('Dinner at Kikunoi'))
|
||||
expect(row).toHaveAttribute('role', 'button')
|
||||
expect(row.style.cursor).toBe('pointer')
|
||||
await user.click(row)
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(1)
|
||||
|
||||
row.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenBooking).toHaveBeenLastCalledWith(dinner)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-229: without a detail to show, a viewer without day rights still gets nothing from a booking row', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPermissions.canEdit = false
|
||||
const onEditReservation = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const dinner = buildReservation({ id: 211, type: 'restaurant', title: 'Dinner', reservation_time: '2025-06-01T19:00:00', day_id: 10 })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [dinner], onEditReservation })} />)
|
||||
|
||||
const row = cardRow(screen.getByText('Dinner'))
|
||||
expect(row.style.cursor).toBe('default')
|
||||
await user.click(row)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-230: with a detail to show, an active rental badge opens it instead of the old transport view', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const car = buildReservation({ id: 530, type: 'car', title: 'Renault Clio', day_id: 10, end_day_id: 12, location: 'Gare du Nord' })
|
||||
const onOpenBooking = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [car], onOpenBooking })} />)
|
||||
|
||||
await user.click(within(dayHeader('Day 2')).getByRole('button', { name: 'Renault Clio' }))
|
||||
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(car)
|
||||
expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-231: the pencil on a booking pinned to a stop still goes straight to the editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Geneva Airport' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as Partial<Reservation>)
|
||||
const onEditTransport = vi.fn()
|
||||
const onOpenBooking = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditTransport, onOpenBooking,
|
||||
})} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onOpenBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-178: the add-transport shortcut targets the day it sits on', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onAddTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAddTransport })} />)
|
||||
await user.click(screen.getByLabelText('Add transport'))
|
||||
await pickDayAdd(user, 'Add transport')
|
||||
expect(onAddTransport).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-225: the day\'s "+" offers a stay, and only with the handler for it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onAddAccommodation = vi.fn()
|
||||
const { unmount } = render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAddAccommodation })} />)
|
||||
await pickDayAdd(user, 'Add accommodation')
|
||||
expect(onAddAccommodation).toHaveBeenCalledWith(expect.objectContaining({ id: 10 }))
|
||||
unmount()
|
||||
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(within(dayHeader('Day 1')).getByRole('button', { name: 'Add to day' }))
|
||||
expect(screen.queryByRole('button', { name: 'Add accommodation' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-179: hovering a day header tints it and clears the tint again', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const header = dayHeader('Day 1')
|
||||
const rest = header.style.background
|
||||
fireEvent.mouseEnter(header)
|
||||
expect(header.style.background).toBe('var(--bg-tertiary)')
|
||||
expect(header.style.background).toBe('color-mix(in srgb, var(--accent) 10%, transparent)')
|
||||
fireEvent.mouseLeave(header)
|
||||
expect(header.style.background).toBe('transparent')
|
||||
expect(header.style.background).toBe(rest)
|
||||
})
|
||||
|
||||
// ── Chronology guards ────────────────────────────────────────────────────
|
||||
@@ -4467,7 +4595,7 @@ describe('DayPlanSidebar', () => {
|
||||
() => cardRow(screen.getByText('Source taxi')),
|
||||
]
|
||||
const targets = [
|
||||
() => document.querySelectorAll('[style*="padding-top: 6px"]')[1] as HTMLElement,
|
||||
() => document.querySelectorAll('[data-dp="day-body"]')[1] as HTMLElement,
|
||||
() => endZones()[1] as HTMLElement,
|
||||
() => cardRow(screen.getByText('Target note')),
|
||||
() => dragRow(screen.getByText('Target place')),
|
||||
@@ -4505,7 +4633,7 @@ describe('DayPlanSidebar', () => {
|
||||
fireEvent.mouseEnter(expand)
|
||||
fireEvent.mouseLeave(expand)
|
||||
await user.click(expand)
|
||||
expect(screen.getAllByLabelText('Collapse').some(el => !el.closest('.dp-day-actions'))).toBe(true)
|
||||
expect(screen.getAllByLabelText('Collapse').some(el => !el.closest('.dp-day-tools'))).toBe(true)
|
||||
await user.click(cardRow(screen.getByText('U2 to Zoo')))
|
||||
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,21 @@
|
||||
import { Wallet } from 'lucide-react'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
interface DayPlanSidebarFooterProps {
|
||||
totalCostLabel: string | null
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
}
|
||||
|
||||
/** The trip's total cost at the foot of the day list, on the panel's tinted band. */
|
||||
export function DayPlanSidebarFooter({ totalCostLabel, t }: DayPlanSidebarFooterProps) {
|
||||
if (!totalCostLabel) return null
|
||||
return (
|
||||
<div className="border-t border-edge-faint" style={{ flexShrink: 0, padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{t('dayplan.totalCost')}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{totalCostLabel}</span>
|
||||
<div className="flex flex-none items-center justify-between gap-3 border-t border-edge-faint px-4 py-2.5" style={{ background: NEUTRAL_TINT }}>
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{t('dayplan.totalCost')}</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1 font-geist font-bold tabular-nums text-content shadow-sm" style={fs(12, 'body')}>
|
||||
<Wallet size={12} strokeWidth={2.2} className="text-content-faint" />
|
||||
{totalCostLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// FE-PLANNER-NOTEMODAL-001 to FE-PLANNER-NOTEMODAL-014
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
|
||||
// `t` arrives as a prop, so echoing the key keeps the assertions exact. The
|
||||
// shared pieces (the close and delete buttons, the colour swatches) read the
|
||||
// English catalogue.
|
||||
const t = (key: string) => key
|
||||
|
||||
type Ui = { mode: 'add' | 'edit'; noteId?: number; icon: string; text: string; time: string; color?: string | null }
|
||||
|
||||
/** Holds the note map like the planner's hook does, so typing reaches the fields. */
|
||||
function setup(initial: Record<string, Ui | undefined>, overrides: Partial<React.ComponentProps<typeof DayPlanSidebarNoteModal>> = {}) {
|
||||
let state = initial
|
||||
const cancelNote = vi.fn()
|
||||
const saveNote = vi.fn()
|
||||
const onRequestDelete = vi.fn()
|
||||
const setNoteUi = vi.fn((updater: (prev: typeof state) => typeof state) => {
|
||||
state = updater(state)
|
||||
view.rerender(ui())
|
||||
})
|
||||
const ui = () => (
|
||||
<DayPlanSidebarNoteModal
|
||||
noteUi={state}
|
||||
setNoteUi={setNoteUi}
|
||||
noteInputRef={{ current: null }}
|
||||
cancelNote={cancelNote}
|
||||
saveNote={saveNote}
|
||||
onRequestDelete={onRequestDelete}
|
||||
t={t}
|
||||
{...overrides}
|
||||
/>
|
||||
)
|
||||
const view = render(ui())
|
||||
return { cancelNote, saveNote, onRequestDelete, setNoteUi, get state() { return state } }
|
||||
}
|
||||
|
||||
const adding = (fields: Partial<Ui> = {}): Ui => ({ mode: 'add', icon: 'FileText', text: '', time: '', color: null, ...fields })
|
||||
|
||||
describe('DayPlanSidebarNoteModal', () => {
|
||||
it('FE-PLANNER-NOTEMODAL-001: nothing open, nothing shown', () => {
|
||||
setup({ '10': undefined })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-002: a new note is a dialog named "Add Note" with the title field focused', () => {
|
||||
setup({ '10': adding() })
|
||||
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'dayplan.noteTitle' })).toHaveFocus()
|
||||
// Nothing to delete yet.
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-003: typing the title reaches the day entry and the preview card', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding() })
|
||||
// An empty title cannot be added.
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
await user.type(screen.getByRole('textbox', { name: 'dayplan.noteTitle' }), 'Ferry')
|
||||
expect(s.state['10']?.text).toBe('Ferry')
|
||||
expect(screen.getByText('Ferry')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-004: Enter in the title saves the note', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding({ text: 'Ferry' }) })
|
||||
await user.type(screen.getByRole('textbox', { name: 'dayplan.noteTitle' }), '{Enter}')
|
||||
expect(s.saveNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-005: Save and Cancel answer for their own day', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding({ text: 'Ferry' }) })
|
||||
await user.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
expect(s.saveNote).toHaveBeenCalledWith(10)
|
||||
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(s.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-006: Escape in the title or the body cancels', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding({ text: 'Ferry' }) })
|
||||
await user.keyboard('{Escape}')
|
||||
expect(s.cancelNote).toHaveBeenCalledWith(10)
|
||||
s.cancelNote.mockClear()
|
||||
await user.click(screen.getByRole('textbox', { name: 'dayplan.noteSubtitle' }))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(s.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
// The lost-note bug: a selection dragged out of a field and let go over the
|
||||
// backdrop used to count as a click on it and threw the note away.
|
||||
it('FE-PLANNER-NOTEMODAL-007: a press that starts in the dialog and ends on the backdrop keeps the note', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding({ text: 'Ferry' }) })
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.mouseDown(screen.getByRole('textbox', { name: 'dayplan.noteTitle' }))
|
||||
fireEvent.click(dialog.parentElement as HTMLElement)
|
||||
expect(s.cancelNote).not.toHaveBeenCalled()
|
||||
// A press that starts and ends on the backdrop still closes.
|
||||
await user.click(dialog.parentElement as HTMLElement)
|
||||
expect(s.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-008: the icon grid marks the pick and names each icon', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding() })
|
||||
const group = screen.getByRole('group', { name: 'dayplan.noteIcon' })
|
||||
expect(group.querySelectorAll('button')).toHaveLength(32)
|
||||
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Plane' }))
|
||||
expect(s.state['10']?.icon).toBe('Plane')
|
||||
expect(screen.getByRole('button', { name: 'Plane' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-009: the shared tooltip names an icon on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({ '10': adding() })
|
||||
const plane = screen.getByRole('button', { name: 'Plane' })
|
||||
expect(plane).not.toHaveAttribute('title')
|
||||
await user.hover(plane)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Plane')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-010: a colour pick tints the head band', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding({ text: 'Ferry' }) })
|
||||
await user.click(screen.getByRole('button', { name: 'Red' }))
|
||||
expect(s.state['10']?.color).toBe('#dc2626')
|
||||
const header = screen.getByRole('dialog').querySelector('header') as HTMLElement
|
||||
// jsdom normalises the hex inside color-mix() to rgb().
|
||||
expect(header.style.background).toContain('220, 38, 38')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-011: the body renders as markdown under the field, with a counter that warns near the limit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding({ text: 'Ferry', time: 'book **early**' }) })
|
||||
expect(screen.getByText('early').tagName).toBe('STRONG')
|
||||
expect(screen.getByText('notes.markdownHint')).toBeInTheDocument()
|
||||
expect(screen.getByText('14/2000')).toHaveClass('text-content-faint')
|
||||
const body = screen.getByRole('textbox', { name: 'dayplan.noteSubtitle' })
|
||||
expect(body).toHaveAttribute('maxLength', '2000')
|
||||
expect(body).toHaveAttribute('rows', '6')
|
||||
fireEvent.change(body, { target: { value: 'x'.repeat(1900) } })
|
||||
expect(s.state['10']?.time).toHaveLength(1900)
|
||||
expect(screen.getByText('1900/2000')).toHaveClass('text-warning')
|
||||
// The formatting bar writes into the same field.
|
||||
await user.click(screen.getByRole('button', { name: 'Bold' }))
|
||||
expect(s.setNoteUi).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-012: an edited note is named "Edit Note", saves, and asks the planner before it deletes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': { mode: 'edit', noteId: 55, icon: 'Plane', text: 'Ferry', time: '', color: null } })
|
||||
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
expect(s.saveNote).toHaveBeenCalledWith(10)
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(s.onRequestDelete).toHaveBeenCalledWith(10, 55)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-013: the head band close button cancels', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup({ '10': adding() })
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(s.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NOTEMODAL-014: the two columns stack on a narrow window', () => {
|
||||
setup({ '10': adding() })
|
||||
const columns = screen.getByRole('group', { name: 'dayplan.noteIcon' }).closest('.grid') as HTMLElement
|
||||
expect(columns).toHaveClass('grid-cols-1')
|
||||
expect(columns.className).toMatch(/sm:grid-cols-\[210px_minmax\(0,1fr\)\]/)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useId, useRef } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -8,6 +7,11 @@ import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import NoteColorPicker from '../shared/NoteColorPicker'
|
||||
import { noteSurface } from './noteSurface'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { EditorField, TEXTAREA } from '../shared/dialogParts'
|
||||
import { EYEBROW } from './bookings/bookingParts'
|
||||
import { tintOf } from './planParts'
|
||||
|
||||
interface NoteModalUi {
|
||||
mode: 'add' | 'edit'
|
||||
@@ -23,179 +27,165 @@ type NoteUiMap = Record<string, NoteModalUi | undefined>
|
||||
interface DayPlanSidebarNoteModalProps {
|
||||
noteUi: NoteUiMap
|
||||
setNoteUi: (updater: (prev: NoteUiMap) => NoteUiMap) => void
|
||||
/**
|
||||
* The hook's handle on the title field. The field sits in the dialog's head
|
||||
* band now and takes the focus itself when the dialog opens, so the hook's
|
||||
* delayed focus call finds nothing to do.
|
||||
*/
|
||||
noteInputRef: React.RefObject<HTMLInputElement>
|
||||
cancelNote: (dayId: number) => void
|
||||
saveNote: (dayId: number) => void
|
||||
/** Asks the planner to confirm and then delete the note being edited (#2249). */
|
||||
onRequestDelete: (dayId: number, noteId: number) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
type NoteDialogProps = Omit<DayPlanSidebarNoteModalProps, 'noteUi' | 'noteInputRef'> & { dayId: string; ui: NoteModalUi | undefined }
|
||||
|
||||
const BODY_MAX = 2000
|
||||
|
||||
/**
|
||||
* Add/edit dialog for a day note (#1629).
|
||||
* Add/edit dialog for a day note (#1629), one per day whose note is open.
|
||||
*
|
||||
* Two columns on anything wider than a phone: how the note looks on the left
|
||||
* (icon, colour, and a preview of the card it will become), what it says on the
|
||||
* right. The preview is the reason the colour picker is worth having — a swatch
|
||||
* row tells you nothing about what a tinted card looks like next to a place.
|
||||
* The title is typed into the head band, whose tile already shows the icon in
|
||||
* the note's colour. Below it two columns on anything wider than a phone: how
|
||||
* the note looks on the left (icon, colour, and a preview of the card it will
|
||||
* become), what it says on the right. The preview is the reason the colour
|
||||
* picker is worth having: a swatch row tells you nothing about what a tinted
|
||||
* card looks like next to a place.
|
||||
*/
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
|
||||
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
|
||||
document.body
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => (
|
||||
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi}
|
||||
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
dayId: string
|
||||
ui: NoteModalUi
|
||||
}) {
|
||||
function NoteDialog({ dayId, ui, ...rest }: NoteDialogProps) {
|
||||
return ui ? <OpenNoteDialog dayId={dayId} ui={ui} {...rest} /> : null
|
||||
}
|
||||
|
||||
function OpenNoteDialog({ dayId, ui, setNoteUi, cancelNote, saveNote, onRequestDelete, t }: NoteDialogProps & { ui: NoteModalUi }) {
|
||||
const bodyRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const labelId = useId()
|
||||
const bodyId = `${labelId}-body`
|
||||
const day = Number(dayId)
|
||||
const patch = (fields: Partial<NoteModalUi>) =>
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { ...(prev[dayId] as NoteModalUi), ...fields } }))
|
||||
|
||||
const color = ui.color ?? null
|
||||
const surface = noteSurface(color)
|
||||
const PreviewIcon = getNoteIcon(ui.icon)
|
||||
const NoteIcon = getNoteIcon(ui.icon)
|
||||
const canSave = Boolean(ui.text?.trim())
|
||||
const bodyLen = ui.time?.length || 0
|
||||
|
||||
const header = (
|
||||
<DialogHeader
|
||||
tile={<DialogTile><NoteIcon size={20} strokeWidth={1.9} color={color ?? 'var(--text-muted)'} /></DialogTile>}
|
||||
tint={color ? tintOf(color, 12) : NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={() => cancelNote(day)}
|
||||
eyebrow={ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
|
||||
titleInput={{
|
||||
value: ui.text,
|
||||
onChange: text => patch({ text }),
|
||||
label: t('dayplan.noteTitle'),
|
||||
placeholder: t('dayplan.noteTitle'),
|
||||
autoFocus: true,
|
||||
required: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter') saveNote(day) },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// Delete lives here rather than on the row: the row's floating pencil/trash
|
||||
// pill covered the reorder chevrons and, on a coarse pointer, never went
|
||||
// away (#2249). The planner still confirms before it deletes.
|
||||
const footer = (
|
||||
<DialogFooter>
|
||||
{ui.mode === 'edit' && ui.noteId != null && <DeleteButton onClick={() => onRequestDelete(day, ui.noteId!)} />}
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={() => cancelNote(day)}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => saveNote(day)} disabled={!canSave}>
|
||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
||||
backdropFilter: 'blur(3px)',
|
||||
}} role="presentation" onClick={() => cancelNote(Number(dayId))}>
|
||||
<div className="bg-surface-card note-modal" style={{
|
||||
width: '100%', maxWidth: 720, maxHeight: '90vh', overflowY: 'auto', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
}} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>
|
||||
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
|
||||
<DialogShell onClose={() => cancelNote(day)} labelledBy={labelId} width="editor" header={header} footer={footer}>
|
||||
<div className="grid grid-cols-1 items-start gap-5 sm:grid-cols-[210px_minmax(0,1fr)]">
|
||||
{/* Left: how it looks. */}
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<EditorField label={t('dayplan.noteIcon')}>
|
||||
<div role="group" aria-label={t('dayplan.noteIcon')} className="flex flex-wrap gap-[5px]">
|
||||
{NOTE_ICONS.map(({ id, Icon }) => {
|
||||
const on = ui.icon === id
|
||||
// The icons have no names of their own in the catalogue, so the id is what the tooltip says.
|
||||
return (
|
||||
<Tooltip key={id} label={id}>
|
||||
<button type="button" onClick={() => patch({ icon: id })} aria-pressed={on} aria-label={id}
|
||||
className={`grid h-[38px] w-[38px] place-items-center rounded-[10px] border-2 ${on ? 'border-content bg-surface-hover' : 'border-edge-faint hover:bg-surface-hover'}`}>
|
||||
<Icon size={16} strokeWidth={1.8} color={on ? (color ?? 'var(--text-primary)') : 'var(--text-muted)'} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('notes.color.label')}>
|
||||
<NoteColorPicker value={color} onChange={c => patch({ color: c })} />
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('notes.preview')}>
|
||||
{/* The same shape the card takes in the day plan. */}
|
||||
<div className="flex items-center gap-2 rounded-[10px] border px-2 py-[7px]" style={{ borderColor: surface.border, background: surface.background }}>
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-full" style={{ background: surface.iconBackground }}>
|
||||
<NoteIcon size={13} strokeWidth={1.8} color={surface.iconColor} />
|
||||
</span>
|
||||
<span className="min-w-0 break-words font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{ui.text?.trim() || t('dayplan.noteTitle')}
|
||||
</span>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
<div className="note-modal-cols" style={{ display: 'grid', gridTemplateColumns: '210px 1fr', gap: 20, alignItems: 'start' }}>
|
||||
{/* Left — how it looks. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
|
||||
{t('dayplan.noteIcon')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{NOTE_ICONS.map(({ id, Icon }) => (
|
||||
<button key={id} type="button" onClick={() => patch({ icon: id })} title={id}
|
||||
aria-pressed={ui.icon === id}
|
||||
className={ui.icon === id ? 'bg-surface-hover' : 'bg-transparent'}
|
||||
style={{ width: 38, height: 38, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||
<Icon size={16} strokeWidth={1.8} color={ui.icon === id ? (color ?? 'var(--text-primary)') : 'var(--text-muted)'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Right: what it says. */}
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex flex-wrap items-center justify-between gap-2">
|
||||
<label htmlFor={bodyId} className={EYEBROW} style={fs(9.5)}>{t('dayplan.noteSubtitle')}</label>
|
||||
<NoteFormatToolbar textareaRef={bodyRef} onChange={v => patch({ time: v })} compact />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
|
||||
{t('notes.color.label')}
|
||||
</div>
|
||||
<NoteColorPicker value={color} onChange={c => patch({ color: c })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
|
||||
{t('notes.preview')}
|
||||
</div>
|
||||
{/* The same shape the card takes in the day plan. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px', borderRadius: 6, border: `1px solid ${surface.border}`, background: surface.background }}>
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'grid', placeItems: 'center', borderRadius: '50%', background: surface.iconBackground }}>
|
||||
<PreviewIcon size={13} strokeWidth={1.8} color={surface.iconColor} />
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, minWidth: 0, wordBreak: 'break-word' }}>
|
||||
{ui.text?.trim() || t('dayplan.noteTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id={bodyId}
|
||||
ref={bodyRef}
|
||||
value={ui.time}
|
||||
maxLength={BODY_MAX}
|
||||
rows={6}
|
||||
onChange={e => patch({ time: e.target.value })}
|
||||
placeholder={t('notes.bodyPlaceholder')}
|
||||
className={`${TEXTAREA} resize-y`}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between gap-2" style={fs(10.5)}>
|
||||
<span className="text-content-faint">{t('notes.markdownHint')}</span>
|
||||
<span className={`tabular-nums ${bodyLen >= BODY_MAX - 100 ? 'text-warning' : 'text-content-faint'}`}>{bodyLen}/{BODY_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — what it says. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
|
||||
<div>
|
||||
<label className="text-content-faint" htmlFor="note-title" style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
|
||||
{t('dayplan.noteTitle')} *
|
||||
</label>
|
||||
<input
|
||||
id="note-title"
|
||||
ref={noteInputRef}
|
||||
type="text"
|
||||
value={ui.text}
|
||||
onChange={e => patch({ text: e.target.value })}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteTitle')}
|
||||
required
|
||||
className="text-content bg-surface-input"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' }}
|
||||
/>
|
||||
{ui.time?.trim() && (
|
||||
<div className="collab-note-md-full max-h-[200px] overflow-y-auto rounded-[10px] border px-3 py-2.5 leading-[1.55] text-content-muted"
|
||||
style={{ ...fs(12, 'body'), borderColor: surface.border, background: surface.background, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{ui.time}</Markdown>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 7, flexWrap: 'wrap' }}>
|
||||
<label className="text-content-faint" htmlFor="note-body" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em' }}>
|
||||
{t('dayplan.noteSubtitle')}
|
||||
</label>
|
||||
<NoteFormatToolbar textareaRef={bodyRef} onChange={v => patch({ time: v })} compact />
|
||||
</div>
|
||||
<textarea
|
||||
id="note-body"
|
||||
ref={bodyRef}
|
||||
value={ui.time}
|
||||
maxLength={BODY_MAX}
|
||||
rows={6}
|
||||
onChange={e => patch({ time: e.target.value })}
|
||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('notes.bodyPlaceholder')}
|
||||
className="text-content bg-surface-input"
|
||||
style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'vertical', lineHeight: 1.5 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 4 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('notes.markdownHint')}</span>
|
||||
<span className={bodyLen >= BODY_MAX - 100 ? 'text-[#d97706]' : 'text-content-faint'} style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{bodyLen}/{BODY_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ui.time?.trim() && (
|
||||
<div className="collab-note-md-full text-content-muted" style={{ borderRadius: 10, border: `1px solid ${surface.border}`, background: surface.background, padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.55, wordBreak: 'break-word', overflowWrap: 'anywhere', maxHeight: 200, overflowY: 'auto' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{ui.time}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete lives here rather than on the row: the row's floating pencil/trash
|
||||
pill covered the reorder chevrons and, on a coarse pointer, never went
|
||||
away (#2249). The planner still confirms before it deletes. */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{ui.mode === 'edit' && ui.noteId != null && (
|
||||
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
|
||||
className="text-danger"
|
||||
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
|
||||
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: 'none', borderRadius: 8, padding: '7px 17px', cursor: !canSave ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Car, Footprints, Hotel, Zap } from 'lucide-react'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
|
||||
// its legs carry the profile-true durationText anyway, the icon just signals that
|
||||
@@ -10,29 +11,31 @@ export function profileIcon(profile: string) {
|
||||
return Car
|
||||
}
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: string }) {
|
||||
/** The leg's figures as one quiet pill: mode, time, distance and a router note, side by side. */
|
||||
function LegPill({ seg, profile }: { seg: RouteSegment; profile: string }) {
|
||||
// The leg's own mode (#1281) wins over the day-wide fallback for icon + text.
|
||||
const effProfile = seg.mode ?? profile
|
||||
const driving = effProfile !== 'walking'
|
||||
const Icon = profileIcon(effProfile)
|
||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
{seg.noteText && (
|
||||
<>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{seg.noteText}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={line} />
|
||||
<span className="inline-flex flex-none items-center gap-1.5 whitespace-nowrap rounded-full bg-surface-secondary px-2 py-[2px] font-geist font-semibold tabular-nums text-content-muted" style={fs(10)}>
|
||||
<Icon size={11} strokeWidth={2} className="flex-none" />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span className="text-content-faint">{seg.distanceText}</span>
|
||||
{seg.noteText && <span className="text-content-secondary">{seg.noteText}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const LINE = 'h-px min-w-3 flex-1 bg-edge-faint'
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-[3px]">
|
||||
<span className={LINE} />
|
||||
<LegPill seg={seg} profile={profile} />
|
||||
<span className={LINE} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,38 +57,23 @@ export function HotelRouteConnector({
|
||||
name: string
|
||||
placement: 'top' | 'bottom'
|
||||
}) {
|
||||
const effProfile = seg.mode ?? profile
|
||||
const driving = effProfile !== 'walking'
|
||||
const Icon = profileIcon(effProfile)
|
||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||
const hotelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
<div className="flex min-w-0 items-center justify-center gap-1.5 px-3">
|
||||
<Hotel size={12} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="truncate font-semibold text-content-muted" style={{ ...fs(11), lineHeight: 1.2 }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const travelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
{seg.noteText && (
|
||||
<>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{seg.noteText}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={line} />
|
||||
<div className="flex items-center gap-2 px-3 py-[3px]">
|
||||
<span className={LINE} />
|
||||
<LegPill seg={seg} profile={profile} />
|
||||
<span className={LINE} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||
<div className={`flex flex-col gap-[3px] ${placement === 'top' ? 'pb-1.5 pt-0.5' : 'pb-0.5 pt-1.5'}`}>
|
||||
{placement === 'top' ? (
|
||||
<>
|
||||
{hotelRow}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-PLANNER-TIMECONFIRM-001 to FE-PLANNER-TIMECONFIRM-004
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal'
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
const pending = { dayId: 10, fromId: 22, time: '14:00' }
|
||||
|
||||
function setup(timeConfirm: typeof pending | null = pending) {
|
||||
const setTimeConfirm = vi.fn()
|
||||
const confirmTimeRemoval = vi.fn()
|
||||
render(<DayPlanSidebarTimeConfirmModal timeConfirm={timeConfirm} setTimeConfirm={setTimeConfirm} confirmTimeRemoval={confirmTimeRemoval} t={t} />)
|
||||
return { setTimeConfirm, confirmTimeRemoval }
|
||||
}
|
||||
|
||||
describe('DayPlanSidebarTimeConfirmModal', () => {
|
||||
it('FE-PLANNER-TIMECONFIRM-001: nothing pending, nothing asked', () => {
|
||||
setup(null)
|
||||
expect(screen.queryByText('dayplan.confirmRemoveTimeTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TIMECONFIRM-002: asks with the time that would be dropped', () => {
|
||||
setup()
|
||||
expect(screen.getByText('dayplan.confirmRemoveTimeTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.confirmRemoveTimeBody|14:00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TIMECONFIRM-003: Confirm removes the time, Cancel leaves it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup()
|
||||
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(s.setTimeConfirm).toHaveBeenCalledWith(null)
|
||||
expect(s.confirmTimeRemoval).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }))
|
||||
expect(s.confirmTimeRemoval).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TIMECONFIRM-004: Escape takes the question back', async () => {
|
||||
const user = userEvent.setup()
|
||||
const s = setup()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(s.setTimeConfirm).toHaveBeenCalledWith(null)
|
||||
expect(s.confirmTimeRemoval).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Clock } from 'lucide-react'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
interface TimeConfirmState {
|
||||
dayId: number
|
||||
@@ -16,48 +15,24 @@ interface DayPlanSidebarTimeConfirmModalProps {
|
||||
timeConfirm: TimeConfirmState | null
|
||||
setTimeConfirm: (v: TimeConfirmState | null) => void
|
||||
confirmTimeRemoval: () => void
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before a move drops a place's time: a timed place moved out of the
|
||||
* day's time order cannot keep its time there. The shared question dialog
|
||||
* carries the look, Escape and the backdrop.
|
||||
*/
|
||||
export function DayPlanSidebarTimeConfirmModal({ timeConfirm, setTimeConfirm, confirmTimeRemoval, t }: DayPlanSidebarTimeConfirmModalProps) {
|
||||
if (!timeConfirm) return null
|
||||
return createPortal(
|
||||
<div role="presentation" className="bg-[rgba(0,0,0,0.3)]" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTimeConfirm(null)}>
|
||||
<div role="presentation" className="bg-surface-card" style={{
|
||||
width: 340, borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="bg-[rgba(239,68,68,0.12)]" style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
}}>
|
||||
<Clock size={18} strokeWidth={1.8} color="#ef4444" />
|
||||
</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('dayplan.confirmRemoveTimeTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-content-secondary" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}>
|
||||
{t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button type="button" onClick={() => setTimeConfirm(null)} className="text-content-muted" style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={confirmTimeRemoval} className="bg-[#ef4444] text-white" style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>{t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
return (
|
||||
<ConfirmDialog
|
||||
isOpen={!!timeConfirm}
|
||||
onClose={() => setTimeConfirm(null)}
|
||||
onConfirm={confirmTimeRemoval}
|
||||
title={t('dayplan.confirmRemoveTimeTitle')}
|
||||
message={timeConfirm ? t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time }) : ''}
|
||||
confirmLabel={t('common.confirm')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,15 +197,16 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(setUndoHover).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-017: the undo tooltip names the last action when there is one', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ onUndo: vi.fn(), canUndo: true, undoHover: true, lastActionLabel: 'Reorder' })} />)
|
||||
expect(screen.getByText('undo.tooltip|Reorder')).toBeInTheDocument()
|
||||
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: the undo tooltip falls back to the plain label with nothing to undo', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ onUndo: vi.fn(), canUndo: false, undoHover: true, lastActionLabel: 'Reorder' })} />)
|
||||
// Both the aria-label and the tooltip carry the same text here.
|
||||
expect(screen.getAllByText('undo.button').length + screen.getAllByLabelText('undo.button').length).toBeGreaterThan(1)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -301,22 +302,13 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(screen.getByRole('button', { name: 'map.hideAllConnections' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-023: the icon buttons paint a hover background and clear it again', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('FE-PLANNER-DPTOOLBAR-023: the icon buttons rise like an active tab while they are on', () => {
|
||||
const days = [buildDay({ id: 10 })]
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, onToggleAllConnections: vi.fn(), reservations: [routableReservation()] })} />)
|
||||
|
||||
const expandBtn = screen.getByRole('button', { name: 'dayplan.expandAll' })
|
||||
await user.hover(expandBtn)
|
||||
expect(expandBtn.style.background).toBe('var(--bg-hover)')
|
||||
await user.unhover(expandBtn)
|
||||
expect(expandBtn.style.background).toBe('transparent')
|
||||
|
||||
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, onToggleAllConnections: vi.fn(), reservations: [routableReservation()] })} />)
|
||||
const routeBtn = screen.getByRole('button', { name: 'map.showAllConnections' })
|
||||
await user.hover(routeBtn)
|
||||
expect(routeBtn.style.background).toBe('var(--bg-hover)')
|
||||
await user.unhover(routeBtn)
|
||||
expect(routeBtn.style.background).toBe('transparent')
|
||||
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')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { BarButton, PANEL_BAR } from './planParts'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
@@ -57,188 +59,100 @@ export function DayPlanSidebarToolbar({
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
const undoTip = canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')
|
||||
const connectionsLabel = t(allConnectionsShown ? 'map.hideAllConnections' : 'map.showAllConnections')
|
||||
|
||||
// The panel's head band: the export as its one labelled action, the tools as
|
||||
// quiet icons that rise like a filter tab when they are on.
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{/* One export button instead of three: PDF, ICS and GPX each carried
|
||||
their own hover menu, and on a narrower sidebar the row ran out of
|
||||
width and pushed them off the edge. The dialog holds every option. */}
|
||||
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={exportOpen}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
<div className={PANEL_BAR} style={{ background: NEUTRAL_TINT }}>
|
||||
{/* One export button instead of three: PDF, ICS and GPX each carried
|
||||
their own hover menu, and on a narrower sidebar the row ran out of
|
||||
width and pushed them off the edge. The dialog holds every option. */}
|
||||
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={exportOpen}
|
||||
className="inline-flex h-8 flex-none items-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text hover:opacity-90"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Download size={13} strokeWidth={2.2} />
|
||||
{t('dayplan.export')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<TripExportModal
|
||||
isOpen={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
dayNotes={dayNotes}
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
canManageShare={canManageShare}
|
||||
/>
|
||||
<span className="flex-1" />
|
||||
{onUndo && (
|
||||
<Tooltip label={undoTip} placement="bottom">
|
||||
<button type="button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
aria-label={t('undo.button')}
|
||||
onMouseEnter={() => setUndoHover(true)}
|
||||
onMouseLeave={() => setUndoHover(false)}
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] text-content-muted transition-colors enabled:hover:bg-surface-card enabled:hover:text-content disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} strokeWidth={2} />
|
||||
{t('dayplan.export')}
|
||||
<Undo2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<TripExportModal
|
||||
isOpen={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
dayNotes={dayNotes}
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
canManageShare={canManageShare}
|
||||
/>
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
return (
|
||||
<Tooltip label={label} placement="bottom">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const next = allExpanded ? new Set<number>() : new Set(days.map(d => d.id))
|
||||
setExpandedDays(next)
|
||||
// Same store the sidebar reads on mount — a sessionStorage write
|
||||
// here left the persisted set behind after a reload.
|
||||
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
|
||||
}}
|
||||
aria-label={label}
|
||||
aria-pressed={allExpanded}
|
||||
style={{
|
||||
position: 'relative', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: allExpanded ? 0 : 1,
|
||||
transform: allExpanded ? 'translateY(-8px) scale(0.6)' : 'translateY(0) scale(1)',
|
||||
}}>
|
||||
<ChevronsUpDown size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: allExpanded ? 1 : 0,
|
||||
transform: allExpanded ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.6)',
|
||||
}}>
|
||||
<ChevronsDownUp size={14} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
{onUndo && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button type="button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
aria-label={t('undo.button')}
|
||||
onMouseEnter={() => setUndoHover(true)}
|
||||
onMouseLeave={() => setUndoHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: canUndo ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
cursor: canUndo ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{undoHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
|
||||
<button type="button"
|
||||
onClick={() => setReorderOpen(v => !v)}
|
||||
aria-label={t('dayplan.reorderDays')}
|
||||
aria-pressed={reorderOpen}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: reorderOpen ? 'var(--bg-hover)' : 'none',
|
||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!reorderOpen) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!reorderOpen) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<ArrowUpDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DayReorderPopup
|
||||
isOpen={reorderOpen}
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
deleteQuestion={deleteDayQuestion}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onToggleAllConnections && reservations.some(isRoutableReservation) && (() => {
|
||||
const label = t(allConnectionsShown ? 'map.hideAllConnections' : 'map.showAllConnections')
|
||||
return (
|
||||
<Tooltip label={label} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAllConnections}
|
||||
aria-label={label}
|
||||
aria-pressed={allConnectionsShown}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: allConnectionsShown ? 'none' : '1px solid var(--border-primary)',
|
||||
background: allConnectionsShown ? '#3b82f6' : 'none',
|
||||
color: allConnectionsShown ? '#fff' : 'var(--text-primary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!allConnectionsShown) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!allConnectionsShown) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<RouteIcon size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<BarButton
|
||||
label={expandLabel}
|
||||
ariaPressed={allExpanded}
|
||||
onClick={() => {
|
||||
const next = allExpanded ? new Set<number>() : new Set(days.map(d => d.id))
|
||||
setExpandedDays(next)
|
||||
// Same store the sidebar reads on mount — a sessionStorage write
|
||||
// here left the persisted set behind after a reload.
|
||||
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
|
||||
}}
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={15} strokeWidth={2} /> : <ChevronsUpDown size={15} strokeWidth={2} />}
|
||||
</BarButton>
|
||||
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
|
||||
<>
|
||||
<BarButton label={t('dayplan.reorderDays')} ariaPressed={reorderOpen} active={reorderOpen} onClick={() => setReorderOpen(v => !v)}>
|
||||
<ArrowUpDown size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<DayReorderPopup
|
||||
isOpen={reorderOpen}
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
deleteQuestion={deleteDayQuestion}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onToggleAllConnections && reservations.some(isRoutableReservation) && (
|
||||
<BarButton label={connectionsLabel} ariaPressed={allConnectionsShown} active={allConnectionsShown}
|
||||
className={allConnectionsShown ? 'text-info' : ''} onClick={onToggleAllConnections}>
|
||||
<RouteIcon size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-019
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-021
|
||||
import { render, screen, fireEvent, within } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -9,7 +9,8 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
// `t` arrives as a prop here, so echoing the key (plus its params) keeps the
|
||||
// assertions independent of the translation catalogue.
|
||||
// assertions independent of the translation catalogue. The shared pieces the
|
||||
// dialog is built from (the head band, the itinerary) read the English one.
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
@@ -56,8 +57,17 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-002: shows the title, date and time range in the header', () => {
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
expect(screen.getByText('BER → CDG')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Jun 15.*08:30 – 10:05/)).toBeInTheDocument()
|
||||
const dialog = screen.getByRole('dialog', { name: 'BER → CDG' })
|
||||
expect(within(dialog).getByRole('heading', { name: 'BER → CDG' })).toBeInTheDocument()
|
||||
// The day and the time range share one pill, the range in its quieter part.
|
||||
const time = screen.getByText('08:30 → 10:05')
|
||||
expect(time.parentElement).toHaveTextContent(/Jun 15/)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-002b: a booking with a time but no date still shows the time', () => {
|
||||
const res = { ...flight(), reservation_time: '09:15', reservation_end_time: null } as Reservation
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
expect(screen.getByText('09:15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-003: a confirmed booking shows the confirmed badge', () => {
|
||||
@@ -74,6 +84,11 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-005: flight metadata renders airline, number, route and seat', () => {
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
// Every value carries its own label.
|
||||
for (const label of ['reservations.meta.airline', 'reservations.meta.flightNumber', 'reservations.meta.from',
|
||||
'reservations.meta.to', 'reservations.meta.seat', 'reservations.confirmationCode', 'reservations.locationAddress']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByText('Air France')).toBeInTheDocument()
|
||||
expect(screen.getByText('AF1235')).toBeInTheDocument()
|
||||
expect(screen.getByText('BER')).toBeInTheDocument()
|
||||
@@ -105,23 +120,25 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } })
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
const code = screen.getByText('XY7Z9Q')
|
||||
expect(code).toHaveStyle({ filter: 'blur(5px)' })
|
||||
expect(code).toHaveClass('blur-[4px]')
|
||||
// The location is not sensitive, so it stays readable.
|
||||
expect(screen.getByText('Terminal 1')).toHaveStyle({ filter: 'none' })
|
||||
expect(screen.getByText('Terminal 1')).not.toHaveClass('blur-[4px]')
|
||||
expect(screen.getByText('Terminal 1').closest('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-009: hovering and clicking a blurred code reveals and re-hides it', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } })
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
const code = screen.getByText('XY7Z9Q')
|
||||
fireEvent.mouseEnter(code)
|
||||
expect(code.style.filter).toBe('none')
|
||||
fireEvent.mouseLeave(code)
|
||||
expect(code.style.filter).toBe('blur(5px)')
|
||||
const code = screen.getByRole('button', { name: 'XY7Z9Q' })
|
||||
// The pointer lifts the blur while it rests on the code.
|
||||
expect(code).toHaveClass('hover:blur-none')
|
||||
expect(code).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(code)
|
||||
expect(code.style.filter).toBe('none')
|
||||
expect(code).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(code).not.toHaveClass('blur-[4px]')
|
||||
fireEvent.click(code)
|
||||
expect(code.style.filter).toBe('blur(5px)')
|
||||
expect(code).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(code).toHaveClass('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-010: an unblurred field ignores hover and click', () => {
|
||||
@@ -129,7 +146,8 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
const code = screen.getByText('XY7Z9Q')
|
||||
fireEvent.mouseEnter(code)
|
||||
fireEvent.click(code)
|
||||
expect(code.style.filter).toBe('none')
|
||||
expect(code).not.toHaveClass('blur-[4px]')
|
||||
expect(screen.queryByRole('button', { name: 'XY7Z9Q' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-011: a transit journey renders its summary and itinerary legs', () => {
|
||||
@@ -150,13 +168,22 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
},
|
||||
} as unknown as Partial<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
// The summary: each fact a tile with its own label.
|
||||
expect(screen.getByText('transit.min|30')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.transfers|1')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.durationLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.transfersLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.min|4')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.walkLabel')).toBeInTheDocument()
|
||||
// The legs: the shared itinerary list, its facts as separate badges.
|
||||
expect(screen.getByText('transit.itinerary')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.walkTo|Alexanderplatz')).toBeInTheDocument()
|
||||
expect(screen.getByText('Walk to Alexanderplatz')).toBeInTheDocument()
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Zoo')).toBeInTheDocument()
|
||||
expect(screen.getByText(/08:36 – 09:00 · transit\.min\|24 · transit\.stops\|6 · → Ruhleben/)).toBeInTheDocument()
|
||||
for (const badge of ['08:36 → 09:00', '24 min', '6 stops', 'Ruhleben']) {
|
||||
expect(screen.getByText(badge)).toBeInTheDocument()
|
||||
}
|
||||
expect(document.body.textContent).not.toContain('·')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-012: a direct, long, walk-free journey renders hours and the direct label', () => {
|
||||
@@ -170,14 +197,33 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
},
|
||||
} as unknown as Partial<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
expect(screen.getByText('2 h 0 min')).toBeInTheDocument()
|
||||
// Hours come through the catalogue, not a hard-coded "h".
|
||||
expect(screen.getByText('dawarich.duration.hours|2')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.direct')).toBeInTheDocument()
|
||||
// Under a minute of walking is not worth a chip.
|
||||
expect(screen.queryByText('transit.min|1')).not.toBeInTheDocument()
|
||||
// Under a minute of walking is not worth a tile.
|
||||
expect(screen.queryByText('transit.walkLabel')).not.toBeInTheDocument()
|
||||
// No line name on this leg, so the badge falls back to the mode.
|
||||
expect(screen.getByText('BUS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-012b: hours with minutes left over name both', () => {
|
||||
const res = buildReservation({
|
||||
id: 46, type: 'transit', title: 'Longer haul', status: 'confirmed',
|
||||
metadata: { transit: { duration: 5400, legs: [{ mode: 'RAIL', duration: 5400, from: { name: 'A' }, to: { name: 'B' } }] } },
|
||||
} as unknown as Partial<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
expect(screen.getByText('dawarich.duration.hoursMinutes|1|30')).toBeInTheDocument()
|
||||
// Without a stored count the transfers come from the legs: one ride is direct.
|
||||
expect(screen.getByText('transit.direct')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-012c: metadata that is not JSON reads as none instead of throwing', () => {
|
||||
const res = buildReservation({ id: 47, type: 'flight', title: 'Broken', metadata: '{not json' } as Partial<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
expect(screen.getByRole('dialog', { name: 'Broken' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('reservations.meta.airline')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-013: notes render as markdown', () => {
|
||||
const res = { ...flight(), notes: 'Bring **passport**' } as Reservation
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
@@ -207,12 +253,8 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
const setTransportDetail = vi.fn()
|
||||
const onNavigateToFiles = vi.fn()
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), setTransportDetail, onNavigateToFiles })} />)
|
||||
const row = screen.getByText('boarding.pdf').parentElement!
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(row.style.background).toBe('var(--bg-hover)')
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(row.style.background).toBe('var(--bg-tertiary)')
|
||||
await user.click(row)
|
||||
expect(screen.getByText('files.title')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'boarding.pdf' }))
|
||||
expect(setTransportDetail).toHaveBeenCalledWith(null)
|
||||
expect(onNavigateToFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -251,7 +293,7 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
|
||||
expect(screen.getByText('BER → CDG')).toBeInTheDocument()
|
||||
expect(screen.getByText('ABC123')).toHaveStyle({ filter: 'blur(5px)' })
|
||||
expect(screen.getByText('ABC123')).toHaveClass('blur-[4px]')
|
||||
// The booking's own reference keeps its own field, a code-less segment adds none.
|
||||
expect(screen.getByText('XY7Z9Q')).toBeInTheDocument()
|
||||
expect(screen.queryByText('CDG → JFK')).not.toBeInTheDocument()
|
||||
@@ -261,10 +303,30 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
const user = userEvent.setup()
|
||||
const setTransportDetail = vi.fn()
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), setTransportDetail })} />)
|
||||
const card = screen.getByText('BER → CDG').closest('div.bg-surface-card')!
|
||||
const card = screen.getByRole('dialog')
|
||||
await user.click(card)
|
||||
expect(setTransportDetail).not.toHaveBeenCalled()
|
||||
await user.click(card.parentElement!)
|
||||
expect(setTransportDetail).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-020: a press that starts in the card and ends on the backdrop keeps it open', () => {
|
||||
const setTransportDetail = vi.fn()
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), setTransportDetail })} />)
|
||||
const card = screen.getByRole('dialog')
|
||||
fireEvent.mouseDown(card)
|
||||
fireEvent.click(card.parentElement!)
|
||||
expect(setTransportDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-021: Escape and the head band close button close it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setTransportDetail = vi.fn()
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), setTransportDetail })} />)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(setTransportDetail).toHaveBeenCalledWith(null)
|
||||
setTransportDetail.mockClear()
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(setTransportDetail).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,281 +1,223 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Ticket, FileText, ExternalLink, Footprints, ArrowRight, Pencil } from 'lucide-react'
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { CalendarDays, Clock, ExternalLink, FileText, Pencil } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { BlurredCode } from '../shared/BookingCode'
|
||||
import { DialogButton, DialogFooter, DialogSection, DialogShell, FooterSpacer, PILL } from '../shared/DialogShell'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { formatTime, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { RES_ICONS, TRANSPORT_DETAIL_COLORS } from './DayPlanSidebar.constants'
|
||||
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { BookingDialogHeader } from './bookings/BookingDialogShell'
|
||||
import { BOX, Eyebrow, Field, filesFor, fs, toneColor, toneOf } from './bookings/bookingParts'
|
||||
import { TransitLegs, transitSummary } from './bookings/transitParts'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
interface DayPlanSidebarTransportDetailModalProps {
|
||||
transportDetail: Reservation | null
|
||||
setTransportDetail: (v: Reservation | null) => void
|
||||
onNavigateToFiles?: () => void
|
||||
/** Opens the edit form for this reservation (shown as a footer action). */
|
||||
onEdit?: (res: Reservation) => void
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
t: Translate
|
||||
locale: string
|
||||
timeFormat: string
|
||||
}
|
||||
|
||||
export function DayPlanSidebarTransportDetailModal({
|
||||
transportDetail, setTransportDetail, onNavigateToFiles, onEdit, t, locale, timeFormat,
|
||||
}: DayPlanSidebarTransportDetailModalProps) {
|
||||
/** One labelled value of the grid; a booking code is drawn through the blur preference. */
|
||||
interface DetailCell {
|
||||
label: string
|
||||
value: string
|
||||
code?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking a rental pill, a transit row or a map endpoint opens from the
|
||||
* plan: the head band tinted by its status like the booking dialogs, the
|
||||
* booking's facts one labelled field each, a transit journey leg by leg, the
|
||||
* notes and the files, and the edit in the bar at the foot.
|
||||
*/
|
||||
export function DayPlanSidebarTransportDetailModal({ transportDetail, ...rest }: DayPlanSidebarTransportDetailModalProps) {
|
||||
if (!transportDetail) return null
|
||||
return createPortal(
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(3px)',
|
||||
}}
|
||||
role="button" tabIndex={0} aria-label={t('common.cancel')}
|
||||
onClick={() => setTransportDetail(null)}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setTransportDetail(null) }
|
||||
}}>
|
||||
<div className="bg-surface-card" role="presentation" style={{
|
||||
width: 380, maxHeight: '80vh', overflowY: 'auto',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
{(() => {
|
||||
const res = transportDetail
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const TRANSPORT_COLORS = TRANSPORT_DETAIL_COLORS
|
||||
const color = TRANSPORT_COLORS[res.type] || 'var(--text-muted)'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
return <TransportDetailDialog res={transportDetail} {...rest} />
|
||||
}
|
||||
|
||||
const detailFields = []
|
||||
if (res.type === 'flight') {
|
||||
if (meta.airline) detailFields.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||
if (meta.flight_number) detailFields.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||
if (meta.departure_airport) detailFields.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||
if (meta.arrival_airport) detailFields.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
} else if (res.type === 'train') {
|
||||
if (meta.train_number) detailFields.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
}
|
||||
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
|
||||
// A stopover booking can carry its own reference per segment (#1943); the
|
||||
// flat fields above only ever describe the first leg. Marked sensitive so
|
||||
// the blur setting covers them like the booking's own code.
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
|
||||
for (const leg of meta.legs) {
|
||||
if (!leg?.confirmation_number) continue
|
||||
const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
|
||||
detailFields.push({ label: segment || t('reservations.confirmationCode'), value: leg.confirmation_number, sensitive: true })
|
||||
}
|
||||
}
|
||||
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
|
||||
function TransportDetailDialog({ res, setTransportDetail, onNavigateToFiles, onEdit, t, locale, timeFormat }: Omit<DayPlanSidebarTransportDetailModalProps, 'transportDetail'> & { res: Reservation }) {
|
||||
const titleId = useId()
|
||||
const allFiles = useTripStore(s => s.files)
|
||||
const close = () => setTransportDetail(null)
|
||||
const meta = parseReservationMetadata(res)
|
||||
const transit = transitSummary(meta)
|
||||
const files = filesFor(res, allFiles || [])
|
||||
const when = whenOf(res, locale, timeFormat)
|
||||
const cells = detailCells(res, meta, t)
|
||||
const confirmed = res.status === 'confirmed'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
}}>
|
||||
<TransportIcon size={18} strokeWidth={1.8} color={color} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{res.title}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 2 }}>
|
||||
{(() => {
|
||||
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const dateStr = date
|
||||
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||
const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||
const parts: string[] = []
|
||||
if (dateStr) parts.push(dateStr)
|
||||
if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
|
||||
return parts.join(', ')
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={res.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{
|
||||
padding: '3px 8px', borderRadius: 6, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
}}>
|
||||
{res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')}
|
||||
</div>
|
||||
</div>
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (transit && transit.legs.length > 0) {
|
||||
if (transit.duration) stats.push({ value: durationText(transit.duration, t), label: t('transit.durationLabel') })
|
||||
stats.push({ value: transit.transfers > 0 ? String(transit.transfers) : t('transit.direct'), label: t('transit.transfersLabel') })
|
||||
// Under a minute of walking is not worth a tile.
|
||||
if (transit.walk > 59) stats.push({ value: t('transit.min', { count: Math.round(transit.walk / 60) }), label: t('transit.walkLabel') })
|
||||
}
|
||||
|
||||
{/* Detail-Felder */}
|
||||
{detailFields.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{detailFields.map((f, i) => {
|
||||
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
|
||||
const valueStyle = {
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, wordBreak: 'break-word',
|
||||
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
|
||||
cursor: shouldBlur ? 'pointer' : 'default',
|
||||
userSelect: shouldBlur ? 'none' : 'auto',
|
||||
} as const
|
||||
return (
|
||||
<div key={i} className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||
{/* A hidden booking code is a reveal toggle, so it has to be a real
|
||||
button; a plain value stays inert text and out of the tab order. */}
|
||||
{shouldBlur ? (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={e => { e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.filter = 'blur(5px)' }}
|
||||
onClick={e => { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' }}
|
||||
className="text-content"
|
||||
style={{ ...valueStyle, display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: 0, fontFamily: 'inherit' }}
|
||||
>{f.value}</button>
|
||||
) : (
|
||||
<div className="text-content" style={valueStyle}>{f.value}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
const header = (
|
||||
<BookingDialogHeader
|
||||
tone={toneOf(res)}
|
||||
type={res.type}
|
||||
labelId={titleId}
|
||||
onClose={close}
|
||||
title={res.title}
|
||||
pills={(
|
||||
<>
|
||||
<span className={PILL}>
|
||||
<span className="h-2 w-2 flex-none rounded-full" style={{ background: toneColor(confirmed ? 'confirmed' : 'pending') }} />
|
||||
{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}
|
||||
</span>
|
||||
{(when.day || when.time) && (
|
||||
<span className={PILL}>
|
||||
{when.day
|
||||
? <CalendarDays size={13} strokeWidth={2.2} className="text-content-faint" />
|
||||
: <Clock size={13} strokeWidth={2.2} className="text-content-faint" />}
|
||||
{when.day}
|
||||
{when.time && <span className={when.day ? 'font-medium text-content-muted' : ''}>{when.time}</span>}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
{/* Public-transit itinerary (#1065) — legs from the transit search */}
|
||||
{meta.transit?.legs && Array.isArray(meta.transit.legs) && meta.transit.legs.length > 0 && (
|
||||
<>
|
||||
{/* journey summary: duration · transfers · walking */}
|
||||
<div className="bg-surface-tertiary text-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '9px 12px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, flexWrap: 'wrap' }}>
|
||||
{meta.transit.duration > 0 && (
|
||||
<span>{Math.floor(meta.transit.duration / 3600) > 0 ? `${Math.floor(meta.transit.duration / 3600)} h ${Math.round((meta.transit.duration % 3600) / 60)} min` : t('transit.min', { count: Math.round(meta.transit.duration / 60) })}</span>
|
||||
)}
|
||||
<span className="text-content-faint">·</span>
|
||||
<span>{meta.transit.transfers > 0 ? t('transit.transfers', { count: meta.transit.transfers }) : t('transit.direct')}</span>
|
||||
{meta.transit.walk_seconds > 59 && (
|
||||
<>
|
||||
<span className="text-content-faint">·</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Footprints size={13} /> {t('transit.min', { count: Math.round(meta.transit.walk_seconds / 60) })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-tertiary" style={{ padding: '10px 12px', borderRadius: 8 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 8 }}>
|
||||
{t('transit.itinerary')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{meta.transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null; headsign?: string | null; duration?: number; stops?: number; from?: { name?: string; time?: string | null }; to?: { name?: string; time?: string | null } }, i: number) => {
|
||||
const isWalk = leg.mode === 'WALK'
|
||||
const mins = leg.duration ? Math.round(leg.duration / 60) : null
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
{isWalk ? (
|
||||
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0, paddingTop: 1 }}>
|
||||
<Footprints size={12} />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 5, padding: '1px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{isWalk
|
||||
? <span className="text-content-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
|
||||
: <>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
|
||||
<ArrowRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
|
||||
</>}
|
||||
</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
|
||||
{[
|
||||
leg.from?.time && !isWalk ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : null,
|
||||
mins ? t('transit.min', { count: mins }) : null,
|
||||
!isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null,
|
||||
!isWalk && leg.headsign ? `→ ${leg.headsign}` : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
const footer = (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={close}>{t('common.close')}</DialogButton>
|
||||
{onEdit && (
|
||||
<DialogButton variant="primary" onClick={() => onEdit(res)} icon={<Pencil size={14} strokeWidth={2} />}>{t('common.edit')}</DialogButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)
|
||||
|
||||
{/* Notizen */}
|
||||
{res.notes && (
|
||||
<div className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div className="collab-note-md text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<DialogShell onClose={close} labelledBy={titleId} header={header} footer={footer}>
|
||||
{cells.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
|
||||
{cells.map((c, i) => (
|
||||
<Field key={i} label={c.label}>
|
||||
{c.code ? <BlurredCode className="font-geist tabular-nums">{c.value}</BlurredCode> : c.value}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dateien */}
|
||||
{(() => {
|
||||
const resFiles = (useTripStore.getState().files || []).filter(f =>
|
||||
!f.deleted_at && (
|
||||
f.reservation_id === res.id ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
||||
)
|
||||
)
|
||||
if (resFiles.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{resFiles.map(f => (
|
||||
<button key={f.id}
|
||||
type="button"
|
||||
onClick={() => { setTransportDetail(null); onNavigateToFiles?.() }}
|
||||
className="bg-surface-tertiary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
|
||||
borderRadius: 8, cursor: 'pointer', border: 'none', textAlign: 'left', fontFamily: 'inherit',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
>
|
||||
<FileText size={14} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{f.original_name}
|
||||
</span>
|
||||
<ExternalLink size={11} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{res.location && <DetailField label={t('reservations.locationAddress')}>{res.location}</DetailField>}
|
||||
|
||||
{/* Aktionen */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{onEdit && (
|
||||
<button type="button" onClick={() => onEdit(res)} className="bg-surface-tertiary text-content" style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
border: 'none', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
<Pencil size={12} /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
{stats.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="min-w-0 flex-1 rounded-[12px] bg-surface-tertiary px-3 py-2.5 text-center">
|
||||
<div className="truncate font-bold tabular-nums tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>{s.value}</div>
|
||||
<div className="mt-0.5 truncate font-geist text-content-faint" style={fs(10.5)}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Public-transit itinerary (#1065): the legs the transit search stored. */}
|
||||
{transit && transit.legs.length > 0 && (
|
||||
<DialogSection label={t('transit.itinerary')}>
|
||||
<TransitLegs legs={transit.legs} />
|
||||
</DialogSection>
|
||||
)}
|
||||
|
||||
{res.notes && (
|
||||
<DialogSection label={t('reservations.notes')}>
|
||||
<div className={`${BOX} collab-note-md px-3.5 py-3 text-content-secondary`} style={{ ...fs(13, 'body'), wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown>
|
||||
</div>
|
||||
</DialogSection>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<DialogSection label={t('files.title')}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{files.map(f => (
|
||||
<button key={f.id} type="button" onClick={() => { close(); onNavigateToFiles?.() }}
|
||||
className={`${BOX} flex items-center gap-2.5 px-3.5 py-2.5 text-left hover:bg-surface-hover`}>
|
||||
<FileText size={14} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{f.original_name}</span>
|
||||
<ExternalLink size={12} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)}
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** The facts of a flight or a train, the booking code and the code of each segment. */
|
||||
function detailCells(res: Reservation, meta: Record<string, unknown>, t: Translate): DetailCell[] {
|
||||
const cells: DetailCell[] = []
|
||||
const add = (label: string, value: unknown, code = false) => {
|
||||
if (value) cells.push({ label, value: String(value), code })
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
add(t('reservations.meta.airline'), meta.airline)
|
||||
add(t('reservations.meta.flightNumber'), meta.flight_number)
|
||||
add(t('reservations.meta.from'), meta.departure_airport)
|
||||
add(t('reservations.meta.to'), meta.arrival_airport)
|
||||
add(t('reservations.meta.seat'), meta.seat)
|
||||
} else if (res.type === 'train') {
|
||||
add(t('reservations.meta.trainNumber'), meta.train_number)
|
||||
add(t('reservations.meta.platform'), meta.platform)
|
||||
add(t('reservations.meta.seat'), meta.seat)
|
||||
}
|
||||
add(t('reservations.confirmationCode'), res.confirmation_number, true)
|
||||
// A stopover booking can carry its own reference per segment (#1943); the
|
||||
// flat fields above only ever describe the first leg. Drawn as codes, so the
|
||||
// blur preference covers them like the booking's own.
|
||||
const legs = Array.isArray(meta.legs) ? meta.legs as { from?: string; to?: string; confirmation_number?: string }[] : []
|
||||
if (legs.length > 1) {
|
||||
for (const leg of legs) {
|
||||
if (!leg?.confirmation_number) continue
|
||||
const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
|
||||
add(segment || t('reservations.confirmationCode'), leg.confirmation_number, true)
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
/** The booking's day and its time or time range, each empty when the booking has none. */
|
||||
function whenOf(res: Reservation, locale: string, timeFormat: string): { day: string; time: string } {
|
||||
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const day = date
|
||||
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
const start = time ? formatTime(time, locale, timeFormat) : ''
|
||||
const end = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||
return { day, time: start && end ? `${start} → ${end}` : start }
|
||||
}
|
||||
|
||||
/** A journey's length in the reader's language: minutes up to an hour, then hours and minutes. */
|
||||
function durationText(seconds: number, t: Translate): string {
|
||||
const mins = Math.round(seconds / 60)
|
||||
if (mins < 60) return t('transit.min', { count: mins })
|
||||
const hours = Math.floor(mins / 60)
|
||||
const minutes = mins % 60
|
||||
return minutes > 0 ? t('dawarich.duration.hoursMinutes', { hours, minutes }) : t('dawarich.duration.hours', { hours })
|
||||
}
|
||||
|
||||
/** A labelled value too long for a grid cell, such as an address. */
|
||||
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[3px]">{label}</Eyebrow>
|
||||
<div className={`${BOX} break-words px-3.5 py-2.5 font-medium text-content`} style={fs(13, 'body')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-028
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-030
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MapPin } from 'lucide-react'
|
||||
@@ -142,6 +142,15 @@ describe('DayReorderPopup', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-029: it is a dialog named by its title, and Escape closes it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onClose })} />)
|
||||
expect(screen.getByRole('dialog', { name: 'dayplan.reorderTitle' })).toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('deleting a day', () => {
|
||||
afterEach(() => setForcedOffline(false))
|
||||
|
||||
@@ -310,14 +319,23 @@ describe('DayReorderPopup', () => {
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-030: a press on the backdrop leaves the question and the dialog alone', async () => {
|
||||
const user = userEvent.setup()
|
||||
const q = question()
|
||||
const onClose = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
|
||||
await user.click(screen.getByRole('dialog').parentElement as HTMLElement)
|
||||
expect(q.onCancel).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-025: closing the dialog also drops an open question', async () => {
|
||||
const user = userEvent.setup()
|
||||
const q = question()
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
|
||||
const closeX = document.querySelector<HTMLButtonElement>('h2 + button')
|
||||
expect(closeX).not.toBeNull()
|
||||
await user.click(closeX as HTMLButtonElement)
|
||||
// The head band's close button: named "Close" by the shared dialog, not by the `t` prop.
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(q.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { GripVertical, ArrowUp, ArrowDown, ArrowUpDown, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import DayImpactList from '../shared/DayImpactList'
|
||||
import { DayAddFooter, FOOTER_BTN, SECONDARY_BTN } from './DayAddFooter'
|
||||
import { DayAddFooter } from './DayAddFooter'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { dayLabel } from '../../utils/dayLabel'
|
||||
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
||||
@@ -14,7 +14,7 @@ import type { Day } from '../../types'
|
||||
interface DayReorderPopupProps {
|
||||
isOpen: boolean
|
||||
days: Day[]
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
onAddDay: () => void
|
||||
@@ -38,12 +38,15 @@ interface ListSpot {
|
||||
}
|
||||
|
||||
const ICON_BTN =
|
||||
'grid h-7 w-7 flex-shrink-0 place-items-center rounded-md border border-edge-faint text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted'
|
||||
'grid h-7 w-7 flex-shrink-0 place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted'
|
||||
const DELETE_BTN =
|
||||
'grid h-7 w-7 place-items-center rounded-md text-content-faint transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content-faint'
|
||||
'grid h-7 w-7 place-items-center rounded-full text-content-faint transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content-faint'
|
||||
/** The question's answers, in the footer buttons' size. */
|
||||
const CANCEL_BTN = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover'
|
||||
const DANGER_BTN = 'inline-flex items-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-semibold text-white hover:opacity-90' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
|
||||
|
||||
/**
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* Dialog for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, add a day, or delete one. Day headers stay untouched, so this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately.
|
||||
@@ -61,6 +64,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const spot = useRef<ListSpot | null>(null)
|
||||
const dialogTitleId = useId()
|
||||
const titleId = useId()
|
||||
const bodyId = useId()
|
||||
|
||||
@@ -90,8 +94,8 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
useEffect(() => { if (!isOpen) questionRef.current?.onCancel() }, [isOpen])
|
||||
useEffect(() => () => questionRef.current?.onCancel(), [])
|
||||
|
||||
// Escape takes back the question, not the dialog. Captured and stopped
|
||||
// before the dialog's own listener, which would close everything.
|
||||
// Escape takes back the question, not the dialog: the dialog is blocked
|
||||
// while it asks, and the key is caught before anything under it sees it.
|
||||
useEffect(() => {
|
||||
if (!asking) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -129,44 +133,55 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
again?.focus()
|
||||
}, [askedDayId])
|
||||
|
||||
const header = (
|
||||
<DialogHeader
|
||||
tile={<DialogTile><ArrowUpDown size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={dialogTitleId}
|
||||
onClose={close}
|
||||
title={t('dayplan.reorderTitle')}
|
||||
/>
|
||||
)
|
||||
|
||||
const footer = asking ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={SECONDARY_BTN}>
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={CANCEL_BTN} style={fs(13, 'body')}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={asking.onConfirm} className={`${FOOTER_BTN} bg-danger font-semibold text-white hover:opacity-90`}>
|
||||
<Trash2 size={15} strokeWidth={2} aria-hidden="true" />
|
||||
<button type="button" onClick={asking.onConfirm} className={DANGER_BTN} style={fs(13, 'body')}>
|
||||
<Trash2 size={14} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.deleteDay')}
|
||||
</button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={close} t={t} locale={locale} />
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={close} title={t('dayplan.reorderTitle')} size="lg" align="top" footer={footer}>
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
|
||||
<div ref={bodyRef}>
|
||||
{asking ? (
|
||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-danger-soft text-danger">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-full bg-danger-soft text-danger">
|
||||
<AlertTriangle size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={titleId} className="text-subtitle font-semibold leading-snug text-content">{asking.title}</h3>
|
||||
<p id={bodyId} className="mt-0.5 text-caption text-content-muted">{t('dayplan.deleteDayBody')}</p>
|
||||
<h3 id={titleId} className="m-0 font-bold leading-snug text-content" style={fs(15, 'subtitle')}>{asking.title}</h3>
|
||||
<p id={bodyId} className="m-0 mt-0.5 text-content-muted" style={fs(12.5, 'body')}>{t('dayplan.deleteDayBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DayImpactList lines={asking.lines} label={asking.title} />
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-caption text-content-muted">{t('dayplan.reorderHint')}</p>
|
||||
<p className="m-0 mb-3 text-content-muted" style={fs(12.5, 'body')}>{t('dayplan.reorderHint')}</p>
|
||||
|
||||
{/* The popup is a modal, so it portals out of the planner and has to opt
|
||||
into the long-press drag itself (#1616). Without this a finger only
|
||||
{/* The dialog portals out of the planner, so it has to opt into the
|
||||
long-press drag itself (#1616). Without this a finger only
|
||||
selects the row text. */}
|
||||
<div data-touch-drag className="flex flex-col gap-1">
|
||||
<div data-touch-drag className="flex flex-col gap-1.5">
|
||||
{ordered.map((day, index) => {
|
||||
const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index
|
||||
return (
|
||||
@@ -181,15 +196,15 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
className={`flex items-center gap-2.5 rounded-lg border border-edge-faint px-2.5 py-1.5 ${
|
||||
className={`flex items-center gap-2.5 rounded-[14px] border border-edge-faint py-2 pl-2.5 pr-2 ${
|
||||
dropTarget ? 'bg-surface-hover outline-dashed outline-2 -outline-offset-2 outline-edge' : 'bg-surface-card'
|
||||
} ${dragIndex === index ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={1.8} className="flex-shrink-0 cursor-grab text-content-faint" />
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-full bg-surface-hover text-caption font-semibold text-content-muted">
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-full bg-surface-tertiary font-geist font-bold tabular-nums text-content-muted" style={fs(11)}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-body font-medium text-content">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{dayLabel(day, index, t, locale)}
|
||||
</span>
|
||||
<button
|
||||
@@ -236,6 +251,6 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-FILEIMP-001 to FE-PLANNER-FILEIMP-018
|
||||
// FE-PLANNER-FILEIMP-001 to FE-PLANNER-FILEIMP-021
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -237,10 +237,38 @@ describe('FileImportModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(document.querySelector('[style*="z-index: 99999"]') as HTMLElement);
|
||||
// The backdrop is the dialog's parent; a press that starts and ends on it closes.
|
||||
const backdrop = screen.getByRole('dialog').parentElement as HTMLElement;
|
||||
fireEvent.mouseDown(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
// A click inside the card is swallowed.
|
||||
fireEvent.mouseDown(screen.getByText('Import file'));
|
||||
fireEvent.click(screen.getByText('Import file'));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEIMP-020: Escape and the head band X close it as well', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<FileImportModal {...defaultProps} onClose={onClose} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Import file' })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEIMP-021: re-ticking a type lifts the block again', () => {
|
||||
render(<FileImportModal {...defaultProps} initialFile={kml()} />);
|
||||
const points = screen.getByRole('checkbox', { name: 'Points (Placemarks)' });
|
||||
const paths = screen.getByRole('checkbox', { name: 'Paths (LineStrings)' });
|
||||
expect(points).toHaveAttribute('aria-checked', 'true');
|
||||
fireEvent.click(points);
|
||||
fireEvent.click(paths);
|
||||
expect(points).toHaveAttribute('aria-checked', 'false');
|
||||
expect(importBtn()).toBeDisabled();
|
||||
fireEvent.click(paths);
|
||||
expect(screen.queryByText('Select at least one type to import.')).not.toBeInTheDocument();
|
||||
expect(importBtn()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useId } from 'react'
|
||||
import { Check, FileDown, Loader2, Upload } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import {
|
||||
DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs,
|
||||
} from '../shared/DialogShell'
|
||||
import { EditorField } from '../shared/dialogParts'
|
||||
|
||||
interface PlacesImportSummary {
|
||||
totalPlacemarks: number
|
||||
@@ -25,11 +28,27 @@ interface FileImportModalProps {
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** One tickable kind of content in the file (waypoints, paths…). */
|
||||
function TypeCheck({ checked, label, onToggle }: { checked: boolean; label: string; onToggle: () => void }) {
|
||||
return (
|
||||
<button type="button" role="checkbox" aria-checked={checked} onClick={onToggle}
|
||||
className="flex w-full items-center gap-2.5 rounded-[10px] px-2 py-1.5 text-left hover:bg-surface-hover">
|
||||
<span className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] ${checked ? 'bg-accent' : 'border-[1.5px] border-edge'}`}>
|
||||
{checked && <Check size={11} strokeWidth={3} className="text-accent-text" />}
|
||||
</span>
|
||||
<span className="select-none text-content" style={fs(12.5, 'body')}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const TYPE_BOX = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
||||
|
||||
export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, initialFile }: FileImportModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const titleId = useId()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
@@ -215,35 +234,52 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={handleClose}
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }}
|
||||
>
|
||||
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
{t('places.importFile')}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('places.importFileHint')}
|
||||
</div>
|
||||
let zoneText: React.ReactNode
|
||||
if (isDragOver) zoneText = <span className="pointer-events-none font-semibold text-accent">{t('places.importFileDropActive')}</span>
|
||||
else if (files.length > 0) zoneText = <span className="pointer-events-none break-all text-center font-semibold text-content">{files.map(f => f.name).join(', ')}</span>
|
||||
else zoneText = <span className="pointer-events-none text-center text-content-faint">{t('places.importFileDropHere')}</span>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".gpx,.kml,.kmz"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={handleClose}
|
||||
labelledBy={titleId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><FileDown size={20} strokeWidth={1.9} className="text-content" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={titleId}
|
||||
onClose={handleClose}
|
||||
title={t('places.importFile')}
|
||||
sub={t('places.importFileHint')}
|
||||
subWraps
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={handleClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
onClick={handleImport}
|
||||
disabled={!canImport}
|
||||
icon={loading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{loading ? t('common.loading') : t('common.import')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".gpx,.kml,.kmz"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<EditorField label={t('files.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -251,143 +287,68 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 88,
|
||||
borderRadius: 12,
|
||||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
marginBottom: 12,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
boxSizing: 'border-box',
|
||||
padding: 16,
|
||||
}}
|
||||
className={`flex min-h-[96px] w-full flex-col items-center justify-center gap-1.5 rounded-[14px] border-2 border-dashed p-4 font-medium transition-colors ${isDragOver ? 'border-accent bg-surface-tertiary' : 'border-edge bg-surface-secondary hover:border-content-faint'}`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
|
||||
)}
|
||||
<Upload size={18} strokeWidth={1.8} className={`pointer-events-none ${isDragOver ? 'text-accent' : 'text-content-faint'}`} />
|
||||
{zoneText}
|
||||
</button>
|
||||
</EditorField>
|
||||
|
||||
{isGpx && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.gpxImportTypes')}
|
||||
</div>
|
||||
{isGpx && (
|
||||
<DialogSection label={t('places.gpxImportTypes')}>
|
||||
<div className={TYPE_BOX}>
|
||||
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
|
||||
<button type="button" key={key} role="checkbox" aria-checked={gpxOpts[key]} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer', width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}>
|
||||
<div className={gpxOpts[key] ? 'bg-accent' : 'bg-transparent'} style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
</span>
|
||||
</button>
|
||||
<TypeCheck
|
||||
key={key}
|
||||
checked={gpxOpts[key]}
|
||||
onToggle={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
label={t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
/>
|
||||
))}
|
||||
{gpxNoneSelected && (
|
||||
<div className="text-[#b45309]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{gpxNoneSelected && <p className="m-0 mt-1.5 text-warning" style={fs(11.5)}>{t('places.gpxImportNoneSelected')}</p>}
|
||||
</DialogSection>
|
||||
)}
|
||||
|
||||
{isKml && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.kmlImportTypes')}
|
||||
</div>
|
||||
{isKml && (
|
||||
<DialogSection label={t('places.kmlImportTypes')}>
|
||||
<div className={TYPE_BOX}>
|
||||
{(['points', 'paths'] as const).map(key => (
|
||||
<button type="button" key={key} role="checkbox" aria-checked={kmlOpts[key]} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer', width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}>
|
||||
<div className={kmlOpts[key] ? 'bg-accent' : 'bg-transparent'} style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
</span>
|
||||
</button>
|
||||
<TypeCheck
|
||||
key={key}
|
||||
checked={kmlOpts[key]}
|
||||
onToggle={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
label={t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
/>
|
||||
))}
|
||||
{kmlNoneSelected && (
|
||||
<div className="text-[#b45309]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{kmlNoneSelected && <p className="m-0 mt-1.5 text-warning" style={fs(11.5)}>{t('places.kmlImportNoneSelected')}</p>}
|
||||
</DialogSection>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
background: 'var(--bg-tertiary)', padding: 10, marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)' }}>
|
||||
{t('places.kmlKmzSummaryValues', {
|
||||
total: summary.totalPlacemarks,
|
||||
created: summary.createdCount,
|
||||
skipped: summary.skippedCount,
|
||||
})}
|
||||
{summary && (
|
||||
<div className="flex flex-col gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<div className="text-content-muted" style={fs(12, 'body')}>
|
||||
{t('places.kmlKmzSummaryValues', {
|
||||
total: summary.totalPlacemarks,
|
||||
created: summary.createdCount,
|
||||
skipped: summary.skippedCount,
|
||||
})}
|
||||
</div>
|
||||
{summary.warnings?.length > 0 && (
|
||||
<div className="whitespace-pre-wrap rounded-[10px] bg-warning-soft px-2.5 py-2 text-warning" style={fs(12, 'body')}>
|
||||
{summary.warnings.join('\n')}
|
||||
</div>
|
||||
{summary.warnings?.length > 0 && (
|
||||
<div className="text-[#b45309]" style={{ marginTop: 8, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap' }}>
|
||||
{summary.warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{
|
||||
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', marginBottom: 10,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button type="button"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleImport}
|
||||
disabled={!canImport}
|
||||
className={canImport ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: canImport ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{loading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="whitespace-pre-wrap rounded-[12px] bg-danger-soft px-3 py-2 text-danger" style={fs(12, 'body')}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// FE-PLANNER-ICS-001 to FE-PLANNER-ICS-012
|
||||
// FE-PLANNER-ICS-001 to FE-PLANNER-ICS-016
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal';
|
||||
|
||||
// The phone keeps the dialog's own markup, the desktop draws it in the shared
|
||||
// dialog frame. useIsPhone reads matchMedia, which the test setup always
|
||||
// answers with false, so the phone is switched on by hand.
|
||||
const phone = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }));
|
||||
|
||||
const ENDPOINT = '/api/trips/9/feed';
|
||||
const TOKEN_URL = `${ENDPOINT}/token`;
|
||||
|
||||
@@ -24,7 +31,12 @@ function tokenHandlers(handler: (method: string) => Response | Promise<Response>
|
||||
];
|
||||
}
|
||||
|
||||
describe('IcsSubscribeModal', () => {
|
||||
afterEach(() => { phone.value = false; });
|
||||
|
||||
// Everything a user can do with the link works the same on both shells.
|
||||
describe.each([['phone', true], ['desktop', false]] as const)('IcsSubscribeModal on the %s', (_shell, isPhone) => {
|
||||
beforeEach(() => { phone.value = isPhone; });
|
||||
|
||||
it('FE-PLANNER-ICS-001: shows the loading state until the token read resolves', async () => {
|
||||
let release: (() => void) | null = null;
|
||||
const gate = new Promise<void>(res => { release = res; });
|
||||
@@ -135,6 +147,10 @@ describe('IcsSubscribeModal', () => {
|
||||
await waitFor(() => expect(regenerate).not.toBeDisabled());
|
||||
expect(screen.getByText('https://trek.example/feed/keep.ics')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IcsSubscribeModal on the phone', () => {
|
||||
beforeEach(() => { phone.value = true; });
|
||||
|
||||
it('FE-PLANNER-ICS-011: the close button and a backdrop click both call onClose, an inner click does not', async () => {
|
||||
const onClose = vi.fn();
|
||||
@@ -154,4 +170,61 @@ describe('IcsSubscribeModal', () => {
|
||||
fireEvent.click(screen.getByText('Keep the itinerary in your own calendar.'));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ICS-013: the phone keeps its own sheet, not the desktop dialog frame', async () => {
|
||||
server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: null })));
|
||||
render(<IcsSubscribeModal {...defaultProps} />);
|
||||
await screen.findByRole('button', { name: /Enable calendar subscription/i });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[style*="z-index: 9999"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IcsSubscribeModal on the desktop', () => {
|
||||
it('FE-PLANNER-ICS-014: a dialog named by its title, the description and the links in it', async () => {
|
||||
server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: 'https://trek.example/feed/tok.ics' })));
|
||||
render(<IcsSubscribeModal {...defaultProps} />);
|
||||
const dialog = screen.getByRole('dialog', { name: 'Subscribe to this trip' });
|
||||
expect(dialog).toHaveTextContent('Keep the itinerary in your own calendar.');
|
||||
expect(await screen.findByRole('link', { name: /Add to Google Calendar/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Regenerating creates a new link/i)).toBeInTheDocument();
|
||||
// Turning the link off is drawn in the danger colour.
|
||||
expect(screen.getByRole('button', { name: /Turn off/i })).toHaveClass('text-danger');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ICS-015: the close button, Escape and a backdrop press close it, a press inside does not', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: null })));
|
||||
render(<IcsSubscribeModal {...defaultProps} onClose={onClose} />);
|
||||
await screen.findByRole('button', { name: /Enable calendar subscription/i });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
|
||||
await user.click(screen.getByText('Keep the itinerary in your own calendar.'));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
|
||||
await user.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ICS-016: while a change is sent, the link controls wait for it', async () => {
|
||||
let release: (() => void) | null = null;
|
||||
const gate = new Promise<void>(res => { release = res; });
|
||||
server.use(
|
||||
http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: 'https://trek.example/feed/tok.ics' })),
|
||||
http.put(TOKEN_URL, async () => { await gate; return HttpResponse.json({ feed_url: 'https://trek.example/feed/new.ics' }); }),
|
||||
);
|
||||
render(<IcsSubscribeModal {...defaultProps} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Regenerate/i }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Turn off/i })).toBeDisabled());
|
||||
expect(screen.getByRole('button', { name: /Regenerate/i })).toBeDisabled();
|
||||
release!();
|
||||
expect(await screen.findByText('https://trek.example/feed/new.ics')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Turn off/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useId, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, RefreshCw, Calendar, Power } from 'lucide-react'
|
||||
import { SubscribeLinks } from './SubscribeLinks'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
interface IcsSubscribeModalProps {
|
||||
/** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */
|
||||
@@ -25,12 +27,17 @@ function absolutize(url: string): string {
|
||||
* Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it
|
||||
* only *reads* the current token — it never mints one silently. The user
|
||||
* explicitly enables the public link, and can rotate or fully turn it off.
|
||||
*
|
||||
* The phone keeps its own markup; the desktop draws the same steps in the
|
||||
* planner's dialog frame, with the link's controls in the bar at the foot.
|
||||
*/
|
||||
export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) {
|
||||
const tokenUrl = `${endpoint}/token`
|
||||
const [feedUrl, setFeedUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const isPhone = useIsPhone()
|
||||
const titleId = useId()
|
||||
|
||||
const httpsUrl = feedUrl ? absolutize(feedUrl) : ''
|
||||
const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
@@ -61,6 +68,23 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
if (!isPhone) {
|
||||
return (
|
||||
<DesktopSubscribeDialog
|
||||
titleId={titleId}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
loading={loading}
|
||||
busy={busy}
|
||||
feedUrl={feedUrl}
|
||||
httpsUrl={httpsUrl}
|
||||
webcalUrl={webcalUrl}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
@@ -177,3 +201,79 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// A secondary footer button in the danger colour, for the switch that takes the link away.
|
||||
const TURN_OFF_BTN = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-danger hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
function DesktopSubscribeDialog({ titleId, title, description, onClose, loading, busy, feedUrl, httpsUrl, webcalUrl, mutate }: {
|
||||
titleId: string
|
||||
title: string
|
||||
description: string
|
||||
onClose: () => void
|
||||
loading: boolean
|
||||
busy: boolean
|
||||
feedUrl: string | null
|
||||
httpsUrl: string
|
||||
webcalUrl: string
|
||||
mutate: (method: 'POST' | 'PUT' | 'DELETE') => Promise<void>
|
||||
}) {
|
||||
const header = (
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Calendar size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={titleId}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
sub={description}
|
||||
subWraps
|
||||
/>
|
||||
)
|
||||
|
||||
const hint = 'm-0 leading-normal text-content-muted'
|
||||
let body: ReactNode
|
||||
let footer: ReactNode = null
|
||||
if (loading) {
|
||||
body = <div className="py-4 text-center text-content-muted" style={fs(12.5, 'body')}>Loading…</div>
|
||||
} else if (!feedUrl) {
|
||||
body = (
|
||||
<p className={hint} style={fs(12.5, 'body')}>
|
||||
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
|
||||
</p>
|
||||
)
|
||||
footer = (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={() => mutate('POST')} disabled={busy} icon={<Calendar size={14} strokeWidth={2} />}>
|
||||
Enable calendar subscription
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
|
||||
<p className={hint} style={fs(11.5)}>
|
||||
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
footer = (
|
||||
<DialogFooter>
|
||||
<DialogButton onClick={() => mutate('PUT')} disabled={busy} icon={<RefreshCw size={14} strokeWidth={2} className={busy ? 'animate-spin' : ''} />}>
|
||||
Regenerate
|
||||
</DialogButton>
|
||||
<FooterSpacer />
|
||||
<button type="button" onClick={() => mutate('DELETE')} disabled={busy} className={TURN_OFF_BTN} style={fs(13, 'body')}>
|
||||
<Power size={14} strokeWidth={2} />
|
||||
Turn off
|
||||
</button>
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogShell onClose={onClose} labelledBy={titleId} width="narrow" header={header} footer={footer}>
|
||||
{body}
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -634,3 +634,157 @@ describe('PlaceDetailsColumn — picture layout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* FE-PDC-040..049 — the desktop dialog's look (variant="dialog").
|
||||
*
|
||||
* The phone sheet keeps the default look, which every case above pins. The
|
||||
* dialog draws the same content on white cards under eyebrows, with a quiet
|
||||
* hint card for the empty states, no native tooltips, and no dot between two
|
||||
* credits.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — dialog variant', () => {
|
||||
const renderDialog = (props: Partial<React.ComponentProps<typeof PlaceDetailsColumn>> = {}) =>
|
||||
renderColumn({ variant: 'dialog', ...props })
|
||||
|
||||
it('FE-PDC-040: waits with a hint card under the column eyebrow while nothing is picked', () => {
|
||||
const { container } = renderDialog({ selection: null })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'places.details.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.empty')).toBeInTheDocument()
|
||||
expect(container.querySelector('aside')).toHaveClass('sm:w-80')
|
||||
expect(placeEnrichment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PDC-041: says it is loading, then shows the content on cards', async () => {
|
||||
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {}
|
||||
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => { settle = r }))
|
||||
renderDialog()
|
||||
|
||||
expect(screen.getByText('places.details.loading')).toBeInTheDocument()
|
||||
settle(RESULT)
|
||||
expect(await screen.findByText('Ein Museum in Köln.')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-042: credits a picture and its licence side by side, without a dot or a native tooltip', async () => {
|
||||
const { onPickImage } = renderDialog()
|
||||
|
||||
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
|
||||
expect(tile).not.toHaveAttribute('title')
|
||||
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' })
|
||||
expect(licence.closest('p')).toHaveTextContent('Alice')
|
||||
expect(licence.closest('p')?.textContent).not.toContain('·')
|
||||
|
||||
fireEvent.click(tile)
|
||||
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
|
||||
})
|
||||
|
||||
it('FE-PDC-043: a picture without a licence link or source page is still credited', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [{ ...COMMONS_PHOTO, sourceUrl: null, licenseUrl: null }],
|
||||
facts: [],
|
||||
description: null,
|
||||
})
|
||||
renderDialog({ selectedImageUrl: COMMONS_PHOTO.url })
|
||||
|
||||
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
|
||||
expect(tile).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('CC BY-SA 4.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-044: the adopt button is a white button, blocked with the reason under it', async () => {
|
||||
const first = renderDialog()
|
||||
const adopt = await screen.findByRole('button', { name: 'places.details.adopt' })
|
||||
expect(adopt).toHaveClass('bg-surface-card')
|
||||
expect(adopt).not.toHaveAttribute('aria-describedby')
|
||||
fireEvent.click(adopt)
|
||||
expect(first.onAdoptDescription).toHaveBeenCalledWith('Ein Museum in Köln.')
|
||||
first.unmount()
|
||||
|
||||
renderDialog({ hasDescription: true })
|
||||
const blocked = await screen.findByRole('button', { name: 'places.details.adopt' })
|
||||
expect(blocked).toBeDisabled()
|
||||
expect(blocked).not.toHaveAttribute('title')
|
||||
// The line under it is what assistive tech reads as the reason.
|
||||
expect(blocked).toHaveAccessibleDescription('places.details.adoptBlocked')
|
||||
})
|
||||
|
||||
it('FE-PDC-045: names the description source and its licence apart, not joined by a dot', async () => {
|
||||
renderDialog()
|
||||
|
||||
const link = await screen.findByRole('link', { name: /Wikipedia/ })
|
||||
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
|
||||
expect(link.parentElement?.textContent).not.toContain('·')
|
||||
})
|
||||
|
||||
it('FE-PDC-046: facts are white pills without native tooltips; only a safe url becomes a link', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
description: null,
|
||||
facts: [
|
||||
{ kind: 'cuisine', value: 'regional', url: null },
|
||||
{ kind: 'menu', value: null, url: 'https://example.org/menu' },
|
||||
{ kind: 'takeaway', value: null, url: 'javascript:alert(1)' },
|
||||
],
|
||||
})
|
||||
renderDialog()
|
||||
|
||||
const chip = await screen.findByText('regional')
|
||||
expect(chip.parentElement).not.toHaveAttribute('title')
|
||||
const menu = screen.getByRole('link', { name: /places.details.fact.menu/ })
|
||||
expect(menu).toHaveAttribute('href', 'https://example.org/menu')
|
||||
expect(menu).not.toHaveAttribute('title')
|
||||
expect(screen.queryByRole('link', { name: /places.details.fact.takeaway/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-047: hours and the rating keep working on the cards', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: null,
|
||||
rating: { value: 3.8, count: 873 },
|
||||
hours: { weekdayDescriptions: ['Monday: 09:00-18:00', 'Tuesday: 09:00-18:00', 'Wednesday: 09:00-18:00', 'Thursday: 09:00-18:00', 'Friday: 09:00-18:00', 'Saturday: 10:00-14:00', 'Sunday: ?'], periods: null, specialDays: null },
|
||||
})
|
||||
renderDialog()
|
||||
|
||||
expect(await screen.findByText('3.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('(873)')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('FE-PDC-048: a chain description says so on its card', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: { text: 'Eine Kette.', source: 'website', sourceUrl: null, license: null, aboutBrand: true },
|
||||
})
|
||||
renderDialog()
|
||||
|
||||
expect(await screen.findByText('places.details.aboutBrand')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.aboutBrandNote')).toBeInTheDocument()
|
||||
expect(screen.getByText('Website')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-049: nothing found, switched off and failed each say so in a hint card', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [] })
|
||||
const nothing = renderDialog()
|
||||
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
|
||||
nothing.unmount()
|
||||
__clearEnrichmentCacheForTests()
|
||||
sessionStorage.clear()
|
||||
|
||||
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
|
||||
const off = renderDialog()
|
||||
expect(await screen.findByText('places.details.disabled')).toBeInTheDocument()
|
||||
off.unmount()
|
||||
__clearEnrichmentCacheForTests()
|
||||
sessionStorage.clear()
|
||||
|
||||
placeEnrichment.mockRejectedValue(new Error('network'))
|
||||
renderDialog()
|
||||
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
Accessibility,
|
||||
ArrowRight,
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ImageIcon,
|
||||
Landmark,
|
||||
Leaf,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
SearchX,
|
||||
ShoppingBag,
|
||||
Sprout,
|
||||
Star,
|
||||
@@ -26,6 +28,10 @@ import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './place
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { LABEL } from '../shared/dialogParts'
|
||||
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/** The place the column is describing. Null while nothing is selected. */
|
||||
@@ -57,6 +63,12 @@ interface PlaceDetailsColumnProps {
|
||||
* where a 320 px cap would leave it hugging the left half of a wide screen.
|
||||
*/
|
||||
fluid?: boolean
|
||||
/**
|
||||
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
|
||||
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
|
||||
* default is the phone sheet's look, which this prop leaves exactly as it was.
|
||||
*/
|
||||
variant?: 'sheet' | 'dialog'
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
t: TranslationFn
|
||||
}
|
||||
@@ -114,6 +126,27 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One block of the column under its label. The sheet keeps its overline; the
|
||||
* dialog uses the eyebrow every field of the form beside it wears.
|
||||
*/
|
||||
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
|
||||
if (!dialog) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Overline>{label}</Overline>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
<p className={LABEL}>{label}</p>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlaceDetailsColumn({
|
||||
selection,
|
||||
selectedImageUrl,
|
||||
@@ -124,8 +157,11 @@ export default function PlaceDetailsColumn({
|
||||
timeFormat = '24h',
|
||||
locale = 'en-US',
|
||||
fluid = false,
|
||||
variant = 'sheet',
|
||||
t,
|
||||
}: PlaceDetailsColumnProps): React.ReactElement {
|
||||
const dialog = variant === 'dialog'
|
||||
const adoptBlockedId = useId()
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
const [state, setState] = useState<LoadState>('idle')
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
@@ -222,16 +258,23 @@ export default function PlaceDetailsColumn({
|
||||
// tiles, which is too small to tell a facade from a foyer. The column is
|
||||
// stretched to the form's height by the row it sits in, so the extra room
|
||||
// costs nothing that was being used.
|
||||
<aside className={`w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Landmark size={15} className="text-accent" />
|
||||
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
|
||||
</div>
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
|
||||
{dialog ? (
|
||||
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Landmark size={15} className="text-accent" />
|
||||
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. */}
|
||||
{!selection && (
|
||||
waits with an idle look instead of a sad one. The dialog says the
|
||||
same in a quiet card, and the column keeps its width either way. */}
|
||||
{!selection && (dialog ? (
|
||||
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
@@ -239,27 +282,33 @@ export default function PlaceDetailsColumn({
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
|
||||
{selection && state === 'loading' && (
|
||||
{selection && state === 'loading' && (dialog ? (
|
||||
<HintCard icon={<Loader2 size={15} className="animate-spin" />}>{t('places.details.loading')}</HintCard>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('places.details.loading')}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
{selection && state === 'error' && <p className="text-caption text-content-muted">{t('places.details.error')}</p>}
|
||||
{selection && state === 'error' && (dialog
|
||||
? <HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.error')}</HintCard>
|
||||
: <p className="text-caption text-content-muted">{t('places.details.error')}</p>)}
|
||||
|
||||
{selection && state === 'ready' && data?.disabled && (
|
||||
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
|
||||
)}
|
||||
{selection && state === 'ready' && data?.disabled && (dialog
|
||||
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
|
||||
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (
|
||||
{isEmpty && (dialog ? (
|
||||
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
@@ -268,21 +317,22 @@ export default function PlaceDetailsColumn({
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
<>
|
||||
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} t={t} />
|
||||
<RatingRow rating={data?.rating ?? null} locale={locale} />
|
||||
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} dialog={dialog} t={t} />
|
||||
<RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
|
||||
<OpeningHoursBlock
|
||||
hours={data?.hours ?? null}
|
||||
lat={selection.lat}
|
||||
lng={selection.lng}
|
||||
timeFormat={timeFormat}
|
||||
dialog={dialog}
|
||||
t={t}
|
||||
/>
|
||||
<FactList facts={data?.facts ?? []} t={t} />
|
||||
<DescriptionBlock description={data?.description ?? null} t={t} />
|
||||
<FactList facts={data?.facts ?? []} dialog={dialog} t={t} />
|
||||
<DescriptionBlock description={data?.description ?? null} dialog={dialog} t={t} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,19 +342,36 @@ export default function PlaceDetailsColumn({
|
||||
enough to push it out of view — the button was there, just never
|
||||
where anyone looked. */}
|
||||
{selection && state === 'ready' && !data?.disabled && data?.description && (
|
||||
<div className="shrink-0 border-t border-edge p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdoptDescription(data.description!.text)}
|
||||
disabled={hasDescription}
|
||||
title={hasDescription ? t('places.details.adoptBlocked') : undefined}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('places.details.adopt')}
|
||||
</button>
|
||||
<div className={dialog ? 'shrink-0 border-t border-edge-faint p-2.5' : 'shrink-0 border-t border-edge p-2.5'}>
|
||||
{dialog ? (
|
||||
// White like the dialog's other secondary buttons. While it is
|
||||
// blocked the line under it says why, so it needs no tooltip, and
|
||||
// that line is its description for assistive tech.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdoptDescription(data.description!.text)}
|
||||
disabled={hasDescription}
|
||||
aria-describedby={hasDescription ? adoptBlockedId : undefined}
|
||||
className={WHITE_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('places.details.adopt')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdoptDescription(data.description!.text)}
|
||||
disabled={hasDescription}
|
||||
title={hasDescription ? t('places.details.adoptBlocked') : undefined}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('places.details.adopt')}
|
||||
</button>
|
||||
)}
|
||||
{hasDescription && (
|
||||
<p className="mt-1 text-center text-caption text-content-faint">{t('places.details.adoptBlocked')}</p>
|
||||
<p id={dialog ? adoptBlockedId : undefined} className="mt-1 text-center text-caption text-content-faint">{t('places.details.adoptBlocked')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -334,21 +401,21 @@ function PhotoStrip({
|
||||
photos,
|
||||
selectedImageUrl,
|
||||
onPickImage,
|
||||
dialog = false,
|
||||
t,
|
||||
}: {
|
||||
photos: PlacePhotoCandidate[]
|
||||
selectedImageUrl?: string
|
||||
onPickImage: (url: string | null) => void
|
||||
dialog?: boolean
|
||||
t: TranslationFn
|
||||
}): React.ReactElement | null {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
if (photos.length === 0) return null
|
||||
|
||||
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Overline>{t('places.details.pickImage')}</Overline>
|
||||
const body = (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{photos.map((photo) => (
|
||||
<PhotoTile
|
||||
@@ -357,14 +424,21 @@ function PhotoStrip({
|
||||
selected={selectedImageUrl === photo.url}
|
||||
onPick={onPickImage}
|
||||
onHover={setHovered}
|
||||
dialog={dialog}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-caption leading-tight text-content-faint truncate">
|
||||
<PhotoCredit photo={shown} />
|
||||
<p className={dialog ? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint truncate'}>
|
||||
<PhotoCredit photo={shown} dialog={dialog} />
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Block label={t('places.details.pickImage')} dialog={dialog}>
|
||||
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,12 +447,15 @@ function PhotoTile({
|
||||
selected,
|
||||
onPick,
|
||||
onHover,
|
||||
dialog = false,
|
||||
t,
|
||||
}: {
|
||||
photo: PlacePhotoCandidate
|
||||
selected: boolean
|
||||
onPick: (url: string | null) => void
|
||||
onHover: (url: string | null) => void
|
||||
/** On a white card, and without the native tooltip: hovering a tile already moves the credit line under the grid to it. */
|
||||
dialog?: boolean
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const credit = creditOf(photo)
|
||||
@@ -393,9 +470,11 @@ function PhotoTile({
|
||||
onBlur={() => onHover(null)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t('places.details.pickImage')} — ${credit}`}
|
||||
title={`${credit}${photo.license ? ` · ${photo.license}` : ''}`}
|
||||
title={dialog ? undefined : `${credit}${photo.license ? ` · ${photo.license}` : ''}`}
|
||||
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
|
||||
selected ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface-secondary' : 'ring-1 ring-edge hover:ring-content-muted'
|
||||
selected
|
||||
? `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`
|
||||
: 'ring-1 ring-edge hover:ring-content-muted'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
@@ -458,9 +537,37 @@ function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
* us no author (Google), we say where it came from rather than inventing one.
|
||||
* Rendered inline — the container supplies the colour and the truncation.
|
||||
*/
|
||||
function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement {
|
||||
function PhotoCredit({ photo, dialog = false }: { photo: PlacePhotoCandidate; dialog?: boolean }): React.ReactElement {
|
||||
const credit = creditOf(photo)
|
||||
|
||||
if (dialog) {
|
||||
// Two pieces side by side with room between them, rather than joined by a dot.
|
||||
return (
|
||||
<>
|
||||
<span className="min-w-0 truncate">
|
||||
{photo.sourceUrl ? (
|
||||
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{credit}
|
||||
</a>
|
||||
) : (
|
||||
credit
|
||||
)}
|
||||
</span>
|
||||
{photo.license && (
|
||||
<span className="flex-none">
|
||||
{photo.licenseUrl ? (
|
||||
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{photo.license}
|
||||
</a>
|
||||
) : (
|
||||
photo.license
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{photo.sourceUrl ? (
|
||||
@@ -505,12 +612,12 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
|
||||
* a pill is read as text. The count only appears when there is one: Google's
|
||||
* search results carry a rating without a count, and empty brackets look broken.
|
||||
*/
|
||||
function RatingRow({ rating, locale }: { rating: PlaceRating | null; locale: string }): React.ReactElement | null {
|
||||
function RatingRow({ rating, locale, dialog = false }: { rating: PlaceRating | null; locale: string; dialog?: boolean }): React.ReactElement | null {
|
||||
if (!rating) return null
|
||||
const filled = Math.round(rating.value)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={dialog ? 'inline-flex items-center gap-1.5 self-start rounded-full bg-surface-card px-2.5 py-1 shadow-sm ring-1 ring-edge-faint' : 'flex items-center gap-1.5'}>
|
||||
<span className="flex items-center gap-0.5" aria-hidden>
|
||||
{[1, 2, 3, 4, 5].map((step) => (
|
||||
<Star
|
||||
@@ -546,12 +653,14 @@ function OpeningHoursBlock({
|
||||
lat,
|
||||
lng,
|
||||
timeFormat,
|
||||
dialog = false,
|
||||
t,
|
||||
}: {
|
||||
hours: PlaceHours | null
|
||||
lat: number
|
||||
lng: number
|
||||
timeFormat: string
|
||||
dialog?: boolean
|
||||
t: TranslationFn
|
||||
}): React.ReactElement | null {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
@@ -564,9 +673,8 @@ function OpeningHoursBlock({
|
||||
const todayLabel = todayTimes && !isUnknownHoursLine(todayTimes) ? todayTimes : t('inspector.showHours')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Overline>{t('inspector.openingHours')}</Overline>
|
||||
<div className="rounded-lg border border-edge bg-surface overflow-hidden">
|
||||
<Block label={t('inspector.openingHours')} dialog={dialog}>
|
||||
<div className={dialog ? `${COLUMN_CARD} overflow-hidden` : 'rounded-lg border border-edge bg-surface overflow-hidden'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
@@ -624,7 +732,7 @@ function OpeningHoursBlock({
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -650,12 +758,11 @@ const FACT_ICONS: Record<PlaceFact['kind'], typeof ChefHat> = {
|
||||
* to its menu are in the map data, and they came along with a lookup that had
|
||||
* already happened.
|
||||
*/
|
||||
function FactList({ facts, t }: { facts: PlaceFact[]; t: TranslationFn }): React.ReactElement | null {
|
||||
function FactList({ facts, dialog = false, t }: { facts: PlaceFact[]; dialog?: boolean; t: TranslationFn }): React.ReactElement | null {
|
||||
if (facts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Overline>{t('places.details.facts')}</Overline>
|
||||
<Block label={t('places.details.facts')} dialog={dialog}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{facts
|
||||
.filter((fact) => fact.kind !== 'openingHours' && fact.kind !== 'rating')
|
||||
@@ -668,11 +775,28 @@ function FactList({ facts, t }: { facts: PlaceFact[]; t: TranslationFn }): React
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)
|
||||
const shared = 'inline-flex items-center gap-1.5 max-w-full rounded-full border border-edge bg-surface px-2 py-1 text-caption text-content-secondary'
|
||||
// A fact url is a community-edited OSM tag (website:menu and friends),
|
||||
// so anything but http(s) renders as the plain chip instead of a link.
|
||||
const href = safeHttpUrl(fact.url)
|
||||
|
||||
if (dialog) {
|
||||
// White pills on the grey column; the label is spelled out in the
|
||||
// shared tooltip for the chip that had to truncate it.
|
||||
const pill = 'inline-flex items-center gap-1.5 max-w-full rounded-full bg-surface-card px-2 py-1 text-caption text-content-secondary shadow-sm ring-1 ring-edge-faint'
|
||||
return (
|
||||
<Tooltip key={fact.kind} label={label}>
|
||||
{href ? (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={`${pill} hover:text-content hover:ring-content-faint transition-colors`}>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<span className={pill}>{body}</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const shared = 'inline-flex items-center gap-1.5 max-w-full rounded-full border border-edge bg-surface px-2 py-1 text-caption text-content-secondary'
|
||||
return href ? (
|
||||
<a
|
||||
key={fact.kind}
|
||||
@@ -691,7 +815,7 @@ function FactList({ facts, t }: { facts: PlaceFact[]; t: TranslationFn }): React
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -704,9 +828,11 @@ function FactList({ facts, t }: { facts: PlaceFact[]; t: TranslationFn }): React
|
||||
*/
|
||||
function DescriptionBlock({
|
||||
description,
|
||||
dialog = false,
|
||||
t,
|
||||
}: {
|
||||
description: MapsPlaceEnrichmentResult['description']
|
||||
dialog?: boolean
|
||||
t: TranslationFn
|
||||
}): React.ReactElement | null {
|
||||
if (!description) return null
|
||||
@@ -720,9 +846,8 @@ function DescriptionBlock({
|
||||
const aboutBrand = !!description.aboutBrand
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Overline>{aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')}</Overline>
|
||||
<div className="rounded-lg border border-edge bg-surface p-2.5 space-y-2">
|
||||
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
|
||||
{aboutBrand && (
|
||||
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
|
||||
<Building2 className="mt-px h-3 w-3 shrink-0" />
|
||||
@@ -730,7 +855,7 @@ function DescriptionBlock({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-caption leading-relaxed text-content whitespace-pre-line">{description.text}</p>
|
||||
<p className="text-caption leading-tight text-content-faint">
|
||||
<p className={dialog ? 'flex flex-wrap items-center gap-x-2 gap-y-0.5 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint'}>
|
||||
{description.sourceUrl ? (
|
||||
<a
|
||||
href={description.sourceUrl}
|
||||
@@ -744,9 +869,9 @@ function DescriptionBlock({
|
||||
) : (
|
||||
sourceLabel
|
||||
)}
|
||||
{description.license && ` · ${description.license}`}
|
||||
{description.license && (dialog ? <span>{description.license}</span> : ` · ${description.license}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isAmapUrl, isGoogleMapsUrl, isMapUrl } from './PlaceFormModal.helpers'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, isAmapUrl, isGoogleMapsUrl, isMapUrl, parseCoordinatePair,
|
||||
timeCollisions,
|
||||
} from './PlaceFormModal.helpers'
|
||||
|
||||
describe('isGoogleMapsUrl', () => {
|
||||
it('accepts the short share hosts', () => {
|
||||
@@ -61,3 +64,85 @@ describe('isMapUrl', () => {
|
||||
expect(isMapUrl('https://example.com/maps')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDuplicatePlace', () => {
|
||||
const form = (over: Partial<typeof DEFAULT_FORM>) => ({ ...DEFAULT_FORM, ...over })
|
||||
|
||||
it('matches a name case- and space-insensitively', () => {
|
||||
expect(findDuplicatePlace(form({ name: ' louvre ' }), [{ name: 'Louvre' }])).toEqual({ name: 'Louvre' })
|
||||
})
|
||||
|
||||
it('matches a shared Google place id whatever the name', () => {
|
||||
const hit = { name: 'Other', google_place_id: 'gp-1' }
|
||||
expect(findDuplicatePlace(form({ name: 'New', google_place_id: 'gp-1' }), [hit])).toBe(hit)
|
||||
})
|
||||
|
||||
it('matches coordinates within about eleven metres, and not beyond', () => {
|
||||
const near = { name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 }
|
||||
expect(findDuplicatePlace(form({ name: 'Iron Lady', lat: '48.85845', lng: '2.29455' }), [near])).toBe(near)
|
||||
expect(findDuplicatePlace(form({ name: 'Iron Lady', lat: '48.8594', lng: '2.2945' }), [near])).toBeNull()
|
||||
})
|
||||
|
||||
it('a stop on a drive ignores the name and compares the OSM object instead', () => {
|
||||
const aral = { name: 'Aral', osm_id: 'node:1' }
|
||||
const rules = { byName: false, byOsmId: true }
|
||||
expect(findDuplicatePlace(form({ name: 'Aral' }), [aral], rules)).toBeNull()
|
||||
expect(findDuplicatePlace(form({ name: 'Tankstelle', osm_id: 'node:1' }), [aral], rules)).toBe(aral)
|
||||
// The ordinary add place never looks at the OSM object.
|
||||
expect(findDuplicatePlace(form({ name: 'Tankstelle', osm_id: 'node:1' }), [aral])).toBeNull()
|
||||
})
|
||||
|
||||
it('an empty list or a missing one finds nothing', () => {
|
||||
expect(findDuplicatePlace(form({ name: 'Louvre' }), [])).toBeNull()
|
||||
expect(findDuplicatePlace(form({ name: 'Louvre' }), null as never)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCoordinatePair', () => {
|
||||
it('splits a pair on a comma, a semicolon or a space', () => {
|
||||
expect(parseCoordinatePair('48.8566, 2.3522')).toEqual(['48.8566', '2.3522'])
|
||||
expect(parseCoordinatePair(' -33.86;151.2 ')).toEqual(['-33.86', '151.2'])
|
||||
expect(parseCoordinatePair('35.68 139.77')).toEqual(['35.68', '139.77'])
|
||||
})
|
||||
|
||||
it('leaves anything else to the field', () => {
|
||||
expect(parseCoordinatePair('48.8566')).toBeNull()
|
||||
expect(parseCoordinatePair('not a coordinate')).toBeNull()
|
||||
expect(parseCoordinatePair('48.8, 2.3, 7')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('endsBeforeStart', () => {
|
||||
it('only compares two complete clocks', () => {
|
||||
expect(endsBeforeStart('14:00', '13:00')).toBe(true)
|
||||
expect(endsBeforeStart('14:00', '14:00')).toBe(true)
|
||||
expect(endsBeforeStart('14:00', '15:00')).toBe(false)
|
||||
expect(endsBeforeStart('14:00', '1')).toBe(false)
|
||||
expect(endsBeforeStart('', '13:00')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeCollisions', () => {
|
||||
const visit = (id: number, day_id: number, place_time: string | null, end_time: string | null = null, name = `P${id}`) =>
|
||||
({ id, day_id, place: { name, place_time, end_time } }) as never
|
||||
|
||||
it('lists the overlapping visits of the same day only', () => {
|
||||
const day = [visit(1, 5, '12:30', '13:30'), visit(2, 5, '13:00', '14:00'), visit(3, 6, '13:00', '14:00'), visit(4, 5, null)]
|
||||
expect(timeCollisions(1, day, '12:30', '13:30').map((a: { id: number }) => a.id)).toEqual([2])
|
||||
})
|
||||
|
||||
it('an open end is a point in time, and touching ends do not overlap', () => {
|
||||
const day = [visit(1, 5, '11:30'), visit(2, 5, '11:00', '12:00'), visit(3, 5, '11:00'), visit(4, 5, '12:00', '13:00')]
|
||||
expect(timeCollisions(1, day, '11:30', '').map((a: { id: number }) => a.id)).toEqual([2])
|
||||
// 12:00 to 13:00 starts where this one ends, and the point at 11:00 is where it starts.
|
||||
expect(timeCollisions(1, day, '11:00', '12:00').map((a: { id: number }) => a.id)).toEqual([2])
|
||||
expect(timeCollisions(1, day, '11:00', '12:30').map((a: { id: number }) => a.id)).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('says nothing without an assignment, a start, or the assignment in the list', () => {
|
||||
const day = [visit(1, 5, '12:00', '13:00'), visit(2, 5, '12:30', '13:30')]
|
||||
expect(timeCollisions(null, day, '12:00', '13:00')).toEqual([])
|
||||
expect(timeCollisions(1, day, '', '13:00')).toEqual([])
|
||||
expect(timeCollisions(99, day, '12:00', '13:00')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import type { Assignment, RoadtripStopType } from '@trek/shared'
|
||||
|
||||
export interface PlaceFormData {
|
||||
name: string
|
||||
@@ -148,3 +148,88 @@ export function mergeResult(
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
||||
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
||||
const DUP_COORD_TOLERANCE = 0.0001
|
||||
|
||||
/**
|
||||
* Which resemblances count as evidence.
|
||||
*
|
||||
* The defaults are the ordinary add place and are not to be changed. A stop on a drive
|
||||
* asks a different question: brand names repeat along a motorway and the map record does
|
||||
* not, so it turns the name off and the OSM object on.
|
||||
*/
|
||||
export interface DuplicateRules {
|
||||
byName?: boolean
|
||||
byOsmId?: boolean
|
||||
}
|
||||
|
||||
export function findDuplicatePlace(
|
||||
form: PlaceFormData,
|
||||
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null; osm_id?: string | null }[],
|
||||
rules: DuplicateRules = {},
|
||||
): { name?: string | null } | null {
|
||||
const { byName = true, byOsmId = false } = rules
|
||||
const name = (form.name || '').trim().toLowerCase()
|
||||
const gid = (form.google_place_id || '').trim()
|
||||
const osmId = (form.osm_id || '').trim()
|
||||
const lat = form.lat ? Number.parseFloat(form.lat) : null
|
||||
const lng = form.lng ? Number.parseFloat(form.lng) : null
|
||||
for (const p of places || []) {
|
||||
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
||||
if (byOsmId && osmId && p.osm_id && p.osm_id === osmId) return p
|
||||
if (byName && name && p.name && p.name.trim().toLowerCase() === name) return p
|
||||
if (
|
||||
lat != null && lng != null && p.lat != null && p.lng != null &&
|
||||
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
||||
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
||||
) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A coordinate pair pasted into the latitude field, as "48.85, 2.35", "48.85;2.35"
|
||||
* or "48.85 2.35": the two halves, or null for anything else, which the field then
|
||||
* takes as typed.
|
||||
*/
|
||||
export function parseCoordinatePair(text: string): [string, string] | null {
|
||||
const match = text.trim().match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
return match ? [match[1], match[2]] : null
|
||||
}
|
||||
|
||||
/** Both clocks are complete and the end is not after the start. */
|
||||
export function endsBeforeStart(start: string, end: string): boolean {
|
||||
return !!start && !!end && start.length >= 5 && end.length >= 5 && end <= start
|
||||
}
|
||||
|
||||
/**
|
||||
* The other visits of the same day whose times overlap this one's.
|
||||
*
|
||||
* An open end collapses to a point in time, and a visit without a start takes no
|
||||
* part: it has no place on the clock to overlap anything.
|
||||
*/
|
||||
export function timeCollisions(
|
||||
assignmentId: number | null,
|
||||
dayAssignments: Pick<Assignment, 'id' | 'day_id' | 'place'>[],
|
||||
start: string,
|
||||
end: string,
|
||||
): Pick<Assignment, 'id' | 'day_id' | 'place'>[] {
|
||||
if (!assignmentId || !start || start.length < 5) return []
|
||||
const current = dayAssignments.find(a => a.id === assignmentId)
|
||||
if (!current) return []
|
||||
const myEnd = end && end.length >= 5 ? end : null
|
||||
return dayAssignments.filter(a => {
|
||||
if (a.id === assignmentId) return false
|
||||
if (a.day_id !== current.day_id) return false
|
||||
const aStart = a.place?.place_time
|
||||
const aEnd = a.place?.end_time
|
||||
if (!aStart) return false
|
||||
// Two intervals overlap if start < otherEnd AND otherStart < end
|
||||
const s1 = start, e1 = myEnd || start
|
||||
const s2 = aStart, e2 = aEnd || aStart
|
||||
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -095
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -121
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
@@ -12,10 +13,19 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories';
|
||||
import PlaceFormModal from './PlaceFormModal';
|
||||
|
||||
// Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI
|
||||
// Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI.
|
||||
// The aria props land on the input, as the real picker spreads them onto its own.
|
||||
vi.mock('../shared/CustomTimePicker', () => ({
|
||||
default: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
|
||||
default: ({ value, onChange, placeholder, ...aria }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
'aria-label'?: string
|
||||
'aria-describedby'?: string
|
||||
'aria-invalid'?: boolean
|
||||
}) => (
|
||||
<input
|
||||
{...aria}
|
||||
data-testid="time-picker"
|
||||
type="text"
|
||||
value={value}
|
||||
@@ -136,8 +146,8 @@ describe('PlaceFormModal', () => {
|
||||
it('FE-COMP-PLACEFORM-015: categories appear in category selector', () => {
|
||||
const cats = [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Park' })];
|
||||
render(<PlaceFormModal {...defaultProps} categories={cats} />);
|
||||
// Category label is present
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
// The category is a pill on the head band, named by what it picks.
|
||||
expect(screen.getByRole('button', { name: /^Category:/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Form initialization ──────────────────────────────────────────────────────
|
||||
@@ -1997,7 +2007,7 @@ describe('PlaceFormModal as a road trip service stop', () => {
|
||||
withBudgetAddon();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Category:/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -2012,7 +2022,8 @@ describe('PlaceFormModal as a road trip service stop', () => {
|
||||
// what it needs first. It turns into the list of legs the moment a place arrives.
|
||||
expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument();
|
||||
// A petrol stop is not an activity with a budget line, and it is not a taste either.
|
||||
expect(screen.queryByText('Category')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Category:/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New category' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2074,8 +2085,8 @@ describe('PlaceFormModal as a road trip service stop', () => {
|
||||
|
||||
named('Supercharger');
|
||||
typeCoords('51.05', '13.73');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Berlin · Dresden/ }));
|
||||
fireEvent.click(screen.getByText(/Bremen · Berlin/));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Berlin\s+Dresden/ }));
|
||||
fireEvent.click(screen.getByText(/Bremen\s+Berlin/));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
@@ -2285,3 +2296,392 @@ describe('PlaceFormModal plugin search (#2221)', () => {
|
||||
expect(perKeystroke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The dialog frame: keys, focus, paste and the columns beside the form ──────
|
||||
|
||||
describe('PlaceFormModal on the dialog frame', () => {
|
||||
const withCollections = () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
|
||||
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
|
||||
incomingInvites: [],
|
||||
});
|
||||
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
|
||||
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
|
||||
places: [{ id: 10, collection_id: 1, name: 'Zebra Cafe', status: 'idea' }],
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-096: the dialog is named by its title', () => {
|
||||
render(<PlaceFormModal {...defaultProps} place={buildPlace({ name: 'Louvre' })} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Edit Place' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-097: Escape in the suggestion list closes the list, and only the next one closes the dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({ suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }] }),
|
||||
),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} onClose={onClose} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
await screen.findByText('Paris, France');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByText('Paris, France')).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-098: Escape in a saved-places filter closes the filter, not the dialog', async () => {
|
||||
withCollections();
|
||||
const onClose = vi.fn();
|
||||
render(<PlaceFormModal {...defaultProps} onClose={onClose} />);
|
||||
await screen.findByText('Zebra Cafe');
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /All lists/ });
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-099: Escape with nothing open closes the dialog', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PlaceFormModal {...defaultProps} onClose={onClose} />);
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-100: a new place opens with the search focused; an edit and a place from the map do not', () => {
|
||||
const { unmount } = render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText('Search places...')).toHaveFocus();
|
||||
unmount();
|
||||
|
||||
const edit = render(<PlaceFormModal {...defaultProps} place={buildPlace({ name: 'Louvre' })} />);
|
||||
expect(screen.getByPlaceholderText('Search places...')).not.toHaveFocus();
|
||||
expect(screen.getByRole('dialog')).toHaveFocus();
|
||||
edit.unmount();
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 48.85, lng: 2.35, name: 'Pin' }} />);
|
||||
expect(screen.getByPlaceholderText('Search places...')).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-101: Enter in a field never saves; only the footer button does', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre{Enter}');
|
||||
await user.type(screen.getByPlaceholderText('https://...'), 'https://louvre.fr{Enter}');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-102: an image pasted anywhere in the dialog, head band included, is attached', () => {
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
fireEvent.paste(screen.getByText('Add Place/Activity'), {
|
||||
clipboardData: { items: [{ type: 'image/png', getAsFile: () => new File(['x'], 'from-header.png', { type: 'image/png' }) }] },
|
||||
});
|
||||
expect(within(screen.getByTestId('pending-files')).getByText('from-header.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] },
|
||||
});
|
||||
await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' }));
|
||||
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-104: every field is labelled, the coordinates as one group', () => {
|
||||
const place = buildPlace({ name: 'Louvre' });
|
||||
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Search' })).toBe(screen.getByPlaceholderText('Search places...'));
|
||||
// The icon button beside it carries the same name.
|
||||
expect(within(screen.getByPlaceholderText('Search places...').closest('.flex') as HTMLElement).getByRole('button', { name: 'Search' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Name *')).toHaveValue('Louvre');
|
||||
expect(screen.getByLabelText('Description')).toBe(screen.getByPlaceholderText(/Short description/i));
|
||||
expect(screen.getByLabelText('Notes')).toBe(screen.getByPlaceholderText(/Personal notes/i));
|
||||
expect(screen.getByLabelText('Address')).toBe(screen.getByPlaceholderText(/Street, City, Country/i));
|
||||
expect(screen.getByLabelText('Notes for this day')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Website')).toBe(screen.getByPlaceholderText('https://...'));
|
||||
const coords = screen.getByRole('group', { name: 'Coordinates' });
|
||||
expect(within(coords).getByPlaceholderText(/Latitude/i)).toBeInTheDocument();
|
||||
expect(within(coords).getByPlaceholderText(/Longitude/i)).toBeInTheDocument();
|
||||
// Each Markdown field keeps its own formatting bar.
|
||||
expect(screen.getAllByRole('toolbar')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-105: the new category field takes the focus when it opens', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.click(screen.getByRole('button', { name: 'New category' }));
|
||||
expect(screen.getByPlaceholderText('Category name')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-106: the dialog widens with the columns beside the form', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, placesEnrichEnabled: false });
|
||||
const alone = render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-w-[600px]');
|
||||
alone.unmount();
|
||||
|
||||
seedStore(useAuthStore, { placesEnrichEnabled: true });
|
||||
const withDetails = render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-w-[960px]');
|
||||
withDetails.unmount();
|
||||
|
||||
withCollections();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-w-[1280px]');
|
||||
// Details on the left, the form, saved places on the right.
|
||||
const [details, form, saved] = Array.from(document.querySelector('form')!.parentElement!.children);
|
||||
expect(details).toHaveTextContent('Place details');
|
||||
expect(form.tagName).toBe('FORM');
|
||||
expect(saved).toContainElement(screen.getByPlaceholderText(/Search your saved places/i));
|
||||
await screen.findByText('Zebra Cafe');
|
||||
});
|
||||
});
|
||||
|
||||
// ── The head band and the panels: the booking editors' look ──────────────────
|
||||
|
||||
describe('PlaceFormModal in the look of the booking editors', () => {
|
||||
const header = () => screen.getByRole('dialog').querySelector('header') as HTMLElement;
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-107: the name is typed in the head band, under the title as its eyebrow', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
|
||||
const name = screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i);
|
||||
expect(header()).toContainElement(name);
|
||||
expect(within(header()).getByRole('heading', { name: 'Add Place/Activity' })).toBeInTheDocument();
|
||||
// Until there is a name, the line under it says one is required.
|
||||
expect(within(header()).getByText('Name *')).toBeInTheDocument();
|
||||
|
||||
await user.type(name, 'Louvre');
|
||||
expect(within(header()).queryByText('Name *')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-108: the category pill picks a category and clears it again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const cat = buildCategory({ name: 'Museums' });
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} categories={[cat]} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Category:/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Museums' }));
|
||||
expect(within(header()).getByText('Museums')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Category:/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'No Category' }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
||||
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].category_id).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-109: Enter in the new category field creates it, and never saves the place', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} onCategoryCreated={onCategoryCreated} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New category' }));
|
||||
// The field takes the pill's place on the band, OK and Cancel beside it.
|
||||
expect(header()).toContainElement(screen.getByPlaceholderText('Category name'));
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Beaches{Enter}');
|
||||
|
||||
expect(onCategoryCreated).toHaveBeenCalledWith({ name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument());
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-110: a picked result names its index in the band until the dialog reopens', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Louvre', address: 'Paris', lat: '48.86', lng: '2.33' }], source: 'openstreetmap' }),
|
||||
),
|
||||
);
|
||||
const { rerender } = render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Louvre{Enter}');
|
||||
await user.click(await screen.findByText('Louvre'));
|
||||
|
||||
expect(within(header()).getByText('OpenStreetMap')).toBeInTheDocument();
|
||||
|
||||
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
|
||||
rerender(<PlaceFormModal {...defaultProps} />);
|
||||
expect(within(header()).queryByText('OpenStreetMap')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-111: a picked suggestion is named after the row that was clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({ suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }], source: 'trek-places' }),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () =>
|
||||
HttpResponse.json({ place: { name: 'Eiffel Tower', address: 'Paris', lat: 48.858, lng: 2.294 } }),
|
||||
),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
fireEvent.mouseDown(await screen.findByText('Paris, France'));
|
||||
|
||||
expect(await screen.findByDisplayValue('48.858')).toBeInTheDocument();
|
||||
expect(within(header()).getByText('TREK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-112: a lookup in flight shows as a status pill in the band', async () => {
|
||||
const user = userEvent.setup();
|
||||
let answer: () => void = () => {};
|
||||
server.use(
|
||||
http.post('/api/maps/search', async () => {
|
||||
await new Promise<void>(resolve => { answer = resolve; });
|
||||
return HttpResponse.json({ places: [], source: 'openstreetmap' });
|
||||
}),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Louvre{Enter}');
|
||||
|
||||
const busy = await screen.findByRole('status', { name: 'Loading place details…' });
|
||||
expect(header()).toContainElement(busy);
|
||||
answer();
|
||||
await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading place details…' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-113: each coordinate has its own label, and the pair stays one group', () => {
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const coords = screen.getByRole('group', { name: 'Coordinates' });
|
||||
expect(within(coords).getByLabelText('Latitude')).toBe(screen.getByPlaceholderText(/Latitude/i));
|
||||
expect(within(coords).getByLabelText('Longitude')).toBe(screen.getByPlaceholderText(/Longitude/i));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-114: the actions in the body are white, not grey', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
for (const button of [screen.getByRole('button', { name: /Attach/i }), screen.getByRole('button', { name: /Create expense/i })]) {
|
||||
expect(button).toHaveClass('bg-surface-card');
|
||||
expect(button).not.toHaveClass('bg-surface-secondary');
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-115: a stop on a drive shows its kind in the band, and a double as the line under the name', () => {
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
places: [buildPlace({ id: 3, name: 'Aral Dammer Berge', lat: 52.5, lng: 8.2 })],
|
||||
});
|
||||
render(
|
||||
<PlaceFormModal
|
||||
{...defaultProps}
|
||||
serviceStop={{
|
||||
days: [{ dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen'] }],
|
||||
appendDay: { dayId: 5, dayNumber: 1, position: 2 },
|
||||
targetFor: () => ({ dayId: 5, position: 1, offRouteKm: 0.2 }),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(within(header()).getByText('Fuel')).toBeInTheDocument();
|
||||
expect(within(header()).queryByRole('button', { name: /^Category:/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Tankstelle' } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/Latitude/i), { target: { value: '52.5' } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/Longitude/i), { target: { value: '8.2' } });
|
||||
expect(header()).toContainElement(screen.getByText(/is already on this trip/));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-116: the details column waits with a quiet hint, not the mascot', () => {
|
||||
seedStore(useAuthStore, { placesEnrichEnabled: true });
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const column = screen.getByText('Place details', { exact: true }).closest('aside') as HTMLElement;
|
||||
expect(within(column).getByText('Pick a result to see more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-117: Escape closes only the new category field, and the focus goes back to its +', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<PlaceFormModal {...defaultProps} onClose={onClose} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New category' }));
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Beaches');
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Louvre');
|
||||
expect(screen.getByRole('button', { name: 'New category' })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-118: Cancel and OK hand the focus back to the + as well', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||
render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New category' }));
|
||||
await user.click(within(header()).getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.getByRole('button', { name: 'New category' })).toHaveFocus();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New category' }));
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Beaches');
|
||||
await user.click(within(header()).getByRole('button', { name: 'OK' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'New category' })).toHaveFocus());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-119: the time fields carry their names, and End says when it is wrong', () => {
|
||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Start' })).not.toHaveAttribute('aria-invalid', 'true');
|
||||
const end = screen.getByRole('textbox', { name: 'End' });
|
||||
expect(end).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(end).toHaveAccessibleDescription('End time is before start time');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-120: the formatting bars show their names in the shared tooltip, never the native one', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const bars = screen.getAllByRole('toolbar');
|
||||
expect(bars).toHaveLength(2);
|
||||
for (const bar of bars) {
|
||||
for (const button of within(bar).getAllByRole('button')) expect(button).not.toHaveAttribute('title');
|
||||
}
|
||||
await user.hover(within(bars[0]).getByRole('button', { name: 'Bold' }));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Bold');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-121: stacked in a narrow window, the details column takes the full width', () => {
|
||||
seedStore(useAuthStore, { placesEnrichEnabled: true });
|
||||
const { unmount } = render(<PlaceFormModal {...defaultProps} isMobile />);
|
||||
const stacked = screen.getByText('Place details', { exact: true }).closest('aside') as HTMLElement;
|
||||
expect(stacked).not.toHaveClass('sm:w-80');
|
||||
unmount();
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const beside = screen.getByText('Place details', { exact: true }).closest('aside') as HTMLElement;
|
||||
expect(beside).toHaveClass('sm:w-80');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import type { AssignmentsMap } from '../../types';
|
||||
import type { AssignmentsMap, Place, Reservation } from '../../types';
|
||||
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
|
||||
|
||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||
@@ -20,7 +20,10 @@ vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
mapsApi: { details: vi.fn().mockResolvedValue({ place: null }) },
|
||||
mapsApi: {
|
||||
details: vi.fn().mockResolvedValue({ place: null }),
|
||||
placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -174,13 +177,8 @@ describe('PlaceInspector', () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<PlaceInspector {...defaultProps} onClose={onClose} />);
|
||||
// Find the X button — it's the close button with an X icon inside
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// The close button is typically in the header, first button with X icon
|
||||
const closeBtn = buttons.find(btn => btn.querySelector('svg'));
|
||||
// Click the last-found header button that has no text label (the X)
|
||||
// More reliable: find button by its position as close button
|
||||
await user.click(buttons[0]); // first button is the close X
|
||||
// The round X on the head band is named by its tooltip.
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -196,11 +194,11 @@ describe('PlaceInspector', () => {
|
||||
it('FE-PLANNER-INSPECTOR-013: clicking Edit button calls onEdit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEdit = vi.fn();
|
||||
const { container } = render(<PlaceInspector {...defaultProps} onEdit={onEdit} />);
|
||||
// The edit button has Edit2 icon — find footer buttons
|
||||
render(<PlaceInspector {...defaultProps} onEdit={onEdit} />);
|
||||
// Edit is the primary action at the right end of the footer.
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
// Edit button is second-to-last in footer (before delete)
|
||||
const editBtn = allButtons[allButtons.length - 2];
|
||||
const editBtn = screen.getByRole('button', { name: 'Edit' });
|
||||
expect(allButtons[allButtons.length - 1]).toBe(editBtn);
|
||||
await user.click(editBtn);
|
||||
expect(onEdit).toHaveBeenCalled();
|
||||
});
|
||||
@@ -210,8 +208,9 @@ describe('PlaceInspector', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<PlaceInspector {...defaultProps} onDelete={onDelete} />);
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
// Delete button is the last button in the footer
|
||||
const deleteBtn = allButtons[allButtons.length - 1];
|
||||
// The square delete button sits just before Edit, named by its tooltip.
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete' });
|
||||
expect(allButtons[allButtons.length - 2]).toBe(deleteBtn);
|
||||
await user.click(deleteBtn);
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
@@ -347,14 +346,18 @@ describe('PlaceInspector', () => {
|
||||
const user = userEvent.setup();
|
||||
const p = buildPlace({ id: 202, google_place_id: 'ChIJ003' });
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
// Wait for hours to load — the button text shows a day's hours line
|
||||
const hoursBtn = await screen.findByText(/Show opening hours|Opening Hours|Mon:|9:00|09:00/i);
|
||||
const btn = hoursBtn.closest('button')!;
|
||||
// Wait for hours to load: the section is labelled, its toggle shows a day's hours line
|
||||
expect(await screen.findByText('Opening Hours')).toBeTruthy();
|
||||
const btn = screen.getByRole('button', { name: /Show opening hours|Mon:|Tue:/i });
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(btn);
|
||||
// After expand, one of the hours lines should be visible
|
||||
// After expand, the whole week is listed and the toggle folds it again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Mon:/)).toBeTruthy();
|
||||
expect(screen.getByText(/Tue:/)).toBeTruthy();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
expect(screen.getByRole('button', { name: /Show opening hours|Mon:|Tue:/i })).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-026: open/closed badge shown when open_now is available', async () => {
|
||||
@@ -551,6 +554,74 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-109: with a detail to show, the booking card opens it instead of the editor', async () => {
|
||||
const onOpenBooking = vi.fn();
|
||||
const onEditTransport = vi.fn();
|
||||
const onEditReservation = vi.fn();
|
||||
const ferry = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as Partial<Reservation>);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[ferry]}
|
||||
onEditTransport={onEditTransport}
|
||||
onEditReservation={onEditReservation}
|
||||
onOpenBooking={onOpenBooking}
|
||||
/>
|
||||
);
|
||||
// The card is the booking, so it says it opens it rather than edits it.
|
||||
expect(screen.queryByRole('button', { name: 'Edit Reservation' })).toBeNull();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open booking: Ferry to Corfu' }));
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(ferry);
|
||||
expect(onEditTransport).not.toHaveBeenCalled();
|
||||
expect(onEditReservation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-110: with a detail to show, a viewer without an editor can still open the card, by keyboard too', () => {
|
||||
const onOpenBooking = vi.fn();
|
||||
const ticket = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as Partial<Reservation>);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[ticket]}
|
||||
onOpenBooking={onOpenBooking}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByRole('button', { name: 'Open booking: Museum Ticket' });
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
fireEvent.keyDown(card, { key: ' ' });
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2);
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(ticket);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-111: several bookings on one stop are told apart by name', async () => {
|
||||
const onOpenBooking = vi.fn();
|
||||
const lunch = buildReservation({ id: 61, title: 'Lunch at Nishiki', status: 'confirmed', type: 'restaurant', assignment_id: 99 } as Partial<Reservation>);
|
||||
const tour = buildReservation({ id: 62, title: 'Market Tour', status: 'pending', type: 'tour', assignment_id: 99 } as Partial<Reservation>);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[lunch, tour]}
|
||||
onOpenBooking={onOpenBooking}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open booking: Market Tour' }));
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(tour);
|
||||
expect(screen.getByRole('button', { name: 'Open booking: Lunch at Nishiki' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Participants ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => {
|
||||
@@ -825,11 +896,14 @@ describe('PlaceInspector', () => {
|
||||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||
expect(scroll.style.overflowY).toBe('auto');
|
||||
expect(scroll.style.minHeight).toBe('0px');
|
||||
const cls = scroll.className.split(/\s+/);
|
||||
expect(cls).toContain('overflow-y-auto');
|
||||
expect(cls).toContain('min-h-0');
|
||||
// flex must allow the region to shrink/grow within the capped card
|
||||
expect(scroll.style.flex).not.toBe('');
|
||||
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||
expect(cls).toContain('flex-1');
|
||||
expect(cls).not.toContain('flex-none');
|
||||
// and the card around it is the one that is capped
|
||||
expect((scroll.parentElement as HTMLElement).className).toContain('max-h-[60vh]');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||
@@ -847,12 +921,13 @@ describe('PlaceInspector', () => {
|
||||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||
// Both description and notes containers must keep their natural height
|
||||
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||
// Both description and notes blocks must keep their natural height
|
||||
// (flex-none), otherwise they compress inside the flex column and
|
||||
// clip the text with no scroll (issue #1195).
|
||||
expect(notes.length).toBe(2);
|
||||
for (const el of notes) {
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
expect((el.closest('section') as HTMLElement).className.split(/\s+/)).toContain('flex-none');
|
||||
expect(el.style.overflow).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -885,7 +960,12 @@ describe('PlaceInspector', () => {
|
||||
const track = { ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]' };
|
||||
render(<PlaceInspector {...defaultProps} place={track} onUpdatePlace={onUpdatePlace} />);
|
||||
|
||||
fireEvent.click(screen.getAllByText('Track color')[0]);
|
||||
// The labelled row shows the colour in use and opens the picker.
|
||||
const toggle = screen.getByRole('button', { name: /^Track color/ });
|
||||
expect(toggle).toHaveTextContent('Automatic color');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '#059669' }));
|
||||
expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: '#059669' });
|
||||
});
|
||||
@@ -895,7 +975,9 @@ describe('PlaceInspector', () => {
|
||||
const track = { ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#059669' };
|
||||
render(<PlaceInspector {...defaultProps} place={track} onUpdatePlace={onUpdatePlace} />);
|
||||
|
||||
fireEvent.click(screen.getAllByText('Track color')[0]);
|
||||
const toggle = screen.getByRole('button', { name: /^Track color/ });
|
||||
expect(toggle).toHaveTextContent('#059669');
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Automatic color' }));
|
||||
// null is what clears the column; undefined would leave the colour in place.
|
||||
expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: null });
|
||||
@@ -1083,22 +1165,26 @@ describe('PlaceInspector', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-070: an action button restores its idle background after hover', () => {
|
||||
render(<PlaceInspector {...defaultProps} />);
|
||||
const edit = screen.getByText('Edit').closest('button') as HTMLButtonElement;
|
||||
act(() => { edit.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(edit.style.background).toBe('var(--bg-tertiary)');
|
||||
act(() => { edit.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(edit.style.background).toBe('var(--bg-hover)');
|
||||
it('FE-PLANNER-INSPECTOR-070: the footer keeps the ways out on the left and delete + edit on the right', () => {
|
||||
const p = buildPlace({ id: 712, name: 'Footer Place', lat: 48.1, lng: 2.1, website: 'https://example.org' });
|
||||
render(<PlaceInspector {...defaultProps} place={p} selectedDayId={1} assignments={{ '1': [] }} />);
|
||||
const footer = document.querySelector('footer') as HTMLElement;
|
||||
const names = within(footer).getAllByRole('button').map(b => b.getAttribute('aria-label') || b.textContent);
|
||||
expect(names).toEqual(['Add to Day', 'Navigation', 'Open Website', 'Delete', 'Edit']);
|
||||
// Hover looks are classes on the tokens now, not inline styles swapped by handlers.
|
||||
const edit = within(footer).getByRole('button', { name: 'Edit' });
|
||||
expect(edit.getAttribute('style') ?? '').not.toMatch(/background/);
|
||||
expect(edit.className).toContain('bg-accent');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-071: the header close button resets its hover background', () => {
|
||||
it('FE-PLANNER-INSPECTOR-071: the header close button is named and shows its tooltip', async () => {
|
||||
render(<PlaceInspector {...defaultProps} />);
|
||||
const close = document.querySelector('.bg-surface-hover') as HTMLButtonElement;
|
||||
act(() => { close.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(close.style.background).toBe('var(--bg-tertiary)');
|
||||
act(() => { close.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(close.style.background).toBe('var(--bg-hover)');
|
||||
const close = screen.getByRole('button', { name: 'Close' });
|
||||
expect(close.getAttribute('title')).toBeNull();
|
||||
fireEvent.mouseEnter(close);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Close');
|
||||
fireEvent.mouseLeave(close);
|
||||
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-072: rating a place forwards the vote', () => {
|
||||
@@ -1125,7 +1211,16 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Flight to Nice')).toBeTruthy();
|
||||
expect(screen.getByText('ABC999')).toBeTruthy();
|
||||
expect(screen.getByText('Aisle seat')).toBeTruthy();
|
||||
expect(screen.getByText(/Air France AF123 · CDG → NCE · TGV1 · Gl\. 7 · Check-in 06:00 · Check-out 12:00/)).toBeTruthy();
|
||||
// Each fact of the metadata is its own pill, labelled in the user's language.
|
||||
for (const fact of ['Air France AF123', 'CDG → NCE', 'TGV1', 'Platform 7', 'Check-in 06:00', 'Check-out 12:00']) {
|
||||
expect(screen.getByText(fact)).toBeTruthy();
|
||||
}
|
||||
expect(screen.queryByText(/·/)).toBeNull();
|
||||
// Date, time and code are labelled fields.
|
||||
expect(screen.getByText('Date')).toBeTruthy();
|
||||
expect(screen.getByText('Time')).toBeTruthy();
|
||||
expect(screen.getByText('Booking Code')).toBeTruthy();
|
||||
expect(screen.getByText('Pending')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-074: a reservation whose metadata has no printable fields shows no meta line', () => {
|
||||
@@ -1134,7 +1229,7 @@ describe('PlaceInspector', () => {
|
||||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||||
reservations={[res]} />);
|
||||
expect(screen.getByText('Plain booking')).toBeTruthy();
|
||||
expect(screen.queryByText(/Gl\./)).toBeNull();
|
||||
expect(screen.queryByText(/Platform|Gl\./)).toBeNull();
|
||||
});
|
||||
|
||||
// ── Participants ─────────────────────────────────────────────────────────────
|
||||
@@ -1318,26 +1413,29 @@ describe('PlaceInspector', () => {
|
||||
expect(onSetParticipants).toHaveBeenCalledWith(9, 1, []);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-093: participant chips and the add menu reset their hover styling', () => {
|
||||
it('FE-PLANNER-INSPECTOR-093: participant chips show the removal in the danger tokens, the add menu opens and closes', () => {
|
||||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} />);
|
||||
const chip = screen.getByText('ada').closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(chip);
|
||||
expect(chip.className).toContain('text-[#ef4444]');
|
||||
fireEvent.mouseLeave(chip);
|
||||
expect(chip.className).toContain('text-content');
|
||||
|
||||
const add = screen.getByText('+') as HTMLButtonElement;
|
||||
act(() => { add.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(add.style.color).toBe('var(--text-primary)');
|
||||
act(() => { add.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(add.style.color).toBe('var(--text-faint)');
|
||||
// A removable chip turns to the danger tokens on hover and strikes its name through.
|
||||
expect(chip.className).toContain('enabled:hover:text-danger');
|
||||
expect(chip.className).not.toMatch(/#[0-9a-f]{3,6}/i);
|
||||
expect(screen.getByText('ada').className).toContain('group-hover/chip:line-through');
|
||||
|
||||
const add = screen.getByRole('button', { name: 'Add' });
|
||||
expect(add).toHaveTextContent('+');
|
||||
expect(add).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(add);
|
||||
expect(add).toHaveAttribute('aria-expanded', 'true');
|
||||
const entry = screen.getByText('cleo').closest('button') as HTMLButtonElement;
|
||||
act(() => { entry.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(entry.style.background).toBe('var(--bg-hover)');
|
||||
act(() => { entry.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(entry.style.background).toBe('none');
|
||||
expect(entry.className).toContain('hover:bg-surface-hover');
|
||||
|
||||
// Escape and a click elsewhere close the list again.
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByText('cleo')).toBeNull();
|
||||
fireEvent.click(add);
|
||||
expect(screen.getByText('cleo')).toBeTruthy();
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByText('cleo')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-094: a flight number without an airline is still summarised', () => {
|
||||
@@ -1488,7 +1586,7 @@ describe('PlaceInspector', () => {
|
||||
const onUpdatePlace = vi.fn();
|
||||
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
fireEvent.doubleClick(screen.getByText(place.name));
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
member.unmount();
|
||||
@@ -1496,7 +1594,7 @@ describe('PlaceInspector', () => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
|
||||
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1565,6 +1663,82 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||||
expect(screen.queryByText('Remove from Day')).toBeNull();
|
||||
});
|
||||
|
||||
// ── Head band (booking-detail language) ──────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-105: the photo credit stays under the avatar next to the open/closed state (CC BY-SA)', async () => {
|
||||
// It used to be dropped whenever the open/closed tag took its place under the
|
||||
// avatar; the tag is a pill now, and the licence credit is always shown.
|
||||
vi.mocked(mapsApi.placePhotoCredit).mockResolvedValue({ credit: 'Jane Doe, CC BY-SA 4.0' } as never);
|
||||
vi.mocked(mapsApi.details).mockResolvedValue({ place: { open_now: true } } as never);
|
||||
const p = buildPlace({ id: 713, name: 'Credited', google_place_id: 'gp-713', image_url: '/api/maps/place-photo/commons%3Aabc/bytes', lat: null, lng: null });
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
expect(await screen.findByText('Open')).toBeTruthy();
|
||||
const credit = await screen.findByText('Jane Doe, CC BY-SA 4.0');
|
||||
expect(credit.getAttribute('title')).toBeNull();
|
||||
expect(vi.mocked(mapsApi.placePhotoCredit)).toHaveBeenCalledWith('commons:abc');
|
||||
// The full credit is one hover away when the line is cut short.
|
||||
fireEvent.mouseEnter(credit);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Jane Doe, CC BY-SA 4.0');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-106: the head band states its facts as pills and takes the category tint', async () => {
|
||||
const coloured = { ...cat, color: '#10b981' };
|
||||
const p: Place = { ...place, id: 714, category_id: cat.id, source: 'dawarich', phone: '+33 1 00', price: 12, currency: 'EUR' };
|
||||
render(<PlaceInspector {...defaultProps} place={p} categories={[coloured]} />);
|
||||
const header = document.querySelector('header') as HTMLElement;
|
||||
expect(header.style.background).toContain('color-mix');
|
||||
// Category as a pill with its colour dot.
|
||||
const categoryPill = within(header).getByText('Landmark');
|
||||
expect((categoryPill.querySelector('span') as HTMLElement).style.background).toBe('rgb(16, 185, 129)');
|
||||
// The Dawarich mark keeps its tooltip and gets a name.
|
||||
const mark = within(header).getByRole('img', { name: 'Added from your Dawarich recordings' });
|
||||
fireEvent.mouseEnter(mark);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Added from your Dawarich recordings');
|
||||
// Phone is a call link, the price a pill, the coordinates only from sm up.
|
||||
expect(within(header).getByText('+33 1 00').closest('a')).toHaveAttribute('href', 'tel:+33 1 00');
|
||||
expect(within(header).getByText(/12,00/)).toBeTruthy();
|
||||
const coords = within(header).getByText(/48\.858400/);
|
||||
expect(coords.className.split(/\s+/)).toEqual(expect.arrayContaining(['hidden', 'sm:inline-flex']));
|
||||
// Nothing in the head band is separated with a middle dot any more.
|
||||
expect(header.textContent).not.toContain('·');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-107: the rating block is labelled and quotes the first usable review', async () => {
|
||||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||||
place: { rating: 4.1, reviews: [{ text: 'meh' }, { text: 'Lovely staff and view' }] },
|
||||
} as never);
|
||||
const p = buildPlace({ id: 715, name: 'Reviewed', google_place_id: 'gp-715' });
|
||||
render(<PlaceInspector {...defaultProps} place={p} onRate={vi.fn()} />);
|
||||
expect(await screen.findByText(/Lovely staff and view/)).toBeTruthy();
|
||||
const section = screen.getByText('Rating').closest('section') as HTMLElement;
|
||||
expect(within(section).getAllByRole('radio')).toHaveLength(5);
|
||||
expect(within(section).getByText(/Lovely staff and view/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-108: every body block sits under its own label', () => {
|
||||
const p: Place = { ...place, id: 716, notes: 'Bring cash', description: 'Iron lady' };
|
||||
const assignmentInDay = [{ id: 99, place: p, day_id: 1, place_id: p.id, order_index: 0, notes: 'Early entry' }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
place={p}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[buildReservation({ id: 610, title: 'Summit ticket', assignment_id: 99 })]}
|
||||
tripMembers={[{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }]}
|
||||
/>
|
||||
);
|
||||
for (const [label, content] of [['Description', 'Iron lady'], ['Notes', 'Bring cash'], ['Notes for this day', 'Early entry'], ['Bookings', 'Summit ticket'], ['Participants', 'ada'], ['Files', '']] as const) {
|
||||
const section = screen.getByText(label, { selector: 'div' }).closest('section') as HTMLElement;
|
||||
expect(section).toBeTruthy();
|
||||
if (content) expect(within(section).getByText(content)).toBeTruthy();
|
||||
}
|
||||
// Bookings and participants sit side by side from sm up.
|
||||
const grid = screen.getByText('Bookings').closest('section')!.parentElement as HTMLElement;
|
||||
expect(grid.className).toContain('sm:grid-cols-2');
|
||||
});
|
||||
})
|
||||
|
||||
// ── Blur booking codes on the linked-booking strip (#2457) ────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
// FE-PLANNER-BULKCAT-001 to FE-PLANNER-BULKCAT-004
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { buildCategory } from '../../../tests/helpers/factories';
|
||||
import { PlacesBulkCategoryModal, CategoryTile } from './PlacesBulkCategoryModal';
|
||||
|
||||
const CATS = [buildCategory({ id: 1, name: 'Museum', color: '#3b82f6' }), buildCategory({ id: 2, name: 'Food', color: null })];
|
||||
|
||||
describe('PlacesBulkCategoryModal', () => {
|
||||
it('FE-PLANNER-BULKCAT-001: names the dialog and says how many places it changes', () => {
|
||||
render(<PlacesBulkCategoryModal count={3} categories={CATS} onPick={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Change category' })).toBeInTheDocument();
|
||||
expect(screen.getByText('3 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BULKCAT-002: a category row applies that category', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<PlacesBulkCategoryModal count={1} categories={CATS} onPick={onPick} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Food' }));
|
||||
expect(onPick).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BULKCAT-003: "No Category" clears it, even with no categories to offer', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<PlacesBulkCategoryModal count={1} categories={[]} onPick={onPick} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'No Category' }));
|
||||
expect(onPick).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BULKCAT-004: Cancel, the X and Escape close without picking', () => {
|
||||
const onPick = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<PlacesBulkCategoryModal count={1} categories={CATS} onPick={onPick} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CategoryTile', () => {
|
||||
it('FE-PLANNER-BULKCAT-005: tints with the category colour, and falls back to the neutral pin without one', () => {
|
||||
const { container, rerender } = render(<CategoryTile category={{ icon: 'MapPin', color: '#3b82f6' }} size={20} />);
|
||||
const tile = container.firstChild as HTMLElement;
|
||||
expect(tile.style.width).toBe('20px');
|
||||
expect(tile.style.background).toContain('color-mix');
|
||||
|
||||
rerender(<CategoryTile />);
|
||||
expect((container.firstChild as HTMLElement).className).toContain('bg-surface-tertiary');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, MapPin } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { MapPin, Tag } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { tintOf } from './planParts'
|
||||
import type { Category } from '../../types'
|
||||
|
||||
interface PlacesBulkCategoryModalProps {
|
||||
@@ -11,56 +13,71 @@ interface PlacesBulkCategoryModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||||
padding: '8px 10px', borderRadius: 7, border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', textAlign: 'left',
|
||||
/**
|
||||
* A category's icon on a wash of its colour, the square the category lists of the
|
||||
* places column are made of. Without a category it is the neutral pin.
|
||||
*/
|
||||
export function CategoryTile({ category, size = 24 }: { category?: Pick<Category, 'icon' | 'color'> | null; size?: number }) {
|
||||
if (!category) {
|
||||
return (
|
||||
<span className="grid flex-none place-items-center rounded-[7px] bg-surface-tertiary text-content-faint" style={{ width: size, height: size }}>
|
||||
<MapPin size={Math.round(size * 0.5)} strokeWidth={2.2} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const Icon = getCategoryIcon(category.icon)
|
||||
const color = category.color || 'var(--text-muted)'
|
||||
return (
|
||||
<span className="grid flex-none place-items-center rounded-[7px]" style={{ width: size, height: size, background: tintOf(color, 16) }}>
|
||||
<Icon size={Math.round(size * 0.5)} strokeWidth={2.2} style={{ color }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const hoverOn = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'var(--bg-hover)' }
|
||||
const hoverOff = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'transparent' }
|
||||
|
||||
const ROW = 'flex w-full items-center gap-2.5 rounded-[10px] px-2 py-1.5 text-left hover:bg-surface-hover'
|
||||
|
||||
/**
|
||||
* Popup for the Places selection toolbar: pick one category to apply to every
|
||||
* currently-selected place. Reuses the category swatch styling from the header's
|
||||
* filter dropdown; clicking a row applies immediately and closes.
|
||||
* currently-selected place. Clicking a row applies it at once and closes.
|
||||
*/
|
||||
export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }: PlacesBulkCategoryModalProps) {
|
||||
const { t } = useTranslation()
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
const titleId = useId()
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={titleId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Tag size={20} strokeWidth={1.9} className="text-content" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={titleId}
|
||||
onClose={onClose}
|
||||
title={t('places.changeCategory')}
|
||||
sub={t('places.selectionCount', { count })}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<div className="bg-surface-card text-content" style={{
|
||||
borderRadius: 14, padding: '18px 20px', width: '100%', maxWidth: 380,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', border: '1px solid var(--border-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('places.changeCategory')}</span>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')} className="text-content-muted" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex' }}>
|
||||
<X size={15} strokeWidth={2} />
|
||||
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
|
||||
{categories.map(c => (
|
||||
<button type="button" key={c.id} onClick={() => onPick(c.id)} className={ROW} style={fs(13, 'body')}>
|
||||
<CategoryTile category={c} size={28} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content">{c.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 12, marginBottom: 12 }}>{t('places.selectionCount', { count })}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{categories.map(c => {
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<button type="button" key={c.id} onClick={() => onPick(c.id)} className="text-content bg-transparent" style={rowStyle} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<CatIcon size={14} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||
<span style={{ flex: 1 }}>{c.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button type="button" onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<MapPin size={14} strokeWidth={2} color="var(--text-faint)" />
|
||||
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{categories.length > 0 && <div className="mx-2 my-1 h-px bg-edge-faint" />}
|
||||
<button type="button" onClick={() => onPick(null)} className={ROW} style={fs(13, 'body')}>
|
||||
<CategoryTile size={28} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content-muted">{t('places.noCategory')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,12 +50,35 @@ beforeEach(() => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
});
|
||||
|
||||
/** The filter is one select now: open it, then pick the option by its label. */
|
||||
async function pickFilter(user: ReturnType<typeof userEvent.setup>, label: string) {
|
||||
await user.click(within(screen.getByTestId('places-filter')).getByRole('button'));
|
||||
await user.click(await screen.findByRole('button', { name: new RegExp(`^${label}`) }));
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
/** The "show" choice sits behind its dropdown: open it, then pick the option by its label (the count follows it). */
|
||||
async function pickFilter(user: User, label: string) {
|
||||
if (!screen.queryByTestId('places-filter')) await user.click(screen.getByRole('button', { name: 'Show' }));
|
||||
await user.click(within(screen.getByTestId('places-filter')).getByRole('button', { name: new RegExp(`^${label}`) }));
|
||||
}
|
||||
|
||||
/** The categories open from their icon beside the show dropdown. */
|
||||
async function openCategories(user: User) {
|
||||
const trigger = screen.getByRole('button', { name: 'Categories' });
|
||||
if (trigger.getAttribute('aria-expanded') !== 'true') await user.click(trigger);
|
||||
}
|
||||
|
||||
/** The rating floors open from the star beside the categories. */
|
||||
async function openRating(user: User) {
|
||||
const trigger = screen.getByRole('button', { name: 'Filter by rating' });
|
||||
if (trigger.getAttribute('aria-expanded') !== 'true') await user.click(trigger);
|
||||
}
|
||||
|
||||
/** File and list import sit behind one Import button now. */
|
||||
async function openImportMenu(user: User, item: RegExp) {
|
||||
await user.click(screen.getByRole('button', { name: 'Import Places' }));
|
||||
await user.click(await screen.findByRole('button', { name: item }));
|
||||
}
|
||||
|
||||
/** A dialog's own button, apart from the sidebar's buttons of the same name. */
|
||||
const inDialog = (name: RegExp) => within(screen.getByRole('dialog')).getByRole('button', { name });
|
||||
|
||||
describe('PlacesSidebar', () => {
|
||||
it('FE-COMP-PLACES-001: renders without crashing', () => {
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
@@ -64,7 +87,7 @@ describe('PlacesSidebar', () => {
|
||||
|
||||
it('FE-COMP-PLACES-002: shows search input', () => {
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
||||
const searchInput = screen.getByPlaceholderText('Search');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -143,7 +166,7 @@ describe('PlacesSidebar', () => {
|
||||
buildPlace({ name: 'Sacre Coeur' }),
|
||||
];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
||||
const searchInput = screen.getByPlaceholderText('Search');
|
||||
await user.type(searchInput, 'Arc');
|
||||
expect(screen.getByText('Arc de Triomphe')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Sacre Coeur')).not.toBeInTheDocument();
|
||||
@@ -153,7 +176,7 @@ describe('PlacesSidebar', () => {
|
||||
const user = userEvent.setup();
|
||||
const places = [buildPlace({ name: 'Museum of Art' })];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
||||
const searchInput = screen.getByPlaceholderText('Search');
|
||||
await user.type(searchInput, 'museum');
|
||||
expect(screen.getByText('Museum of Art')).toBeInTheDocument();
|
||||
});
|
||||
@@ -190,7 +213,7 @@ describe('PlacesSidebar', () => {
|
||||
];
|
||||
const { rerender } = render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={null} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible');
|
||||
await user.type(screen.getByPlaceholderText('Search'), 'Visible');
|
||||
scrollIntoView.mockClear();
|
||||
rerender(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
|
||||
|
||||
@@ -201,8 +224,8 @@ describe('PlacesSidebar', () => {
|
||||
it('FE-COMP-PLACES-010: shows place count', () => {
|
||||
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
// i18n: places.count = "{count} places"
|
||||
expect(screen.getByText(/3 places/i)).toBeInTheDocument();
|
||||
// The count rides on the show dropdown in the head.
|
||||
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACES-011: empty list shows no place names', () => {
|
||||
@@ -220,7 +243,7 @@ describe('PlacesSidebar', () => {
|
||||
const user = userEvent.setup();
|
||||
const places = [buildPlace({ name: 'Place A' }), buildPlace({ name: 'Place B' })];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
||||
const searchInput = screen.getByPlaceholderText('Search');
|
||||
await user.type(searchInput, 'Place A');
|
||||
expect(screen.queryByText('Place B')).not.toBeInTheDocument();
|
||||
await user.clear(searchInput);
|
||||
@@ -411,7 +434,7 @@ describe('Search', () => {
|
||||
const place = buildPlace({ name: 'UK Office', address: '10 Downing Street' });
|
||||
const other = buildPlace({ name: 'Other Place', address: null });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place, other]} />);
|
||||
await user.type(screen.getByPlaceholderText(/Search places/i), 'Downing');
|
||||
await user.type(screen.getByPlaceholderText('Search'), 'Downing');
|
||||
expect(screen.getByText('UK Office')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Other Place')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -420,37 +443,40 @@ describe('Search', () => {
|
||||
const user = userEvent.setup();
|
||||
const places = [buildPlace({ name: 'Paris Hotel' }), buildPlace({ name: 'Rome Cafe' })];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
||||
const searchInput = screen.getByPlaceholderText('Search');
|
||||
await user.type(searchInput, 'Paris');
|
||||
expect(screen.queryByText('Rome Cafe')).not.toBeInTheDocument();
|
||||
// X clear button should appear
|
||||
const clearBtn = document.querySelector('button svg[data-lucide="x"]')?.closest('button')
|
||||
?? document.querySelector('input[type="text"] ~ button')
|
||||
?? screen.getByRole('button', { name: '' });
|
||||
// Find the X button by querying near the search input
|
||||
const inputWrapper = searchInput.closest('div');
|
||||
const xBtn = inputWrapper?.querySelector('button');
|
||||
expect(xBtn).toBeTruthy();
|
||||
await user.click(xBtn!);
|
||||
// The X that clears it carries its own name now.
|
||||
await user.click(screen.getByRole('button', { name: 'Clear search' }));
|
||||
expect(searchInput).toHaveValue('');
|
||||
expect(screen.getByText('Rome Cafe')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Category filter dropdown ──────────────────────────────────────────────────
|
||||
|
||||
describe('Category filter dropdown', () => {
|
||||
it('FE-PLANNER-SIDEBAR-022: category dropdown renders when categories are present', () => {
|
||||
it('FE-PLANNER-SIDEBAR-022: the category filter is offered when categories are present', async () => {
|
||||
const user = userEvent.setup();
|
||||
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
||||
render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
|
||||
expect(screen.getByText(/All Categories/i)).toBeInTheDocument();
|
||||
const { unmount } = render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
|
||||
await openCategories(user);
|
||||
expect(screen.getByRole('button', { name: 'Museum' })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
// Without categories there is nothing to pick, so there is no category filter either.
|
||||
render(<PlacesSidebar {...defaultProps} categories={[]} />);
|
||||
expect(screen.queryByRole('button', { name: 'Categories' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-023: clicking category dropdown opens options', async () => {
|
||||
it('FE-PLANNER-SIDEBAR-023: opening the category filter lists the categories', async () => {
|
||||
const user = userEvent.setup();
|
||||
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
||||
render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
|
||||
await user.click(screen.getByText(/All Categories/i));
|
||||
expect(screen.getByText('Museum')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Museum')).not.toBeInTheDocument();
|
||||
await openCategories(user);
|
||||
expect(screen.getByRole('button', { name: 'Museum' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-024: selecting a category filters places', async () => {
|
||||
@@ -460,11 +486,12 @@ describe('Category filter dropdown', () => {
|
||||
const withCat = buildPlace({ name: 'Central Park', category_id: cat.id, address: 'New York, NY' });
|
||||
const noCat = buildPlace({ name: 'Random Shop', category_id: null, address: 'London, UK' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
||||
await user.click(screen.getByText(/All Categories/i));
|
||||
// Click the category option in the dropdown (only one 'Park' now — no subtitle conflict)
|
||||
await openCategories(user);
|
||||
// Click the category option in the panel (only one 'Park' now — no subtitle conflict)
|
||||
await user.click(screen.getByText('Park'));
|
||||
expect(screen.getByText('Central Park')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Random Shop')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Park' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-025: "Clear filter" button appears when filter active and clears it', async () => {
|
||||
@@ -474,7 +501,7 @@ describe('Category filter dropdown', () => {
|
||||
const withCat = buildPlace({ name: 'Art Museum', category_id: cat.id, address: 'Paris' });
|
||||
const noCat = buildPlace({ name: 'Untagged Place', category_id: null, address: 'Berlin' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
||||
await user.click(screen.getByText(/All Categories/i));
|
||||
await openCategories(user);
|
||||
await user.click(screen.getByText('Museum'));
|
||||
expect(screen.queryByText('Untagged Place')).not.toBeInTheDocument();
|
||||
// Clear filter button should appear
|
||||
@@ -488,12 +515,12 @@ describe('Category filter dropdown', () => {
|
||||
const cat1 = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
||||
const cat2 = buildCategory({ name: 'Park', color: '#22c55e' });
|
||||
render(<PlacesSidebar {...defaultProps} categories={[cat1, cat2]} />);
|
||||
await user.click(screen.getByText(/All Categories/i));
|
||||
await openCategories(user);
|
||||
const museumOpts = screen.getAllByText('Museum');
|
||||
await user.click(museumOpts[museumOpts.length - 1]);
|
||||
const parkOpts = screen.getAllByText('Park');
|
||||
await user.click(parkOpts[parkOpts.length - 1]);
|
||||
expect(screen.getByText(/2 categories/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Categories' })).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-047: category filter survives unmount/remount (#1541)', async () => {
|
||||
@@ -502,15 +529,16 @@ describe('Category filter dropdown', () => {
|
||||
const withCat = buildPlace({ name: 'Grand Palace', category_id: cat.id, address: 'Vienna' });
|
||||
const noCat = buildPlace({ name: 'Street Market', category_id: null, address: 'Lisbon' });
|
||||
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
||||
await user.click(screen.getByText(/All Categories/i));
|
||||
await openCategories(user);
|
||||
await user.click(screen.getByText('Hotel'));
|
||||
expect(screen.queryByText('Street Market')).not.toBeInTheDocument();
|
||||
// Switching planner tabs unmounts the sidebar; the filter must come back
|
||||
// both applied and visible instead of silently sticking on the map only.
|
||||
unmount();
|
||||
render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
||||
expect(screen.queryByText(/All Categories/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Hotel')).toBeInTheDocument();
|
||||
// Visible as the badge on the category filter, with its list closed.
|
||||
expect(screen.getByRole('button', { name: 'Categories' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Categories' })).toHaveTextContent('1');
|
||||
expect(screen.getByText('Grand Palace')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Street Market')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -536,33 +564,30 @@ describe('Place list interaction', () => {
|
||||
it('FE-PLANNER-SIDEBAR-027: "+" assign button appears when selectedDayId set and place not in day', () => {
|
||||
const place = buildPlace({ name: 'Unassigned Place' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={{}} />);
|
||||
// Plus button should be visible next to the place
|
||||
const plusBtns = screen.getAllByRole('button');
|
||||
const plusBtn = plusBtns.find(b => b.querySelector('svg'));
|
||||
expect(plusBtn).toBeTruthy();
|
||||
// The place row itself should be in the DOM
|
||||
expect(screen.getByText('Unassigned Place')).toBeInTheDocument();
|
||||
// Plus button should be visible next to the place, named like the menu entry it mirrors
|
||||
const placeRow = screen.getByRole('option', { name: 'Unassigned Place' });
|
||||
expect(within(placeRow).getByRole('button', { name: '+ Day' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-028: clicking "+" assign button calls onAssignToDay with placeId', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAssignToDay = vi.fn();
|
||||
const place = buildPlace({ id: 99, name: 'Place To Assign' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={{}} onAssignToDay={onAssignToDay} />);
|
||||
// Find the + button inside the place row (small inline button)
|
||||
const placeRow = screen.getByText('Place To Assign').closest('div[draggable]')!;
|
||||
const plusBtn = placeRow.querySelector('button')!;
|
||||
await user.click(plusBtn);
|
||||
const onPlaceClick = vi.fn();
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={{}} onAssignToDay={onAssignToDay} onPlaceClick={onPlaceClick} />);
|
||||
// The + button inside the place row acts on its own: it does not select the row as well.
|
||||
const placeRow = screen.getByText('Place To Assign').closest('div[draggable]') as HTMLElement;
|
||||
await user.click(within(placeRow).getByRole('button', { name: '+ Day' }));
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(99);
|
||||
expect(onPlaceClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-029: "+" button not shown when place already assigned to selectedDay', () => {
|
||||
const place = buildPlace({ id: 55, name: 'Already Assigned' });
|
||||
const assignments = { '5': [buildAssignment({ place, day_id: 5 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={assignments} />);
|
||||
const placeRow = screen.getByText('Already Assigned').closest('div[draggable]')!;
|
||||
const plusBtn = placeRow.querySelector('button');
|
||||
expect(plusBtn).toBeNull();
|
||||
const placeRow = screen.getByText('Already Assigned').closest('div[draggable]') as HTMLElement;
|
||||
expect(within(placeRow).queryByRole('button', { name: '+ Day' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-030: place address shown as subtitle', () => {
|
||||
@@ -577,12 +602,16 @@ describe('Place list interaction', () => {
|
||||
expect(screen.queryByText(/Add Place\/Activity/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/GPX/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Google List/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Import Places' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
|
||||
// Filtering is for everyone.
|
||||
expect(screen.getByRole('button', { name: 'Show' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-032: place count shows singular form for 1 place', () => {
|
||||
const place = buildPlace({ name: 'Solo Place' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} />);
|
||||
expect(screen.getByText('1 place')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -662,7 +691,7 @@ describe('GPX import', () => {
|
||||
it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/Import file/i));
|
||||
await openImportMenu(user, /^Import file$/);
|
||||
expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -674,14 +703,14 @@ describe('GPX import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/Import file/i));
|
||||
await openImportMenu(user, /^Import file$/);
|
||||
const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /^import$/i }));
|
||||
await user.click(inDialog(/^import$/i));
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2'),
|
||||
@@ -699,16 +728,16 @@ describe('Google Maps list import', () => {
|
||||
it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
await openImportMenu(user, /^List Import$/);
|
||||
expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
await openImportMenu(user, /^List Import$/);
|
||||
await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
const importBtn = screen.getByRole('button', { name: /^Import$/i });
|
||||
const importBtn = inDialog(/^Import$/i);
|
||||
expect(importBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -724,10 +753,10 @@ describe('Google Maps list import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
await openImportMenu(user, /^List Import$/);
|
||||
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
await user.type(urlInput, 'https://maps.app.goo.gl/abc123');
|
||||
await user.click(screen.getByRole('button', { name: /^Import$/i }));
|
||||
await user.click(inDialog(/^Import$/i));
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('3'),
|
||||
@@ -753,7 +782,7 @@ describe('Google Maps list import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
await openImportMenu(user, /^List Import$/);
|
||||
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}');
|
||||
await waitFor(() => {
|
||||
@@ -832,8 +861,8 @@ describe('track colour legend (#776)', () => {
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
expect(screen.getByText('Meh Place')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText('Filter by rating'));
|
||||
await user.click(screen.getByText('4+'));
|
||||
await openRating(user);
|
||||
await user.click(screen.getByRole('button', { name: /4\+/ }));
|
||||
|
||||
expect(screen.getByText('Loved Place')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Meh Place')).not.toBeInTheDocument();
|
||||
@@ -878,3 +907,210 @@ describe('picking a place up with a finger (#1616)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── The decluttered head: one filter panel, chips for what is in force ─────────
|
||||
|
||||
describe('filter panel and chips', () => {
|
||||
it('FE-PLANNER-SIDEBAR-056: a filter list closes on Escape and on a click outside it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} places={[buildPlace({ name: 'Somewhere' })]} />);
|
||||
const trigger = screen.getByRole('button', { name: 'Show' });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await user.click(trigger);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.keyboard('{Escape}');
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(trigger).toHaveFocus();
|
||||
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByPlaceholderText('Search'));
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-057: each filter says what it narrows to and lifts again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
||||
const places = [
|
||||
buildPlace({ id: 1, name: 'Rated Museum', category_id: cat.id, rating_avg: 4.5, rating_count: 2, address: 'A' }),
|
||||
buildPlace({ id: 2, name: 'Plain Spot', category_id: null, address: 'B' }),
|
||||
];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} categories={[cat]} />);
|
||||
const categoriesBtn = screen.getByRole('button', { name: 'Categories' });
|
||||
const ratingBtn = screen.getByRole('button', { name: 'Filter by rating' });
|
||||
expect(categoriesBtn).not.toHaveTextContent(/\d/);
|
||||
|
||||
await pickFilter(user, 'Unplanned');
|
||||
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('Unplanned');
|
||||
await openCategories(user);
|
||||
await user.click(screen.getByRole('button', { name: 'Museum' }));
|
||||
await user.keyboard('{Escape}');
|
||||
await openRating(user);
|
||||
await user.click(screen.getByRole('button', { name: /4\+/ }));
|
||||
expect(categoriesBtn).toHaveTextContent('1');
|
||||
expect(ratingBtn).toHaveTextContent('4+');
|
||||
|
||||
expect(screen.getByText('Rated Museum')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Plain Spot')).not.toBeInTheDocument();
|
||||
|
||||
await pickFilter(user, 'All');
|
||||
await openCategories(user);
|
||||
await user.click(screen.getByRole('button', { name: 'Clear filter' }));
|
||||
await user.keyboard('{Escape}');
|
||||
await openRating(user);
|
||||
await user.click(screen.getByRole('button', { name: /^All$/ }));
|
||||
|
||||
expect(screen.getByText('Plain Spot')).toBeInTheDocument();
|
||||
expect(useTripStore.getState().placesFilter).toBe('all');
|
||||
expect(useTripStore.getState().placesCategoryFilter.size).toBe(0);
|
||||
expect(categoriesBtn).not.toHaveTextContent(/\d/);
|
||||
expect(ratingBtn).not.toHaveTextContent('+');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-058: the counts beside each choice ignore the rating floor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const planned = buildPlace({ id: 1, name: 'Planned One', rating_avg: 1, rating_count: 1 });
|
||||
const loose = buildPlace({ id: 2, name: 'Loose One' });
|
||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[planned, loose]} assignments={assignments} />);
|
||||
await openRating(user);
|
||||
await user.click(screen.getByRole('button', { name: /5\+/ }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show' }));
|
||||
const show = screen.getByTestId('places-filter');
|
||||
expect(within(show).getByRole('button', { name: /^All/ })).toHaveTextContent('2');
|
||||
expect(within(show).getByRole('button', { name: /^Unplanned/ })).toHaveTextContent('1');
|
||||
expect(within(show).getByRole('button', { name: /^Planned/ })).toHaveTextContent('1');
|
||||
// Tracks is only offered once a place carries one.
|
||||
expect(within(show).queryByRole('button', { name: /^Tracks/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-059: the day chip has no X when there is no way to close the day from here', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] };
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[today]} assignments={assignments} days={[{ id: 1 }] as never} selectedDayId={1} />);
|
||||
expect(screen.getByText('Showing the open day only')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Show the whole trip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rows: the "…" mirrors the right-click ───────────────────────────────────────
|
||||
|
||||
describe('row actions', () => {
|
||||
it('FE-PLANNER-SIDEBAR-060: the "…" offers the right-click entries and acts without selecting the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditPlace = vi.fn();
|
||||
const onPlaceClick = vi.fn();
|
||||
const place = buildPlace({ id: 5, name: 'Menu Place' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={3} onEditPlace={onEditPlace} onPlaceClick={onPlaceClick} />);
|
||||
const row = screen.getByRole('option', { name: 'Menu Place' });
|
||||
|
||||
await user.click(within(row).getByRole('button', { name: 'More options' }));
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
// The row's own "+" and the menu's "+ Day".
|
||||
expect(screen.getAllByRole('button', { name: '+ Day' })).toHaveLength(2);
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(onEditPlace).toHaveBeenCalledWith(expect.objectContaining({ id: 5 }));
|
||||
expect(onPlaceClick).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-061: the right-click menu stays, with the same entries', async () => {
|
||||
const onDeletePlace = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const place = buildPlace({ id: 6, name: 'Right Click Place' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} onDeletePlace={onDeletePlace} />);
|
||||
fireEvent.contextMenu(screen.getByRole('option', { name: 'Right Click Place' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(onDeletePlace).toHaveBeenCalledWith(6);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-062: no "…" while selecting, nor below lg where a tap opens the day sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
const place = buildPlace({ id: 7, name: 'Quiet Place' });
|
||||
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[place]} />);
|
||||
const row = () => screen.getByRole('option', { name: 'Quiet Place' });
|
||||
expect(within(row()).getByRole('button', { name: 'More options' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Select' }));
|
||||
expect(within(row()).queryByRole('button', { name: 'More options' })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} isMobile />);
|
||||
expect(within(row()).queryByRole('button', { name: 'More options' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-063: Enter selects a focused row and Space ticks it in select mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlaceClick = vi.fn();
|
||||
const place = buildPlace({ id: 8, name: 'Keyboard Place' });
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} onPlaceClick={onPlaceClick} />);
|
||||
const row = screen.getByRole('option', { name: 'Keyboard Place' });
|
||||
act(() => { row.focus(); });
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onPlaceClick).toHaveBeenCalledWith(8);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select' }));
|
||||
act(() => { row.focus(); });
|
||||
await user.keyboard(' ');
|
||||
expect(screen.getByRole('status', { name: '1 selected' })).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Select mode and the import menu ─────────────────────────────────────────────
|
||||
|
||||
describe('select mode and import menu', () => {
|
||||
it('FE-PLANNER-SIDEBAR-064: select mode raises the selection bar at the foot, and takes it away again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const places = [buildPlace({ name: 'One' }), buildPlace({ name: 'Two' })];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Select' });
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('status', { name: '0 selected' })).toHaveTextContent('0');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select all' }));
|
||||
expect(screen.getByRole('status', { name: '2 selected' })).toHaveTextContent('2');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Done' }));
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-065: the Import button opens its menu and a second click closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
const trigger = screen.getByRole('button', { name: 'Import Places' });
|
||||
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
|
||||
|
||||
await user.click(trigger);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Import file' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'List Import' })).toBeInTheDocument();
|
||||
|
||||
await user.click(trigger);
|
||||
expect(screen.queryByRole('button', { name: 'Import file' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-066: the list import closes on Cancel and keeps its enrich switch behind the maps key', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { hasMapsKey: true });
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await openImportMenu(user, /^List Import$/);
|
||||
const enrich = screen.getByRole('button', { name: 'Enrich places via Google' });
|
||||
expect(enrich).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(enrich);
|
||||
expect(enrich).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
// Switching the provider swaps the example link.
|
||||
await user.click(screen.getByRole('button', { name: 'Naver List' }));
|
||||
expect(screen.getByPlaceholderText(/naver\.me/)).toBeInTheDocument();
|
||||
|
||||
await user.click(inDialog(/^Cancel$/));
|
||||
expect(screen.queryByPlaceholderText(/naver\.me/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user