Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions __tests__/components/fleet/VehicleRegistration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VehicleRegistration } from '@/components/fleet/VehicleRegistration';
import { useVehicleRegistration } from '@/hooks/useVehicleRegistration';
import type { VehicleRegistrationInput } from '@/types/fleet';

jest.mock('@/hooks/useVehicleRegistration');

const mockedUseVehicleRegistration = useVehicleRegistration as jest.MockedFunction<
typeof useVehicleRegistration
>;

const emptyValues: VehicleRegistrationInput = {
plateNumber: '',
make: '',
model: '',
year: '',
type: '',
capacityKg: '',
temperatureMin: '',
cargoBoxSize: '',
registrationDocument: null,
vehiclePhoto: null,
};

function mockHook(overrides: Partial<ReturnType<typeof useVehicleRegistration>> = {}) {
mockedUseVehicleRegistration.mockReturnValue({
values: emptyValues,
errors: {},
isValid: false,
isSubmitting: false,
isSubmitted: false,
photoPreview: null,
updateField: jest.fn(),
setVehiclePhoto: jest.fn(),
submit: jest.fn().mockResolvedValue(true),
reset: jest.fn(),
...overrides,
});
}

describe('VehicleRegistration', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHook();
});

it('renders every required vehicle field', () => {
render(<VehicleRegistration />);

expect(screen.getByLabelText('Plate number')).toBeRequired();
expect(screen.getByLabelText('Vehicle type')).toBeRequired();
expect(screen.getByLabelText('Make')).toBeRequired();
expect(screen.getByLabelText('Model')).toBeRequired();
expect(screen.getByLabelText('Year')).toBeRequired();
expect(screen.getByLabelText('Capacity (kg)')).toBeRequired();
expect(screen.getByLabelText('Registration document')).toBeRequired();
expect(screen.getByLabelText('Vehicle photo')).toBeRequired();
});

it('passes text input changes to the hook', async () => {
const updateField = jest.fn();
mockHook({ updateField });
const user = userEvent.setup();
render(<VehicleRegistration />);

await user.type(screen.getByLabelText('Plate number'), 'ABC-123');

expect(updateField).toHaveBeenCalledWith('plateNumber', 'A');
});

it('routes every standard field and document change through the hook', () => {
const updateField = jest.fn();
mockHook({ updateField });
render(<VehicleRegistration />);
const document = new File(['registration'], 'registration.pdf', {
type: 'application/pdf',
});

fireEvent.change(screen.getByLabelText('Vehicle type'), { target: { value: 'truck' } });
fireEvent.change(screen.getByLabelText('Make'), { target: { value: 'Ford' } });
fireEvent.change(screen.getByLabelText('Model'), { target: { value: 'Transit' } });
fireEvent.change(screen.getByLabelText('Year'), { target: { value: '2025' } });
fireEvent.change(screen.getByLabelText('Capacity (kg)'), { target: { value: '1200' } });
fireEvent.change(screen.getByLabelText('Registration document'), {
target: { files: [document] },
});

expect(updateField).toHaveBeenCalledWith('type', 'truck');
expect(updateField).toHaveBeenCalledWith('make', 'Ford');
expect(updateField).toHaveBeenCalledWith('model', 'Transit');
expect(updateField).toHaveBeenCalledWith('year', '2025');
expect(updateField).toHaveBeenCalledWith('capacityKg', '1200');
expect(updateField).toHaveBeenCalledWith('registrationDocument', document);
});

it('shows refrigerated fields only for refrigerated vehicles', () => {
const updateField = jest.fn();
mockHook({ values: { ...emptyValues, type: 'refrigerated' }, updateField });
render(<VehicleRegistration />);

expect(screen.getByLabelText('Minimum temperature (C)')).toBeInTheDocument();
expect(screen.queryByLabelText('Cargo box size')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Minimum temperature (C)'), {
target: { value: '-10' },
});
expect(updateField).toHaveBeenCalledWith('temperatureMin', '-10');
});

it('shows cargo box fields only for motorcycles', () => {
const updateField = jest.fn();
mockHook({ values: { ...emptyValues, type: 'motorcycle' }, updateField });
render(<VehicleRegistration />);

expect(screen.getByLabelText('Cargo box size')).toBeInTheDocument();
expect(screen.queryByLabelText('Minimum temperature (C)')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Cargo box size'), {
target: { value: '60 L' },
});
expect(updateField).toHaveBeenCalledWith('cargoBoxSize', '60 L');
});

