ui phase 9

This commit is contained in:
hamid
2026-07-19 15:14:44 +03:30
parent 1ef4feb911
commit b638e25a0e
47 changed files with 2628 additions and 666 deletions
@@ -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')}