department-of-veterans-affairs/vets-website

View on GitHub
src/platform/forms-system/src/js/validation.js

Summary

Maintainability
D
2 days
Test Coverage
import find from 'lodash/find';
import { Validator } from 'jsonschema';
import get from '../../../utilities/data/get';
import omit from '../../../utilities/data/omit';
import set from '../../../utilities/data/set';
import unset from '../../../utilities/data/unset';

import { isActivePage, parseISODate, minYear, maxYear } from './helpers';
import {
  isValidSSN,
  isValidYear,
  isValidVAFileNumber,
  isValidCurrentOrPastDate,
  isValidCurrentOrPastYear,
  isValidCurrentOrFutureDate,
  isValidCurrentOrFutureMonthYear,
  isValidDateRange,
  isValidRoutingNumber,
  isValidPartialMonthYear,
  isValidPartialMonthYearInPast,
  isValidDate,
  isValidPartialDate,
} from './utilities/validations';

/*
 * This contains the code for supporting our own custom validations and messages
 */

/*
 * Override the default messages for these json schema error types
 */
const defaultMessages = {
  required: 'You must provide a response',
  enum: 'You must select a valid option',
  maxLength: max => `This field should be less than ${max} characters`,
  minLength: min => `This field should be at least ${min} character(s)`,
  format: type => {
    if (type === 'email') {
      return 'You must enter a valid email address';
    }

    return 'You must enter a valid value';
  },
};

function getMessage(path, name, uiSchema, errorArgument) {
  let pathSpecificMessage;
  if (path === 'instance') {
    pathSpecificMessage = get(['ui:errorMessages', name], uiSchema);
  } else {
    const cleanPath = path
      .replace('instance.', '')
      .replace(/\[\d+\]/g, '.items');
    pathSpecificMessage = get(
      `${cleanPath}[ui:errorMessages].${name}`,
      uiSchema,
    );
  }

  if (pathSpecificMessage) {
    return pathSpecificMessage;
  }

  return typeof defaultMessages[name] === 'function'
    ? defaultMessages[name](errorArgument)
    : defaultMessages[name];
}

/*
 * This takes the list of errors outputted by the json schema node library
 * and moves the required errors to the missing field, rather than the containing
 * object.
 *
 * It also replaces the error messages with any form specific messages.
 */
export function transformErrors(errors, uiSchema) {
  return errors.map(error => {
    if (error.name === 'required') {
      const path = `${error.property}.${error.argument}`;
      return {
        ...error,
        property: path,
        message: getMessage(path, error.name, uiSchema, error.argument),
      };
    }

    const newMessage = getMessage(
      error.property,
      error.name,
      uiSchema,
      error.argument,
    );
    if (newMessage) {
      return set('message', newMessage, error);
    }

    return error;
  });
}

/**
 * This pulls custom validations specified in the uiSchema and validates the formData
 * against them.
 *
 * Expects validations that look like:
 *
 * someField: {
 *   "ui:validations": [
 *     someValidation
 *   ]
 * }
 *
 * Each item in the ui:validations array can be a function OR an object:
 *    - Functions are called with (in order)
 *        pathErrors:                   Errors object for the field
 *        currentData:                  Data for the field
 *        formData:                     Current form data
 *        schema:                       Current JSON Schema for the field
 *        uiSchema['ui:errorMessages']: Error message object (if available) for the field
 *        currentIndex:                 Used to select the correct field data to validate against
 *    - Objects should have two properties, 'options' and 'validator'
 *        options:   Object (or anything, really) that will be passed to your validation function.
 *                   You can use this to allow your validation function to be configurable for
 *                   different fields on the form.
 *        validator: Same signature as function above, but with extra 'options' object as the
 *                   second-to-last argument (...options, currentIndex)
 * Both versions of custom validators should call `addError()` to actually add any errors to the
 * errors object
 *
 * @param {Object} errors Errors object from rjsf, which includes an addError method
 * @param {Object} uiSchema The uiSchema for the current field
 * @param {Object} schema The schema for the current field
 * @param {Object} formData The (flattened) data for the entire form
 * @param {string} [path] The path to the current field relative to the root of the page.
 * @param {number} [currentIndex] Used to select the correct field data to validate against
 * @param {Object} [appStateData] Data from the app state
 * @param {({ all }) => any} [getFormData] Function to get the form data. Useful if you need all form data
 */

