From a843b66c33f595770b4bb78b4441775a7af4c324 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 15:56:40 +0200 Subject: [PATCH] refactor(bookings): draw the notes, files and costs of the booking dialogs with one component The desktop booking and transport dialogs ended on the same notes field, link and files row and linked costs block. Both now render BookingNotesFilesCosts next to them, with the same markup as before. --- .../Planner/BookingNotesFilesCosts.test.tsx | 102 ++++++++++++++++++ .../Planner/BookingNotesFilesCosts.tsx | 90 ++++++++++++++++ .../components/Planner/ReservationModal.tsx | 39 ++----- .../src/components/Planner/TransportModal.tsx | 39 ++----- 4 files changed, 210 insertions(+), 60 deletions(-) create mode 100644 client/src/components/Planner/BookingNotesFilesCosts.test.tsx create mode 100644 client/src/components/Planner/BookingNotesFilesCosts.tsx diff --git a/client/src/components/Planner/BookingNotesFilesCosts.test.tsx b/client/src/components/Planner/BookingNotesFilesCosts.test.tsx new file mode 100644 index 000000000..2e17b2c39 --- /dev/null +++ b/client/src/components/Planner/BookingNotesFilesCosts.test.tsx @@ -0,0 +1,102 @@ +// FE-PLANNER-NOTESFILESCOSTS-001 to FE-PLANNER-NOTESFILESCOSTS-003 +import { createRef, type ComponentProps } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import { BookingNotesFilesCosts } from './BookingNotesFilesCosts'; + +const linkAndFiles = vi.fn(); +const costs = vi.fn(); + +vi.mock('./BookingLinkAndFiles', () => ({ + BookingLinkAndFiles: (props: Record) => { + linkAndFiles(props); + return
; + }, +})); + +vi.mock('./BookingCostsSection', () => ({ + BookingCostsSection: (props: Record) => { + costs(props); + return
; + }, +})); + +type Props = ComponentProps; + +function setup(overrides: Partial = {}) { + linkAndFiles.mockClear(); + costs.mockClear(); + const props: Props = { + notes: 'Gate B', + onNotesChange: vi.fn(), + url: 'https://example.com', + onUrlChange: vi.fn(), + reservationId: 9, + tripFiles: [], + pendingFiles: [], + fileInputRef: createRef(), + attach: { + uploadingFile: false, + handleFileChange: vi.fn(), + attachedFiles: [], + removePending: vi.fn(), + linkFile: vi.fn(), + detachFile: vi.fn(), + }, + canAttach: true, + showCosts: true, + pendingExpense: null, + expense: { reset: vi.fn(), take: vi.fn(), create: vi.fn(), edit: vi.fn(), remove: vi.fn() }, + ...overrides, + }; + render(); + return props; +} + +describe('BookingNotesFilesCosts', () => { + it('FE-PLANNER-NOTESFILESCOSTS-001: shows the notes and reports an edit', () => { + const props = setup(); + + const area = screen.getByPlaceholderText('Additional notes...'); + expect(area).toHaveValue('Gate B'); + fireEvent.change(area, { target: { value: 'Gate C' } }); + expect(props.onNotesChange).toHaveBeenCalledWith('Gate C'); + }); + + it('FE-PLANNER-NOTESFILESCOSTS-002: hands the link, the files and the costs their parts of the booking', () => { + const props = setup(); + + expect(linkAndFiles).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com', + onUrlChange: props.onUrlChange, + reservationId: 9, + canAttach: true, + uploading: false, + onFileChange: props.attach.handleFileChange, + onRemovePending: props.attach.removePending, + onLinked: props.attach.linkFile, + onDetached: props.attach.detachFile, + }) + ); + expect(costs).toHaveBeenCalledWith( + expect.objectContaining({ + reservationId: 9, + pendingExpense: null, + onCreate: props.expense.create, + onEdit: props.expense.edit, + onRemove: props.expense.remove, + customTooltips: true, + }) + ); + }); + + it('FE-PLANNER-NOTESFILESCOSTS-003: leaves the costs out without the budget, and passes no id for a new booking', () => { + setup({ showCosts: false, reservationId: undefined }); + + expect(screen.getByTestId('link-and-files')).toBeInTheDocument(); + expect(screen.queryByTestId('costs')).toBeNull(); + expect(linkAndFiles).toHaveBeenCalledWith(expect.objectContaining({ reservationId: undefined })); + }); +}); diff --git a/client/src/components/Planner/BookingNotesFilesCosts.tsx b/client/src/components/Planner/BookingNotesFilesCosts.tsx new file mode 100644 index 000000000..4ce102205 --- /dev/null +++ b/client/src/components/Planner/BookingNotesFilesCosts.tsx @@ -0,0 +1,90 @@ +import type { RefObject } from 'react'; + +import { useTranslation } from '../../i18n'; +import type { TripFile } from '../../types'; +import { EditorField, INPUT, LABEL, TEXTAREA } from '../shared/dialogParts'; +import { BookingCostsSection } from './BookingCostsSection'; +import { BookingLinkAndFiles } from './BookingLinkAndFiles'; +import type { useBookingExpenseIntent } from './useBookingExpenseIntent'; +import type { useBookingFileAttach } from './useBookingFileAttach'; + +/** + * The lower part the booking and transport dialogs share: notes, the link and + * files row, and the costs linked to this booking when the budget addon is on. + */ +export function BookingNotesFilesCosts({ + notes, + onNotesChange, + url, + onUrlChange, + reservationId, + tripFiles, + pendingFiles, + fileInputRef, + attach, + canAttach, + showCosts, + pendingExpense, + expense, +}: { + notes: string; + onNotesChange: (notes: string) => void; + url: string; + onUrlChange: (url: string) => void; + /** The saved booking; unset while it is being created. */ + reservationId: number | undefined; + tripFiles: TripFile[]; + pendingFiles: File[]; + fileInputRef: RefObject; + attach: ReturnType; + canAttach: boolean; + showCosts: boolean; + pendingExpense: { total_price: number; currency?: string | null; category: string } | null; + expense: ReturnType; +}) { + const { t } = useTranslation(); + return ( + <> + +