feat: add date bounds to DateField and improve validation flow in registration wizard

This commit is contained in:
estifanos
2026-09-01 06:43:32 +00:00
parent 3b5bdfb6d1
commit ff3396ed05
3 changed files with 36 additions and 14 deletions

View File

@@ -61,7 +61,7 @@ export function SelectField(p: FieldProps & { options: { value: string; label: s
); );
} }
export function DateField(p: FieldProps) { export function DateField(p: FieldProps & { minDate?: Date | string; maxDate?: Date | string }) {
return ( return (
<Col span={p.span}> <Col span={p.span}>
<AmharicDatePicker <AmharicDatePicker
@@ -70,6 +70,8 @@ export function DateField(p: FieldProps) {
disabled={p.disabled} disabled={p.disabled}
required={p.required} required={p.required}
dateFormat="date" dateFormat="date"
minDate={p.minDate}
maxDate={p.maxDate}
value={(p.form[p.name] as string) ?? ''} value={(p.form[p.name] as string) ?? ''}
onChange={(v) => p.set(p.name, v)} onChange={(v) => p.set(p.name, v)}
/> />

View File

@@ -220,6 +220,10 @@ export function EmergencyContactStep(p: StepProps) {
name="medicalIssueDate" name="medicalIssueDate"
label="Issue Date" label="Issue Date"
required required
// Bounded here rather than only at submit: the calendar is the one
// place the applicant can see why a day is refused, and the server's
// rejection otherwise only surfaces five steps later.
maxDate={new Date()}
description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18." description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18."
/> />
</Grid> </Grid>

View File

@@ -80,6 +80,11 @@ function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration
return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration; return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration;
} }
/** Today as `yyyy-mm-dd` in the browser's own zone — en-CA is that format. */
function todayDate(): string {
return new Date().toLocaleDateString('en-CA');
}
function blank(value: unknown): boolean { function blank(value: unknown): boolean {
return value === null || value === undefined || value === '' || value === false; return value === null || value === undefined || value === '' || value === false;
} }
@@ -258,6 +263,10 @@ export function SeafarerRegistrationPage() {
function set(key: AnswerKey, value: unknown) { function set(key: AnswerKey, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
// The submit refusal names what was wrong at the time it was refused.
// Leaving it on screen while the applicant corrects it reads as the
// correction having been ignored.
if (issues.length) setIssues([]);
setErrors((prev) => { setErrors((prev) => {
if (!prev[key]) return prev; if (!prev[key]) return prev;
const next = { ...prev }; const next = { ...prev };
@@ -280,6 +289,9 @@ export function SeafarerRegistrationPage() {
found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`; found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`;
} }
} }
if (index === 2 && (form.medicalIssueDate ?? '').slice(0, 10) > todayDate()) {
found.medicalIssueDate = 'The issue date cannot be in the future.';
}
setErrors(found); setErrors(found);
const missingKeys = Object.keys(found) as AnswerKey[]; const missingKeys = Object.keys(found) as AnswerKey[];
if (missingKeys.length) { if (missingKeys.length) {
@@ -323,19 +335,23 @@ export function SeafarerRegistrationPage() {
} }
async function goToStep(target: number) { async function goToStep(target: number) {
if (target <= active) { // Saved before anything can turn the navigation around. `form` is the only
setActive(target); // copy of what was typed, so validating first — as this used to — threw the
return; // edit away on every blocked step and every step back: an applicant fixing
} // a field the submit check rejected watched the correction vanish. A draft
// Going forward validates every step passed over, so a jump cannot skip a // takes any subset of the answers, so persisting an incomplete one is safe.
// required field; the walk stops on the first step that fails. const saved = await saveAnswers();
for (let step = active; step < target; step++) { if (target > active) {
if (!readOnly && !validateStep(step)) { if (!saved) return;
setActive(step); // Going forward validates every step passed over, so a jump cannot skip a
return; // required field; the walk stops on the first step that fails.
for (let step = active; step < target; step++) {
if (!readOnly && !validateStep(step)) {
setActive(step);
return;
}
} }
} }
if (!(await saveAnswers())) return;
setErrors({}); setErrors({});
setActive(target); setActive(target);
} }
@@ -343,8 +359,8 @@ export function SeafarerRegistrationPage() {
async function handleSubmit() { async function handleSubmit() {
if (!registration) return; if (!registration) return;
setIssues([]); setIssues([]);
if (!readOnly && !validateStep(4)) return;
if (!(await saveAnswers())) return; if (!(await saveAnswers())) return;
if (!readOnly && !validateStep(4)) return;
try { try {
await submit(registration.id).unwrap(); await submit(registration.id).unwrap();
notifications.show({ notifications.show({
@@ -510,7 +526,7 @@ export function SeafarerRegistrationPage() {
)} )}
<Group justify="space-between" mt="xl"> <Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => setActive((s) => Math.max(0, s - 1))} disabled={active === 0}> <Button variant="default" onClick={() => goToStep(Math.max(0, active - 1))} disabled={active === 0}>
Back Back
</Button> </Button>
{active < STEPS.length - 1 ? ( {active < STEPS.length - 1 ? (