export function uiSchemaValidate(
  errors,
  uiSchema,
  schema,
  formData,
  path = '',
  currentIndex = null,
  appStateData,
  getFormData,
) {
  if (uiSchema && schema) {
    const currentData = path !== '' ? get(path, formData) : formData;
    if (uiSchema.items && currentData) {
      currentData.forEach((item, index) => {
        const newPath = `${path}[${index}]`;
        const currentSchema =
          index < schema.items.length
            ? schema.items[index]
            : schema.additionalItems;
        if (!get(newPath, errors)) {
          const currentErrors = path ? get(path, errors) : errors;
          currentErrors[index] = {
            __errors: [],
            addError(error) {
              this.__errors.push(error);
            },
          };
        }
        uiSchemaValidate(
          errors,
          uiSchema.items,
          currentSchema,
          formData,
          newPath,
          index,
          appStateData,
          getFormData,
        );
      });
    } else if (!uiSchema.items) {
      Object.keys(uiSchema)
        .filter(prop => !prop.startsWith('ui:'))
        .forEach(item => {
          const nextPath = path !== '' ? `${path}.${item}` : item;
          if (!get(nextPath, errors)) {
            const currentErrors = path === '' ? errors : get(path, errors);

            currentErrors[item] = {
              __errors: [],
              addError(error) {
                this.__errors.push(error);
              },
            };
          }
          uiSchemaValidate(
            errors,
            uiSchema[item],
            schema.properties[item],
            formData,
            nextPath,
            currentIndex,
            appStateData,
            getFormData,
          );
        });
    }

    const validations = uiSchema['ui:validations'];
    const useAllFormData = uiSchema?.['ui:options']?.useAllFormData;
    const data =
      useAllFormData && getFormData ? getFormData({ all: true }) : formData;
    if (validations && currentData !== undefined) {
      validations.forEach(validation => {
        const pathErrors = path ? get(path, errors) : errors;
        if (typeof validation === 'function') {
          validation(
            pathErrors,
            currentData,
            data,
            schema,
            uiSchema['ui:errorMessages'],
            currentIndex,
            appStateData,
          );
        } else {
          validation.validator(
            pathErrors,
            currentData,
            data,
            schema,
            uiSchema['ui:errorMessages'],
            validation.options,
            currentIndex,
            appStateData,
          );
        }
      });
    }
  }
  return errors;
}

export function errorSchemaIsValid(errorSchema) {
  if (errorSchema && errorSchema.__errors && errorSchema.__errors.length) {
    return false;
  }

  return Object.values(omit('__errors', errorSchema)).every(errorSchemaIsValid);
}

/**
 * IsValidForm~results
 * @typedef {Object}
 * @property {Boolean} isValid - Returns true if the formData & schema match and
 *  are valid
 * @property {Object[]} errors - Errors returned by jsonschema validator
 * @property {Object|null} formData - Only available during unit tests
 */
/**
 * Use third-party jsonschema validator to validate the formData against the
 * schema
 * @param {Object} form - the entire form object from Redux state
 * @param {Object[]} pageList - Page list array from the router
 * @param {Boolean} isTesting - Testing flag used to return the modified form
 *  data to verify the correct changes were made
 * @returns {IsValidForm~results}
 */
export function isValidForm(form, pageList, isTesting = false) {
  const pageListMap = new Map();
  pageList.forEach(page => pageListMap.set(page.pageKey, page));
  const validPages = Object.keys(form.pages).filter(pageKey =>
    isActivePage(find(pageList, { pageKey }), form.data),
  );

  const v = new Validator();
  let getFormData;

  return validPages.reduce(
    ({ isValid, errors }, page) => {
      const {
        uiSchema,
        schema,
        showPagePerItem,
        itemFilter,
        arrayPath,
      } = form.pages[page];
      const { appStateData } = pageListMap.get(page);
      let formData = form.data;

      if (showPagePerItem) {
        const arrayData = formData[arrayPath];
        if (arrayData) {
          const itemsToKeep = arrayData.map(itemFilter || (() => true));
          // Remove the excluded array data
          formData = set(
            arrayPath,
            arrayData.filter((item, index) => itemsToKeep[index]),
            formData,
          );
          // Remove the excluded array itemSchemas
          //
          // NOTE: This will only work when `arrayPath` isn't a nested path.
          // This is consistent with other uses of arrayPath throughout the
          // library.
          if (Array.isArray(schema.properties[arrayPath].items)) {
            // `items` may be an array if the individual item schemas can be
            // different, or a single object to describe every item. We only
            // want to filter the schemas if they can be different. This ensures
            // the data still matches its corresponding schema if we filtered
            // out some data with `itemFilter`.
            schema.properties[arrayPath].items = schema.properties[
              arrayPath
            ].items.filter((item, index) => itemsToKeep[index]);
          }
        } else {
          formData = unset(arrayPath, formData);
        }
      }

      const result = v.validate(formData, schema);

      // mimics FormPage formData() function
      getFormData = ({ all }) => (all ? form.data : formData);

      if (result.valid) {
        const customErrors = {};
        uiSchemaValidate(
          customErrors,
          uiSchema,
          schema,
          formData,
          '',
          null,
          appStateData,
          getFormData,
        );

        return {
          isValid: isValid && errorSchemaIsValid(customErrors),
          errors: errors.concat(customErrors),
          formData: isTesting ? formData : null, // for unit tests
        };
      }

      return {
        isValid: false,
        // removes PII
        errors: errors.concat(
          result.errors.map(error => unset('instance', error)),
        ),
        formData: isTesting ? formData : null, // for unit tests
      };
    },
    { isValid: true, errors: [] },
  );
}

