mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user