it('hides dynamic fields for a standard van', () => {
mockHook({ values: { ...emptyValues, type: 'van' } });
render(<VehicleRegistration />);

expect(screen.queryByLabelText('Cargo box size')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Minimum temperature (C)')).not.toBeInTheDocument();
});

it('renders validation messages returned by the hook', () => {
mockHook({
errors: {
plateNumber: 'Plate number is required',
make: 'Make is required',
model: 'Model is required',
year: 'Year is required',
type: 'Vehicle type is required',
capacityKg: 'Capacity is required',
registrationDocument: 'Registration document is required',
vehiclePhoto: 'Vehicle photo is required',
},
});
render(<VehicleRegistration />);

expect(screen.getByText('Plate number is required')).toHaveAttribute('role', 'alert');
expect(screen.getByText('Registration document is required')).toBeInTheDocument();
expect(screen.getByText('Vehicle photo is required')).toBeInTheDocument();
});

it('keeps submit disabled until the form is valid', () => {
render(<VehicleRegistration />);
expect(screen.getByRole('button', { name: 'Register vehicle' })).toBeDisabled();
});

it('enables submit for a valid form', () => {
mockHook({ isValid: true });
render(<VehicleRegistration />);
expect(screen.getByRole('button', { name: 'Register vehicle' })).toBeEnabled();
});

it('submits through the hook', async () => {
const submit = jest.fn().mockResolvedValue(true);
mockHook({ isValid: true, submit });
const user = userEvent.setup();
render(<VehicleRegistration />);

await user.click(screen.getByRole('button', { name: 'Register vehicle' }));

expect(submit).toHaveBeenCalledTimes(1);
});

it('sends an uploaded photo to the hook', () => {
const setVehiclePhoto = jest.fn();
mockHook({ setVehiclePhoto });
render(<VehicleRegistration />);
const photo = new File(['vehicle'], 'vehicle.png', { type: 'image/png' });

fireEvent.change(screen.getByLabelText('Vehicle photo'), {
target: { files: [photo] },
});

expect(setVehiclePhoto).toHaveBeenCalledWith(photo);
});

it('renders the vehicle photo preview', () => {
mockHook({ photoPreview: 'blob:vehicle-preview' });
render(<VehicleRegistration />);

expect(screen.getByAltText('Vehicle preview')).toHaveAttribute(
'src',
'blob:vehicle-preview',
);
});

it('clears every field through the reset action', async () => {
const reset = jest.fn();
mockHook({ reset });
const user = userEvent.setup();
render(<VehicleRegistration />);

await user.click(screen.getByRole('button', { name: 'Reset form' }));

expect(reset).toHaveBeenCalledTimes(1);
});

it('shows submission progress and success feedback', () => {
mockHook({ isValid: true, isSubmitting: true, isSubmitted: true });
render(<VehicleRegistration />);

expect(screen.getByRole('button', { name: 'Registering...' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('Vehicle registered successfully.');
});
});
115 changes: 115 additions & 0 deletions components/deliveries/SignatureCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use client';

import { useRef } from 'react';
import { Eraser, Save } from 'lucide-react';
import { useSignatureCanvas } from '@/hooks/useSignatureCanvas';

export interface SignatureCanvasProps {
onSave: (_dataUrl: string) => void;
width?: number;
height?: number;
disabled?: boolean;
className?: string;
}

/** Canvas signature capture with a keyboard-accessible typed fallback. */
export function SignatureCanvas({
onSave,
width = 720,
height = 260,
disabled = false,
className = '',
}: SignatureCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const {
beginStroke,
clear,
continueStroke,
endStroke,
hasSignature,
isTooLight,
save,
setTypedSignature,
typedSignature,
} = useSignatureCanvas({ canvasRef, onSave, disabled });

const cannotSave = disabled || !hasSignature || isTooLight;

return (
<section
className={`space-y-4 rounded-lg border border-slate-200 bg-white p-4 ${className}`.trim()}
aria-labelledby="recipient-signature-heading"
>
<div>
<h2 id="recipient-signature-heading" className="text-sm font-semibold text-slate-900">
Recipient signature
</h2>
<p className="mt-1 text-sm text-slate-600">
Sign inside the box using a mouse, stylus, or touch screen.
</p>
</div>

<canvas
ref={canvasRef}
width={width}
height={height}
aria-label="Recipient signature drawing area"
className="block aspect-[18/7] w-full touch-none rounded border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-600 disabled:cursor-not-allowed"
onPointerDown={beginStroke}
onPointerMove={continueStroke}
onPointerUp={endStroke}
onPointerCancel={endStroke}
onPointerLeave={endStroke}
role="img"
/>

{hasSignature && isTooLight && (
<p role="alert" className="text-sm font-medium text-amber-700">
Signature is too light. Add a longer stroke before saving.
</p>
)}

<div>
<label htmlFor="typed-signature" className="text-sm font-medium text-slate-800">
Keyboard signature fallback
</label>
<p id="typed-signature-help" className="mt-1 text-xs text-slate-500">
Type the recipient&apos;s name when drawing is not accessible.
</p>
<textarea
id="typed-signature"
value={typedSignature}
onChange={(event) => setTypedSignature(event.target.value)}
disabled={disabled}
aria-describedby="typed-signature-help"
rows={2}
className="mt-2 w-full resize-none rounded border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-100 disabled:bg-slate-100"
placeholder="Type recipient name"
/>
</div>

<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={clear}
disabled={disabled || !hasSignature}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Eraser aria-hidden="true" className="h-4 w-4" />
Clear
</button>
<button
type="button"
onClick={save}
disabled={cannotSave}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
>
<Save aria-hidden="true" className="h-4 w-4" />
Save signature
</button>
</div>
</section>
);
}

export default SignatureCanvas;
Loading