Showing @mui/x-date-pickers Validation Errors on Form Submission in Custom JSONForms Renderer

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:

  1. 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.

  1. 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:

  1. How can I persist the date picker’s validation errors on form submission?

  2. 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!

Hi @gr8pathik,

persistent errors should be determined independent of the UI, so that they can for example be reproduced in the backend too.

For this my main recommendation is to implement a custom validator for AJV. Then the errors are determined as part of the regular validation process and don’t need any special handling in the UI.

Some alternatives, although all of them have some downsides:

  • You could perform the custom validation outside of JSON Forms whenever you receive a data update and thereby always hand in updated additionalErrors. Downside: Additional rendering step
  • The custom validation can also be executed as part of the middleware whenever validation is performed, i.e. as part of the INIT, UPDATE_CORE, UPDATE_DATA actions. Downside: Error handling tied to JSON Forms
  • The errors determined during rendering could be collected by a React context, thereby persisting them. Downside: Error handling tied to JSON Forms and UI