export function validateSSN(errors, ssn) {
  if (ssn && !isValidSSN(ssn)) {
    errors.addError('Please enter a valid 9 digit SSN (dashes allowed)');
  }
}

export function validateVAFileNumber(errors, vaFileNumber) {
  if (vaFileNumber && !isValidVAFileNumber(vaFileNumber)) {
    errors.addError('VA file number must be 8 or 9 digits (dashes allowed)');
  }
}

export function validateDate(
  errors,
  dateString,
  formData,
  schema,
  errorMessages,
  currentIndex,
  appStateData,
  customMinYear = minYear,
  customMaxYear = maxYear,
) {
  const { day, month, year } = parseISODate(dateString);
  if (year?.length >= 4 && !isValidYear(year)) {
    errors.addError(
      `Please enter a year between ${customMinYear} and ${customMaxYear}`,
    );
  } else if (!isValidPartialDate(day, month, year)) {
    errors.addError('Please provide a valid date');
  } else if (day && month && year && !isValidDate(day, month, year)) {
    errors.addError('Please provide a valid date');
  }
}

export function validateMonthYear(errors, dateString) {
  const { month, year } = parseISODate(dateString);
  if (!isValidPartialMonthYear(month, year)) {
    errors.addError('Please provide a valid date');
  }
}

/**
 * Adds an error message to errors if a date is an invalid date or in the future.
 *
 * The message it adds can be customized in uiSchema.errorMessages.futureDate
 */
export function validateCurrentOrPastDate(
  errors,
  dateString,
  formData,
  schema,
  errorMessages = {},
) {
  const {
    futureDate = 'Please provide a valid current or past date',
  } = errorMessages;
  validateDate(
    errors,
    dateString,
    formData,
    schema,
    errorMessages,
    undefined,
    undefined,
    minYear,
    new Date().getFullYear(),
  );
  const { day, month, year } = parseISODate(dateString);
  if (!isValidCurrentOrPastDate(day, month, year)) {
    errors.addError(futureDate);
  }
}

export function validateCurrentOrPastMemorableDate(
  errors,
  dateString,
  formData,
  schema,
  errorMessages = {},
) {
  const {
    futureDate = 'Please provide a valid current or past date',
  } = errorMessages;
  validateDate(
    errors,
    dateString,
    formData,
    schema,
    errorMessages,
    undefined,
    undefined,
    minYear,
    new Date().getFullYear(),
  );
  const { day, month, year } = parseISODate(dateString);
  if (!day || !month || !year || !isValidCurrentOrPastDate(day, month, year)) {
    errors.addError(futureDate);
  }
}

/**
 * Adds an error message to errors if a date is an invalid date or in the future.
 *
 * The message it adds can be customized in uiSchema.errorMessages.futureDate
 */
export function validateCurrentOrFutureDate(
  errors,
  dateString,
  formData,
  schema,
  errorMessages = {},
) {
  const {
    pastDate = 'Please provide a valid current or future date',
  } = errorMessages;
  validateDate(errors, dateString);
  const { day, month, year } = parseISODate(dateString);
  if (!isValidCurrentOrFutureDate(day, month, year)) {
    errors.addError(pastDate);
  }
}

