ui phase 9
This commit is contained in:
@@ -6,11 +6,11 @@ jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
import PatientForm from './PatientForm';
|
||||
|
||||
function renderForm() {
|
||||
function renderForm(extra: Partial<React.ComponentProps<typeof PatientForm>> = {}) {
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<PatientForm submitLabel="Save" onSubmit={onSubmit} />
|
||||
<PatientForm submitLabel="Save" onSubmit={onSubmit} {...extra} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSubmit };
|
||||
@@ -20,17 +20,19 @@ describe('<PatientForm/> component', () => {
|
||||
it('blocks submit and flags gender when it is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei');
|
||||
await user.type(screen.getByLabelText('first_name_label'), 'Ali');
|
||||
await user.type(screen.getByLabelText('last_name_label'), 'Rezaei');
|
||||
await user.type(screen.getByLabelText('age_label'), '40');
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('gender_required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the mapped patient input once name, age and gender are set', async () => {
|
||||
it('submits the mapped patient input once first/last name, age and gender are set', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei');
|
||||
await user.type(screen.getByLabelText('first_name_label'), 'Ali');
|
||||
await user.type(screen.getByLabelText('last_name_label'), 'Rezaei');
|
||||
await user.type(screen.getByLabelText('age_label'), '40');
|
||||
await user.click(screen.getByText('gender_male'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
@@ -47,4 +49,23 @@ describe('<PatientForm/> component', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back the last name to the first name when left blank (the wire requires it)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
await user.type(screen.getByLabelText('first_name_label'), 'Ali');
|
||||
await user.type(screen.getByLabelText('age_label'), '40');
|
||||
await user.click(screen.getByText('gender_male'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Ali', firstName: 'Ali', lastName: 'Ali' }));
|
||||
});
|
||||
|
||||
it('reports dirty state changes via onDirtyChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDirtyChange = jest.fn();
|
||||
renderForm({ onDirtyChange });
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
await user.type(screen.getByLabelText('first_name_label'), 'A');
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import FormLabel from '@mui/material/FormLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
@@ -18,7 +18,7 @@ const MAX_AGE = 120;
|
||||
|
||||
export interface PatientFormProps {
|
||||
/** Prefill for edit, or a relation carried from the onboarding relation step. */
|
||||
initial?: Partial<Pick<Patient, 'displayName' | 'birthDate' | 'gender' | 'conditions' | 'relation'>>;
|
||||
initial?: Partial<Pick<Patient, 'displayName' | 'firstName' | 'lastName' | 'birthDate' | 'gender' | 'conditions' | 'relation'>>;
|
||||
/** Show the relation picker (E1 add/edit). Onboarding hides it — A3 already chose the relation. */
|
||||
showRelation?: boolean;
|
||||
submitLabel: string;
|
||||
@@ -26,21 +26,16 @@ export interface PatientFormProps {
|
||||
onSubmit: (input: CreatePatientInput) => void;
|
||||
onCancel?: () => void;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
// A single full-name field (per the wireframe) maps to the contract's first/last/display.
|
||||
function splitName(fullName: string): Pick<CreatePatientInput, 'displayName' | 'firstName' | 'lastName'> {
|
||||
const displayName = fullName.trim();
|
||||
const parts = displayName.split(/\s+/);
|
||||
const firstName = parts[0] ?? '';
|
||||
const lastName = parts.slice(1).join(' ') || firstName;
|
||||
return { displayName, firstName, lastName };
|
||||
/** Reports whether any field differs from `initial` — drives a host `FormDialogShell`'s discard-confirm. */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The A4 patient form — full name, age, **required** gender, optional condition chips, and
|
||||
* (for E1) the relation. Reused for create and edit. Gender is required and never defaulted;
|
||||
* age maps to `birthDate`. Strings come from the `onboarding` namespace.
|
||||
* The A4 patient form — first/last name, age, **required** gender, optional condition chips, and
|
||||
* (for E1) the relation. Reused for create and edit. Gender is required and never defaulted; age
|
||||
* maps to `birthDate` (never rendered back as a fabricated full date — display stays age-only).
|
||||
* `lastName` falls back to `firstName` only when left blank (the wire's `lastName` is required),
|
||||
* never guessed by splitting a single field. Strings come from the `onboarding` namespace.
|
||||
* @component PatientForm
|
||||
*/
|
||||
const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
@@ -51,17 +46,26 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
onDirtyChange,
|
||||
}) => {
|
||||
const t = useTranslations('onboarding');
|
||||
|
||||
const [fullName, setFullName] = useState(initial?.displayName ?? '');
|
||||
const [age, setAge] = useState(() => {
|
||||
const initialAge = birthDateToAge(initial?.birthDate);
|
||||
return initialAge == null ? '' : String(initialAge);
|
||||
});
|
||||
const [gender, setGender] = useState<Gender | null>(initial?.gender ?? null);
|
||||
const [conditions, setConditions] = useState<string[]>(initial?.conditions ?? []);
|
||||
const [relation, setRelation] = useState<Relation | null>(initial?.relation ?? null);
|
||||
const initialFirstName = initial?.firstName ?? '';
|
||||
const initialLastName = initial?.lastName ?? '';
|
||||
const initialAge = (() => {
|
||||
const age = birthDateToAge(initial?.birthDate);
|
||||
return age == null ? '' : String(age);
|
||||
})();
|
||||
const initialGender = initial?.gender ?? null;
|
||||
const initialConditions = initial?.conditions ?? [];
|
||||
const initialRelation = initial?.relation ?? null;
|
||||
|
||||
const [firstName, setFirstName] = useState(initialFirstName);
|
||||
const [lastName, setLastName] = useState(initialLastName);
|
||||
const [age, setAge] = useState(initialAge);
|
||||
const [gender, setGender] = useState<Gender | null>(initialGender);
|
||||
const [conditions, setConditions] = useState<string[]>(initialConditions);
|
||||
const [relation, setRelation] = useState<Relation | null>(initialRelation);
|
||||
|
||||
const [nameError, setNameError] = useState(false);
|
||||
const [ageError, setAgeError] = useState(false);
|
||||
@@ -70,10 +74,28 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
const conditionOptions = CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) }));
|
||||
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`) }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!onDirtyChange) return;
|
||||
const dirty =
|
||||
firstName !== initialFirstName ||
|
||||
lastName !== initialLastName ||
|
||||
age !== initialAge ||
|
||||
gender !== initialGender ||
|
||||
relation !== initialRelation ||
|
||||
conditions.length !== initialConditions.length ||
|
||||
conditions.some((code) => !initialConditions.includes(code as ConditionCode));
|
||||
onDirtyChange(dirty);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial* are derived from the `initial` prop once per mount (form is re-keyed by the caller on edit target change), not reactive state.
|
||||
}, [firstName, lastName, age, gender, relation, conditions]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const name = fullName.trim();
|
||||
const first = firstName.trim();
|
||||
const enteredLast = lastName.trim();
|
||||
// The wire's `lastName` is a required string, so it falls back to the first name when left
|
||||
// blank — but `displayName` reflects only what was actually entered (never a duplicated name).
|
||||
const last = enteredLast || first;
|
||||
const ageNum = Number(digitsOnly(age));
|
||||
const nameInvalid = name.length === 0;
|
||||
const nameInvalid = first.length === 0;
|
||||
const ageInvalid = age.trim().length === 0 || !Number.isInteger(ageNum) || ageNum < 0 || ageNum > MAX_AGE;
|
||||
const genderInvalid = gender == null;
|
||||
|
||||
@@ -83,7 +105,9 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
if (nameInvalid || ageInvalid || genderInvalid) return;
|
||||
|
||||
onSubmit({
|
||||
...splitName(name),
|
||||
displayName: [first, enteredLast].filter(Boolean).join(' '),
|
||||
firstName: first,
|
||||
lastName: last,
|
||||
birthDate: ageToBirthDate(ageNum),
|
||||
gender: gender as Gender,
|
||||
bloodType: null,
|
||||
@@ -95,17 +119,25 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<TextField
|
||||
label={t('name_label')}
|
||||
value={fullName}
|
||||
onChange={(event) => {
|
||||
setFullName(event.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
helperText={nameError ? t('name_required') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<TextField
|
||||
label={t('first_name_label')}
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
helperText={nameError ? t('name_required') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('last_name_label')}
|
||||
value={lastName}
|
||||
onChange={(event) => setLastName(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={t('age_label')}
|
||||
|
||||
Reference in New Issue
Block a user