Files
TREK/client/src/components/Planner/BookingCostsSection.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

291 lines
16 KiB
TypeScript

// 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';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { formatMoney } from '../../utils/formatters';
import { BookingCostsSection } from './BookingCostsSection';
import type { BudgetItem } from '../../types';
// getByText collapses the no-break space Intl puts between amount and symbol,
// so the expected amount is collapsed the same way.
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ');
const flight = buildBudgetItem({ id: 11, trip_id: 1, name: 'Flight LH 400', total_price: 420, currency: 'EUR', category: 'flights', reservation_id: 9 });
const upgrade = buildBudgetItem({ id: 12, trip_id: 1, name: 'Seat upgrade', total_price: 80, currency: 'USD', category: 'fees', reservation_id: 9 });
const elsewhere = buildBudgetItem({ id: 13, trip_id: 1, name: 'Train ride', total_price: 30, reservation_id: 10 });
const museum = buildBudgetItem({ id: 14, trip_id: 1, name: 'Museum pass', total_price: 25, category: 'activities', place_id: 4 });
const souvenirs = buildBudgetItem({ id: 15, trip_id: 1, name: 'Souvenirs', total_price: 18, currency: 'EUR', category: 'shopping' });
const snacks = buildBudgetItem({ id: 16, trip_id: 1, name: 'Snacks', total_price: 7, currency: 'EUR', category: 'food' });
function renderSection(props: Partial<React.ComponentProps<typeof BookingCostsSection>> = {}) {
const handlers = { onCreate: vi.fn(), onEdit: vi.fn(), onRemove: vi.fn() };
render(<BookingCostsSection reservationId={9} {...handlers} {...props} />);
return handlers;
}
/** A linked expense's row: its name sits two levels below the row itself. */
const rowOf = (name: string) => screen.getByText(name).parentElement!.parentElement as HTMLElement;
/** The trigger of the "link an expense" dropdown, found by what it currently says. */
const linkSelect = (label: RegExp = /Link existing expense/) => screen.getByRole('button', { name: label });
/** Records every expense update and answers with the merged item. */
function captureUpdates() {
const bodies: { id: number; body: Record<string, unknown> }[] = [];
server.use(
http.put('/api/trips/1/budget/:itemId', async ({ params, request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push({ id: Number(params.itemId), body });
const current = useTripStore.getState().budgetItems.find(i => i.id === Number(params.itemId));
return HttpResponse.json({ item: { ...current, ...body } });
}),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
);
return bodies;
}
beforeEach(() => {
resetAllStores();
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }), budgetItems: [flight, upgrade, elsewhere, museum, souvenirs, snacks] });
});
describe('BookingCostsSection', () => {
// ── Nothing linked yet ──────────────────────────────────────────────────────
it('FE-PLANNER-COSTSEC-001: with nothing linked it offers create and link side by side, with the hint below', () => {
seedStore(useTripStore, { budgetItems: [souvenirs] });
renderSection();
expect(screen.getByText('Costs')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument();
expect(linkSelect()).toBeEnabled();
expect(screen.getByText('Saves the booking, then opens the Costs editor.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Unlink, keep the expense' })).not.toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-002: before the first save there is nothing to link to, so the select is missing', () => {
const { onCreate } = renderSection({ reservationId: null });
expect(screen.queryByRole('button', { name: /Link existing expense|No unlinked expenses/ })).not.toBeInTheDocument();
// Only the create button is left in the row.
const create = screen.getByRole('button', { name: /Create expense/ });
expect(create.parentElement!.style.gridTemplateColumns).toBe('1fr');
create.click();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(screen.getByText('Saves the booking, then opens the Costs editor.')).toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-003: once the record exists the create button shares its row with the select', () => {
renderSection();
expect(screen.getByRole('button', { name: /Create expense/ }).parentElement!.style.gridTemplateColumns).toBe('1fr 1fr');
});
it('FE-PLANNER-COSTSEC-004: the select offers only expenses without any link, with their amount', async () => {
const user = userEvent.setup();
renderSection();
await user.click(linkSelect());
const souvenirOption = screen.getByRole('button', { name: /Souvenirs/ });
expect(souvenirOption).toHaveTextContent(money(18, 'EUR'));
expect(screen.getByRole('button', { name: /Snacks/ })).toBeInTheDocument();
// Linked to another booking or to a place: not offered, so linking never steals it.
expect(screen.queryByRole('button', { name: /Train ride/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Museum pass/ })).not.toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-005: picking an expense in the select links it to the booking', async () => {
const user = userEvent.setup();
const bodies = captureUpdates();
renderSection();
await user.click(linkSelect());
await user.click(screen.getByRole('button', { name: /Souvenirs/ }));
await waitFor(() => expect(bodies).toEqual([{ id: 15, body: { reservation_id: 9 } }]));
// It now sits in the linked list with its own row actions.
expect(await screen.findByText('Souvenirs')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Unlink, keep the expense' })).toHaveLength(3);
});
it('FE-PLANNER-COSTSEC-006: the select can be searched by name', async () => {
const user = userEvent.setup();
renderSection();
await user.click(linkSelect());
await user.type(screen.getByPlaceholderText('...'), 'snack');
expect(screen.getByRole('button', { name: /Snacks/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Souvenirs/ })).not.toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-007: with no unlinked expense left the select says so and stays shut', () => {
seedStore(useTripStore, { budgetItems: [flight, elsewhere, museum] });
renderSection();
expect(linkSelect(/No unlinked expenses/)).toBeDisabled();
});
// ── Linked expenses ────────────────────────────────────────────────────────
it('FE-PLANNER-COSTSEC-008: every linked expense is listed with its category and amount, and the hint goes', () => {
renderSection();
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
expect(screen.getByText('Flight LH 400')).toBeInTheDocument();
expect(screen.getByText('Seat upgrade')).toBeInTheDocument();
expect(screen.getByText('Flights')).toBeInTheDocument();
expect(screen.getByText('Fees & tickets')).toBeInTheDocument();
expect(screen.getByText(money(420, 'EUR'))).toBeInTheDocument();
// Each amount stays in the expense's own currency.
expect(screen.getByText(money(80, 'USD'))).toBeInTheDocument();
expect(screen.queryByText('Train ride')).not.toBeInTheDocument();
expect(screen.queryByText('Saves the booking, then opens the Costs editor.')).not.toBeInTheDocument();
// A further expense can still be created next to them.
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-009: edit hands the row\'s expense to onEdit', async () => {
const user = userEvent.setup();
const { onEdit } = renderSection();
const row = rowOf('Seat upgrade');
await user.click(within(row).getByRole('button', { name: 'Edit' }));
expect(onEdit).toHaveBeenCalledWith(upgrade);
});
it('FE-PLANNER-COSTSEC-010: delete hands the row\'s expense to onRemove', async () => {
const user = userEvent.setup();
const { onRemove } = renderSection();
const row = rowOf('Flight LH 400');
await user.click(within(row).getByRole('button', { name: 'Remove expense' }));
expect(onRemove).toHaveBeenCalledWith(flight);
});
it('FE-PLANNER-COSTSEC-011: unlink lets go of the link and keeps the expense in Costs', async () => {
const user = userEvent.setup();
const bodies = captureUpdates();
renderSection();
const row = rowOf('Seat upgrade');
await user.click(within(row).getByRole('button', { name: 'Unlink, keep the expense' }));
await waitFor(() => expect(bodies).toEqual([{ id: 12, body: { reservation_id: null } }]));
await waitFor(() => expect(screen.queryByText('Seat upgrade')).not.toBeInTheDocument());
expect(useTripStore.getState().budgetItems.some((i: BudgetItem) => i.id === 12)).toBe(true);
});
it('FE-PLANNER-COSTSEC-012: create stays available with expenses linked', async () => {
const user = userEvent.setup();
const { onCreate } = renderSection();
await user.click(screen.getByRole('button', { name: /Create expense/ }));
expect(onCreate).toHaveBeenCalledTimes(1);
});
// ── The place form ─────────────────────────────────────────────────────────
it('FE-PLANNER-COSTSEC-013: in the place form it lists and links through place_id, with the place hint', async () => {
const user = userEvent.setup();
const bodies = captureUpdates();
renderSection({ reservationId: null, placeId: 4, hintKey: 'places.createExpenseHint' });
expect(screen.getByText('Museum pass')).toBeInTheDocument();
expect(screen.queryByText('Flight LH 400')).not.toBeInTheDocument();
await user.click(linkSelect());
await user.click(screen.getByRole('button', { name: /Snacks/ }));
await waitFor(() => expect(bodies).toEqual([{ id: 16, body: { place_id: 4 } }]));
});
it('FE-PLANNER-COSTSEC-014: the place hint shows while the place has no expense', () => {
seedStore(useTripStore, { budgetItems: [souvenirs] });
renderSection({ reservationId: null, placeId: 4, hintKey: 'places.createExpenseHint' });
expect(screen.getByText('Saves the place, then opens the Costs editor.')).toBeInTheDocument();
});
// ── Import review ──────────────────────────────────────────────────────────
it('FE-PLANNER-COSTSEC-015: an import preview shows the parsed cost instead of the buttons', () => {
renderSection({ reservationId: null, pendingExpense: { total_price: 99, currency: 'CHF', category: 'transport' } });
expect(screen.getByText('Linked expense')).toBeInTheDocument();
expect(screen.getByText('Transport')).toBeInTheDocument();
expect(screen.getByText(money(99, 'CHF'))).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Create expense/ })).not.toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-016: an amount without a currency is in the trip currency, whatever the display currency (#2525)', () => {
// The booking saves its cost without a currency, which the server and Costs
// read as the trip's. Previewing it in the display currency said 12 GBP for
// what was then stored as 12 JPY.
seedStore(useTripStore, { budgetItems: [], trip: buildTrip({ id: 1, currency: 'JPY' }) });
seedStore(useSettingsStore, { settings: { default_currency: 'gbp' } });
const { unmount } = render(
<BookingCostsSection reservationId={null} pendingExpense={{ total_price: 12, category: 'food' }} onCreate={vi.fn()} onEdit={vi.fn()} onRemove={vi.fn()} />,
);
expect(screen.getByText(money(12, 'JPY'))).toBeInTheDocument();
expect(screen.queryByText(money(12, 'GBP'))).not.toBeInTheDocument();
unmount();
seedStore(useSettingsStore, { settings: { default_currency: '' } });
render(
<BookingCostsSection reservationId={null} pendingExpense={{ total_price: 12, category: 'food' }} onCreate={vi.fn()} onEdit={vi.fn()} onRemove={vi.fn()} />,
);
expect(screen.getByText(money(12, 'JPY'))).toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-017: a zero-priced import is not previewed', () => {
seedStore(useTripStore, { budgetItems: [] });
renderSection({ reservationId: null, pendingExpense: { total_price: 0, category: 'food' } });
expect(screen.queryByText('Linked expense')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-018: a booking that already has an expense lists it instead of the import preview', () => {
renderSection({ pendingExpense: { total_price: 99, category: 'transport' } });
expect(screen.queryByText('Linked expense')).not.toBeInTheDocument();
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
expect(screen.getByText('Flight LH 400')).toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-019: linked and offered expenses without a currency read in the trip currency (#2525)', async () => {
const user = userEvent.setup();
const deposit = buildBudgetItem({ id: 17, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9 });
const tram = buildBudgetItem({ id: 18, trip_id: 1, name: 'Tram pass', total_price: 9, currency: null, category: 'transport' });
seedStore(useTripStore, { budgetItems: [deposit, tram] });
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
renderSection();
expect(within(rowOf('Hotel deposit')).getByText(money(120, 'EUR'))).toBeInTheDocument();
expect(screen.queryByText(money(120, 'USD'))).not.toBeInTheDocument();
await user.click(linkSelect());
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');
}
});
});