Hello JSONForms community,
I’m working on a custom renderer for an appointment form in JSONForms, which includes a date picker and two time pickers using @mui/x-date-pickers. JSONForms’ material renderers currently don’t support features like disabling past or future dates, so I’ve implemented a custom solution that uses the minDate and maxDate props from MUI.
The main issue I’m facing is with handling validation errors on form submission. When selecting a past or out-of-range date, the onChange event of the date picker provides an error that I’m able to display immediately. However, once the form is submitted, this validation error disappears and is not reflected in the form’s submission state.
Attempts so far:
- Using additionalErrors Prop:
I tried using the additionalErrors prop, but this needs to be set at the root level. Since I’m working within the renderer, it’s challenging to map these date picker errors to additionalErrors.
- Dispatching Actions.updateErrors(updatedErrors) on Change:
I attempted to dispatch an action to update the errors in core.errors, but as per the JSONForms discussion (link), the errors are reset with any data update, making this approach unreliable.
Here’s a snippet of my code, showing my current approach:
import { RenderersUIType } from "@home-health-notify/domain";
import {
Actions,
and,
ControlProps,
RankedTester,
rankWith,
schemaTypeIs,
uiTypeIs,
} from "@jsonforms/core";
import { ErrorObject } from "ajv";
import { useJsonForms, withJsonFormsControlProps } from "@jsonforms/react";
import { Box } from "@mui/material";
import {
DatePicker,
DateValidationError,
LocalizationProvider,
TimePicker,
} from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { FieldChangeHandlerContext } from "@mui/x-date-pickers/internals";
import dayjs, { Dayjs } from "dayjs";
import React, { useState } from "react";
import { timePickerViewRenderers } from "../../../common/ViewRenderers/timePickerViewRenderers";
const AppointmentPickerRenderer = ({
path,
data,
handleChange,
uischema,
schema,
errors,
}: ControlProps) => {
console.log("AppointmentPickerRenderer===>", path, errors);
const { dispatch, core, additionalErrors } = useJsonForms();
const coreErrors = core.errors;
const { label, options } = uischema;
const { dateFormat, timeFormat, disableFuture, disablePast } = options || {};
const { required } = schema;
const appointmentPickerLabel = typeof label === "string" ? label : "Visit";
const [visitDateInputError, setVisitDateInputError] = useState(null);
const getFieldErrorMessage = (
fieldName: string,
errors: string | undefined,
): string | null => {
if (!errors) return null;
const regex = new RegExp(`${fieldName}::Start::(.*?)::End::`, "g");
const matches = [];
let match;
while ((match = regex.exec(errors)) !== null) {
matches.push(match[1]);
}
const result = matches.length > 0 ? matches.join(". ") : null;
return result
? result.replace(new RegExp(`'${fieldName}'`, "g"), "").trim()
: null;
};
const visitDateError = getFieldErrorMessage("visitDate", errors);
const visitStartTimeError = getFieldErrorMessage("visitStartTime", errors);
const visitEndTimeError = getFieldErrorMessage("visitEndTime", errors);
/*console.log("visitDate Error:", visitDateError);
console.log("visitStartTime Error:", visitStartTimeError);
console.log("visitEndTime Error:", visitEndTimeError);*/
const handleDateChange = (
newValue: Dayjs | null,
dateErrors: FieldChangeHandlerContext<DateValidationError>,
) => {
const newPath = "/" + path + "/visitDate";
const newError = dateErrors.validationError
? {
instancePath: newPath,
dataPath: newPath,
message: renderHelperText(dateErrors.validationError),
keyword: "invalidDate",
params: { invalidProperty: "visitDate" },
parentSchema: schema,
schemaPath: "#/properties/futureAppointment/visitDate/required",
}
: null;
console.log("newError ==>", newError);
if (newValue) {
handleChange(path, {
...data,
visitDate: dayjs(newValue).format("YYYY-MM-DD"),
});
}
if (newError) {
setVisitDateInputError(renderHelperText(dateErrors.validationError));
} else {
if (newValue) {
setVisitDateInputError(null);
} else {
setVisitDateInputError(`${appointmentPickerLabel} Date is required`);
}
}
// Update only the relevant field's error
const updatedErrors: ErrorObject[] = newError
? [...coreErrors.filter(err => err.instancePath !== newPath), newError] // Add or update field error
: coreErrors.filter(err => err.instancePath !== newPath); // Remove field error if validation passes
// Dispatch the updated errors array
dispatch(Actions.updateErrors(updatedErrors));
// setAdditionalErrors(errors => [...errors, newError]);
};
const handleStartTimeChange = (newValue: Dayjs | null) => {
if (newValue) {
handleChange(path, {
...data,
visitStartTime: newValue.format("HH:mm:ss"),
visitEndTime: newValue.add(2, "hour").format("HH:mm:ss"),
});
}
};
const handleEndTimeChange = (newValue: Dayjs | null) => {
if (newValue) {
handleChange(path, {
...data,
visitEndTime: newValue.format("HH:mm:ss"),
});
}
};
const renderDateError = (dateInputError: DateValidationError): string => {
//TODO : Text this text out side from schema using jsonforms i18n
if (dateInputError) {
switch (dateInputError) {
case "invalidDate":
return "Invalid date";
case "maxDate":
return "Date is in the future";
case "minDate":
return "Date is in the past";
case "disableFuture":
case "disablePast":
return "Date is out of range";
}
}
};
const renderHelperText = (dateInputError: DateValidationError): string => {
let dateText = "";
if (dateInputError) {
dateText = renderDateError(dateInputError);
} else if (visitDateError) {
dateText = visitDateError;
} else {
dateText = `${appointmentPickerLabel} Date is required`;
}
return dateText;
};
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Box style={{ display: "flex", gap: "16px", padding: "16px 0" }}>
<Box style={{ flex: 1 }}>
<DatePicker
label={`${appointmentPickerLabel} Date`}
format={dateFormat}
value={data?.visitDate ? dayjs(data.visitDate) : null}
onChange={(newDate, errors) => {
console.log("newDate ==>", newDate);
console.log("errors ==>", errors);
handleDateChange(newDate, errors);
/*handleChange(path, {
...data,
visitDate: dayjs(newDate).format("YYYY-MM-DD"),
});*/
}}
minDate={disablePast ? dayjs() : undefined}
maxDate={disableFuture ? dayjs() : undefined}
slotProps={{
textField: {
fullWidth: true,
required: required.includes("visitDate"),
error: !!visitDateError || !!visitDateInputError,
helperText: visitDateError || visitDateInputError,
},
}}
/>
</Box>
<Box style={{ flex: 1 }}>
<TimePicker
viewRenderers={timePickerViewRenderers}
label={`${appointmentPickerLabel} Start Time`}
value={
data?.visitStartTime
? dayjs(data.visitStartTime, timeFormat)
: null
}
format={timeFormat}
onChange={handleStartTimeChange}
minTime={data?.visitDate === dayjs() ? dayjs() : undefined}
slotProps={{
textField: {
fullWidth: true,
required: required.includes("visitStartTime"),
error: !!visitStartTimeError,
helperText: visitStartTimeError,
},
}}
/>
</Box>
<Box style={{ flex: 1 }}>
<TimePicker
viewRenderers={timePickerViewRenderers}
label={`${appointmentPickerLabel} End Time`}
value={
data?.visitEndTime ? dayjs(data.visitEndTime, timeFormat) : null
}
format={timeFormat}
onChange={handleEndTimeChange}
minTime={data?.visitStartTime && dayjs(data.visitStartTime)}
slotProps={{
textField: {
fullWidth: true,
required: required.includes("visitEndTime"),
error: !!visitEndTimeError,
helperText: visitEndTimeError,
},
}}
/>
</Box>
</Box>
</LocalizationProvider>
);
};
export const appointmentPickerTester: RankedTester = rankWith(
5,
and(uiTypeIs(RenderersUIType.AppointmentPicker), schemaTypeIs("object")),
);
export default withJsonFormsControlProps(AppointmentPickerRenderer);
Questions:
-
How can I persist the date picker’s validation errors on form submission?
-
Is there a recommended approach for handling custom validation errors that appear in onChange but also need to persist for form-level validation?
Thank you for any guidance or alternative solutions!