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.
This commit is contained in:
Maurice
2026-10-09 15:56:40 +02:00
parent 3de3ffcd1c
commit a843b66c33
4 changed files with 210 additions and 60 deletions
@@ -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<string, unknown>) => {
linkAndFiles(props);
return <div data-testid="link-and-files" />;
},
}));
vi.mock('./BookingCostsSection', () => ({
BookingCostsSection: (props: Record<string, unknown>) => {
costs(props);
return <div data-testid="costs" />;
},
}));
type Props = ComponentProps<typeof BookingNotesFilesCosts>;
function setup(overrides: Partial<Props> = {}) {
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<HTMLInputElement>(),
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(<BookingNotesFilesCosts {...props} />);
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 }));
});
});
@@ -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<HTMLInputElement | null>;
attach: ReturnType<typeof useBookingFileAttach>;
canAttach: boolean;
showCosts: boolean;
pendingExpense: { total_price: number; currency?: string | null; category: string } | null;
expense: ReturnType<typeof useBookingExpenseIntent>;
}) {
const { t } = useTranslation();
return (
<>
<EditorField label={t('reservations.notes')}>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
rows={2}
placeholder={t('reservations.notesPlaceholder')}
className={TEXTAREA}
/>
</EditorField>
<BookingLinkAndFiles
url={url}
onUrlChange={onUrlChange}
labelClass={LABEL}
inputClass={INPUT}
reservationId={reservationId}
tripFiles={tripFiles}
attachedFiles={attach.attachedFiles}
pendingFiles={pendingFiles}
onRemovePending={attach.removePending}
fileInputRef={fileInputRef}
onFileChange={attach.handleFileChange}
canAttach={canAttach}
uploading={attach.uploadingFile}
onLinked={attach.linkFile}
onDetached={attach.detachFile}
/>
{/* Costs: create or view the expenses linked to this booking */}
{showCosts && (
<BookingCostsSection
reservationId={reservationId ?? null}
pendingExpense={pendingExpense}
onCreate={expense.create}
onEdit={expense.edit}
onRemove={expense.remove}
labelClassName={LABEL}
customTooltips
/>
)}
</>
);
}
@@ -13,8 +13,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { BookingNotesFilesCosts } from './BookingNotesFilesCosts'
import { importedPriceEntry } from './importedPrice'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
@@ -23,7 +22,7 @@ import type { BookingReviewDraft } from './parsedItemToDraft'
import { stayPlaces } from '../../utils/stayPlaces'
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
import { DialogShell, DialogFooter, FooterSpacer, DialogButton } from '../shared/DialogShell'
import { INPUT, TEXTAREA, LABEL, GRID_2, GRID_3, EditorField, PillSelect } from '../shared/dialogParts'
import { INPUT, GRID_2, GRID_3, EditorField, PillSelect } from '../shared/dialogParts'
import { typeInfo } from './bookings/bookingsModel'
import type { StatusTone } from './bookings/bookingParts'
import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill } from './reservationFormModel'
@@ -376,41 +375,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</EditorField>
</div>
<EditorField label={t('reservations.notes')}>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
placeholder={t('reservations.notesPlaceholder')} className={TEXTAREA} />
</EditorField>
<BookingLinkAndFiles
<BookingNotesFilesCosts
notes={form.notes}
onNotesChange={value => set('notes', value)}
url={form.url}
onUrlChange={value => set('url', value)}
labelClass={LABEL}
inputClass={INPUT}
reservationId={reservation?.id}
tripFiles={files}
attachedFiles={attach.attachedFiles}
pendingFiles={pendingFiles}
onRemovePending={attach.removePending}
fileInputRef={fileInputRef}
onFileChange={attach.handleFileChange}
attach={attach}
canAttach={!!onFileUpload}
uploading={attach.uploadingFile}
onLinked={attach.linkFile}
onDetached={attach.detachFile}
showCosts={isBudgetEnabled}
pendingExpense={pendingExpense}
expense={expense}
/>
{/* Costs — create / view the expense linked to this booking */}
{isBudgetEnabled && (
<BookingCostsSection
reservationId={reservation?.id ?? null}
pendingExpense={pendingExpense}
onCreate={expense.create}
onEdit={expense.edit}
onRemove={expense.remove}
labelClassName={LABEL}
customTooltips
/>
)}
</DialogShell>
)
}
@@ -14,8 +14,7 @@ import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import type { Day, Place, Accommodation, Reservation, TripFile, AssignmentsMap } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { BookingNotesFilesCosts } from './BookingNotesFilesCosts'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -23,7 +22,7 @@ import type { BookingReviewDraft } from './parsedItemToDraft'
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
import { INPUT, TEXTAREA, READONLY_BOX, LABEL, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
import { fs, Eyebrow, type StatusTone } from './bookings/bookingParts'
import { typeInfo } from './bookings/bookingsModel'
import {
@@ -540,41 +539,21 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
placeholder={t('reservations.confirmationPlaceholder')} className={INPUT} />
</EditorField>
<EditorField label={t('reservations.notes')}>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
placeholder={t('reservations.notesPlaceholder')} className={TEXTAREA} />
</EditorField>
<BookingLinkAndFiles
<BookingNotesFilesCosts
notes={form.notes}
onNotesChange={value => set('notes', value)}
url={form.url}
onUrlChange={value => set('url', value)}
labelClass={LABEL}
inputClass={INPUT}
reservationId={reservation?.id}
tripFiles={files}
attachedFiles={attach.attachedFiles}
pendingFiles={pendingFiles}
onRemovePending={attach.removePending}
fileInputRef={fileInputRef}
onFileChange={attach.handleFileChange}
attach={attach}
canAttach={!!onFileUpload}
uploading={attach.uploadingFile}
onLinked={attach.linkFile}
onDetached={attach.detachFile}
showCosts={isBudgetEnabled}
pendingExpense={pendingExpense}
expense={expense}
/>
{/* Costs: create or view the expenses linked to this booking */}
{isBudgetEnabled && (
<BookingCostsSection
reservationId={reservation?.id ?? null}
pendingExpense={pendingExpense}
onCreate={expense.create}
onEdit={expense.edit}
onRemove={expense.remove}
labelClassName={LABEL}
customTooltips
/>
)}
</>
)}