export function validateCurrentOrPastMonthYear(
  errors,
  dateString,
  formData,
  schema,
  errorMessages = {},
) {
  const {
    futureDate = 'Please provide a valid current or past date',
  } = errorMessages;
  validateMonthYear(errors, dateString);
  const { month, year } = parseISODate(dateString);
  if (!isValidPartialMonthYearInPast(month, year)) {
    errors.addError(futureDate);
  }
}

/**
 * Adds an error message to errors if a date is an invalid date or in the past.
 */
export function validateFutureDateIfExpectedGrad(errors, dateString, formData) {
  validateDate(errors, dateString);
  const { month, year } = parseISODate(dateString);
  if (
    formData.highSchool.status === 'graduationExpected' &&
    !isValidCurrentOrFutureMonthYear(month, year)
  ) {
    errors.addError('Please provide a valid future date');
  }
}

/**
 * Adds an error message to errors if an integer year value is not between 1900 and the current year.
 */
export function validateCurrentOrPastYear(errors, year) {
  if (!isValidCurrentOrPastYear(year)) {
    errors.addError('Please provide a valid year');
  }
}

export function validateMatch(
  field1,
  field2,
  { ignoreCase } = { ignoreCase: false },
) {
  return (errors, formData) => {
    const transform = ignoreCase ? val => val?.toLowerCase() : val => val;
    if (transform(formData[field1]) !== transform(formData[field2])) {
      errors[field2].addError('Please ensure your entries match');
    }
  };
}

export function validateRoutingNumber(
  errors,
  routingNumber,
  formData,
  schema,
  errorMessages,
) {
  if (!isValidRoutingNumber(routingNumber)) {
    errors.addError(errorMessages.pattern);
  }
}

export function convertToDateField(dateStr) {
  const date = parseISODate(dateStr);
  return Object.keys(date).reduce((dateField, part) => {
    const datePart = {};
    datePart[part] = {
      value: date[part],
    };
    return { ...dateField, ...datePart };
  }, date);
}

export function validateDateRange(
  errors,
  dateRange,
  formData,
  schema,
  errorMessages,
  allowSameMonth = false, // This is actually only allowing the same date
) {
  const fromDate = convertToDateField(dateRange.from);
  const toDate = convertToDateField(dateRange.to);

  if (!isValidDateRange(fromDate, toDate, allowSameMonth)) {
    errors.to.addError(
      errorMessages?.pattern || 'To date must be after from date',
    );
  }
}

// using ...args here breaks unit test that don't include all parameters
export function validateDateRangeAllowSameMonth(
  errors,
  dateRange,
  formData,
  schema,
  errorMessages,
) {
  validateDateRange(errors, dateRange, formData, schema, errorMessages, true);
}

export const UPLOADING_FILE = 'Uploading file...';
export const NOT_UPLOADED = 'We couldn’t upload your file';
export const MISSING_PASSWORD_ERROR = 'Missing password';
export const UNSUPPORTED_ENCRYPTED_FILE_ERROR =
  "We weren't able to upload your file. Make sure the file is not encrypted and an accepted format.";
export function getFileError(file) {
  if (file.errorMessage) {
    return file.errorMessage;
  }
  if (file.uploading) {
    return UPLOADING_FILE;
  }
  // Awaiting password entry, but we need to set an error so that using the form
  // continue button blocks progression through the form; look in FileField code
  // to see that we prevent error message rendering for this particular error
  if (file.isEncrypted && !file.confirmationCode && !file.password) {
    return MISSING_PASSWORD_ERROR;
  }
  if (!file.confirmationCode) {
    return NOT_UPLOADED;
  }

  return null;
}

export function validateFileField(errors, fileList) {
  fileList.forEach((file, index) => {
    const error = getFileError(file);

    if (error && !errors[index]) {
      /* eslint-disable no-param-reassign */
      errors[index] = {
        __errors: [],
        addError(msg) {
          this.__errors.push(msg);
        },
      };
      /* eslint-enable no-param-reassign */
    }

    if (error) {
      errors[index].addError(error);
    }
  });
}

export function validateBooleanGroup(
  errors,
  userGroup,
  form,
  schema,
  errorMessages = {},
) {
  const { atLeastOne = 'Please choose at least one option' } = errorMessages;
  const group = userGroup || {};
  if (!Object.keys(group).filter(item => group[item] === true).length) {
    errors.addError(atLeastOne);
  }
}

export function validateAutosuggestOption(errors, formData) {
  if (
    formData &&
    formData.widget === 'autosuggest' &&
    !formData.id &&
    formData.label
  ) {
    errors.addError('Please select an option from the suggestions');
  }
}