/** * WHAT A REFUSED SUBMIT SAYS, WHERE THE PERSON WHO CLICKED IS LOOKING. * * THE DEFECT. Every clinical data modal puts its Create button in the modal HEADER, above a form that * can be several screens tall, and calls `handleSubmit(onSubmit)` with no error callback. React Hook * Form blocks the submit correctly and reports each problem INLINE beside its field - so a clinician * scrolled below the first card clicks Create and sees nothing happen at all. The information is on the * page; it is not where they are. * * MEASURED across `src/modals`: 35 modals call `handleSubmit`, 31 put the button in `headerActions`, * and NONE passes an invalid handler. Every one of the 35 has exactly one `DismissibleAlert * status="error"` and exactly one `const [error, setError]` - zero exceptions - so the slot to fill * already exists in all of them and this needs no new component. * * IT RELAYS THE FORM'S OWN SENTENCES RATHER THAN INVENTING ANY. The field components already write * "{label} is required", which is a human sentence a clinician can act on. Falling back to field NAMES * would put `performedDateTime` in front of a clinician, so when there is no message the summary says * how many answers are missing and leaves the inline markers to say which - which is true, and is the * one thing a summary can always say honestly. */ /** The shape of a react-hook-form error tree, loosely. It nests for object and array fields. */ type ErrorNode = { message?: unknown; type?: unknown } | Record | undefined | null; const MAX_LISTED = 3; const collect = (node: ErrorNode, out: string[], depth = 0): number => { // A cap on depth rather than on trust: a cyclic or unexpectedly deep tree must not hang a click. if (!node || typeof node !== 'object' || depth > 6) return 0; const message = (node as { message?: unknown }).message; const type = (node as { type?: unknown }).type; // A LEAF is a node carrying `type` - that is what react-hook-form stamps on a failed rule. Testing // for `message` alone would miss a rule that failed without one, which is exactly the case the // count-only fallback exists for. if (typeof type === 'string') { if (typeof message === 'string' && message.trim()) out.push(message.trim()); return 1; } let found = 0; for (const value of Object.values(node as Record)) { found += collect(value as ErrorNode, out, depth + 1); } return found; }; /** * The sentence to put in the modal's existing error slot when a submit is refused. * * Returns `undefined` when nothing is wrong, so a caller cannot accidentally show an alert on a form * that is actually fine - a refusal message on a valid form is worse than the silence this replaces. */ export const invalidSubmitMessage = (errors: unknown): string | undefined => { const messages: string[] = []; const count = collect(errors as ErrorNode, messages); if (count === 0) return undefined; const unique = Array.from(new Set(messages)); if (unique.length === 0) { // Nothing readable to relay. Say how many and point at the markers rather than naming fields by // their code names. return count === 1 ? 'One answer is missing. It is marked below.' : `${count} answers are missing. They are marked below.`; } const listed = unique.slice(0, MAX_LISTED).join(' '); const remaining = count - Math.min(unique.length, MAX_LISTED); // A LIST WITH NO END IS A WALL. Three sentences is enough to act on, and the tail says there is // more rather than pretending the three are all of it. return remaining > 0 ? `${listed} ${remaining} more ${remaining === 1 ? 'answer is' : 'answers are'} missing below.` : listed; };