Source

stories/AnyField.jsx

/** @module AnyField */
import React from 'react';
import PropTypes from 'prop-types';
import { Controller } from 'react-hook-form';

import {
  TextField, FormControl,
  FormGroup, FormControlLabel, FormHelperText,
  Checkbox
} from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';

import RadioOptions from './RadioOptions';
import Typeahead from './Typeahead';
import FormErrorMessage from './FormErrorMessage';
import AnyFieldLabel from './AnyFieldLabel';

import { FIELD_TYPES } from '../constants';
import { Box } from '@mui/material';
import { isObject } from '../helpers';

const makeFilter = (checkedId) => {
  const checkId = isObject(checkedId) ? checkedId.id : checkedId;
  return (option) => {
    const optId = isObject(option) ? option.id : option;
    return optId.toString() !== checkId.toString();
  };
};


const handleMultiSelectChange = (field, checkedId) => {
  const ids = field.value;
  const checkId = isObject(checkedId) ? checkedId.id : checkedId;
  // If the id is in the array, remove it, otherwise add it
  const filterFunc = makeFilter(checkId);
  const newIds = ids?.includes(checkId)
    ? ids?.filter(filterFunc)
    : [...(ids ?? []), checkId];
  return newIds;
};

/**
 * Icons for info icons
 * @typedef {Object} FieldIconOptions
 * @property {string} [color] - the color of the icon
 * @property {string} [gap] - the gap between the label and the icon
 * @property {boolean} [beforeLabel] - whether to display the icon before the label
 * @property {React.Component} [iconComponent] - a component to use instead of the default InfoIcon
 * @property {string} [iconText] - the text to display in the info icon
 */

/**
 * Theme group for the label
 * @typedef {Object} FieldLabelThemeGroup
 * @property {object} [anyFieldLabel] - any theme properties to use for the box containing the label
 * @property {object} [anyFieldLabel.helperText] - any theme properties to use for the helper text
 */

/**
 * Various options for the fields
 * @typedef {Object} FieldOptions
 * @property {FieldIconOptions} [icon] - the options to pass to the info icon
 * @property {FieldLabelThemeGroup} [labelThemeGroup] - the theme group to use for the label
 */

/**
 * Various options for the fields
 * @typedef {Object} FieldLayout
 * @property {string} id - the id of the field
 * @property {string} name - the name of the field
 * @property {string} type - the type of the field
 * @property {string} label - the label of the field
 * @property {string} [helperText] - the helper text of the field
 * @property {string} [placeholder] - the placeholder of the field
 * @property {string} [iconHelperText] - the helper text of the info icon
 * @property {string} [altHelperText] - helper text to display in an alternate location
 * @property {boolean} [required] - whether the field is required
 * @property {boolean} [disabled] - whether the field is disabled
 * @property {boolean} [hidden] - whether the field is hidden
 * @property {array} [choices] - The choices for the field
 * @property {boolean} [multiple] - whether the field is multi select
 * @property {boolean} [isMultiLine] - whether the field is multiline
 */

/**
 * AnyField is a wrapper around the various field types that implements the react-hook-form Controller
 * @function
 * @param {object} props
 * @param {object} props.control - the react-hook-form control object
 * @param {object} props.layout - the layout object
 * @param {object} [props.rules] - the react-hook-form rules object (this is not used if using form level validation)
 * @param {boolean} [props.disabled] - whether the field is disabled
 * @param {string} [props.nestedName] - the name of the field if it is nested
 * @param {boolean} [props.isNested] - whether the field is nested
 * @param {FieldOptions} [props.options] - various options for the fields
 * @returns {React.ReactElement | null} - the rendered AnyField
 */
const AnyField = ({ control, layout, rules, options, nestedName, isNested, ...props }) => {
  // If this component ever uses hooks make sure to move this return BELOW those hooks
  if (layout.hidden) {
    return null;
  }

  const name = (isNested && nestedName) ? nestedName : layout.name;
  const renderState = renderType(layout, options, nestedName);
  // Per react-hook-form docs, we should not unregister fields in a field Array at this level
  // It is done via the useFieldArray hook in ClusterField component
  const shouldUnregister = !isNested;

  return (
    <Box {...props}>
      <Controller
        shouldUnregister={shouldUnregister}
        control={control}
        rules={rules}
        name={name}
        render={renderState}
      />
    </Box>
  );
};

AnyField.propTypes = {
  control: PropTypes.object.isRequired,
  layout: PropTypes.object.isRequired,
  rules: PropTypes.object,
  options: PropTypes.object,
  isNested: PropTypes.bool,
  nestedName: PropTypes.string,
};

/**
 * Return the correct renderer for the given type
 * @function
 * @param {FieldLayout} layout - the layout object for the field
 * @param {FieldOptions} [fieldOptions] - various options for the fields
 * @param {string} [nestedName] - the name of the field if it is nested
 * @returns {React.ReactElement} - the rendered field
 */
const renderType = (layout, fieldOptions = {}, nestedName) => {
  if (layout.iconHelperText) {
    fieldOptions.icon = fieldOptions.icon || {};
    fieldOptions.icon.color = fieldOptions.icon.color || 'primary';
  }

  const { id, type, label, options } = layout;
  const finalId = nestedName || id;
  switch (type) {
    case FIELD_TYPES.DATE: {
      return dateRenderer(layout, fieldOptions, finalId);
    }
    case FIELD_TYPES.TEXT:
    case FIELD_TYPES.LONG_TEXT:
    case FIELD_TYPES.INT:
    case FIELD_TYPES.LINK:
    case FIELD_TYPES.CURRENCY:
    case FIELD_TYPES.FLOAT: {
      return textRenderer(layout, fieldOptions, finalId);
    }
    case FIELD_TYPES.CHOICE:
    case FIELD_TYPES.OBJECT: {
      if (layout.multiple && layout.checkbox) {
        return checkboxRenderer(layout, fieldOptions, finalId);
      }
      return typeaheadRenderer(layout, fieldOptions, finalId);
    }
    case 'radio': {
      const renderRadio = ({ field: { value, onChange }, fieldState: { error } }) => {
        // so we need to manually connect a few props here for react hook form
        return (
          <RadioOptions
            items={options}
            isRequired={true}
            id={finalId}
            label={label}
            value={value}
            onChange={onChange}
            error={error}
          />);
      };
      return renderRadio;
    }
    default:
      return textRenderer(layout, fieldOptions);
  }
};

/**
 * This is a custom renderer for the MUI TextField component to work with react-hook-form
 * @function
 * @param {FieldLayout} layout Object containing the layout of the field
 * @param {FieldOptions} [fieldOptions] Various options for the field
 * @returns {React.ReactElement} A custom renderer for the MUI TextField component
 */
const textRenderer = ({ id, name, label, isMultiLine, placeholder, required, disabled, readOnly, altHelperText, iconHelperText, helperText, type }, fieldOptions, finalId) => {
  const inputAttrs = {
    'data-src-field': finalId,
    readOnly: readOnly,
  };

  const isNumber = type === FIELD_TYPES.CURRENCY || type === FIELD_TYPES.INT || type === FIELD_TYPES.FLOAT;
  const prefix = readOnly ? '' : 'Enter';

  const TextFieldWrapped = ({ field: { ref, value, onChange, onBlur }, fieldState: { error } }) => {
    return (
      <>
        <AnyFieldLabel
          htmlFor={finalId || name}
          error={!!error}
          label={label}
          required={!!required}
          disabled={disabled}
          iconText={iconHelperText}
          fieldOptions={fieldOptions}
          helperText={altHelperText}
        />
        <TextField sx={{ width: '100%' }}
          name={finalId || name}
          inputProps={inputAttrs}
          inputRef={ref}
          disabled={disabled}
          type={isNumber ? 'number' : 'text'}
          id={finalId || name}
          error={!!error}
          onChange={onChange}
          onBlur={onBlur}
          value={value}
          multiline={isMultiLine}
          minRows={isMultiLine ? 3 : 1}
          placeholder={placeholder || `${prefix} ${label}`}
          variant="outlined"
        />
        {helperText && <FormHelperText error={false}>{helperText}</FormHelperText>}
        <FormErrorMessage error={error} />
      </>
    );
  };

  TextFieldWrapped.propTypes = {
    field: PropTypes.shape({
      value: PropTypes.any,
      onChange: PropTypes.func,
      onBlur: PropTypes.func,
      ref: PropTypes.any
    }),
    fieldState: PropTypes.shape({
      error: PropTypes.any
    })
  };

  return TextFieldWrapped;
};

/**
 * This is a custom renderer for the MUI DatePicker component to work with react-hook-form
 * @function
 * @param {FieldLayout} layout Object containing the layout of the field
 * @param {FieldOptions} [fieldOptions] Various options for the field
 * @returns {React.ReactElement} A custom renderer for the MUI DatePicker component
 */
const dateRenderer = ({ id, name, label, disabled, required, readOnly, helperText, iconHelperText, altHelperText, placeholder, disableFuture }, fieldOptions, finalId) => {
  const DateField = ({ field: { value, onChange, ref }, fieldState: { error } }) => (
    <>
      <DatePicker
        id={finalId}
        name={finalId || name}
        disabled={disabled}
        value={value}
        onChange={onChange}
        disableFuture={disableFuture}
        renderInput={(params) => {
          // MUI-X DatePicker injects a bunch of props into the input element. If we override the inputProps entirely functionality goes BOOM
          params.inputProps['data-src-field'] = finalId || name;
          params.inputProps.readOnly = readOnly;
          params.name = finalId || name;
          params.id = finalId || name;
          if (placeholder) {
            params.inputProps.placeholder = placeholder;
          }
          return (
            <>
              <AnyFieldLabel htmlFor={finalId || name} error={!!error} label={label} required={!!required} disabled={disabled} iconText={iconHelperText} helperText={altHelperText} fieldOptions={fieldOptions} />
              <TextField sx={{ width: '100%' }} {...params} />
            </>
          );
        }}
      />
      {helperText && <FormHelperText error={false}>{helperText}</FormHelperText>}
      <FormErrorMessage error={error} />
    </>
  );

  DateField.propTypes = {
    field: PropTypes.shape({
      value: PropTypes.any,
      onChange: PropTypes.func
    }),
    fieldState: PropTypes.shape({
      error: PropTypes.any
    })
  };

  return DateField;
};

/**
 * This is a custom renderer for our Typeahead component to work with react-hook-form
 * @function
 * @param {FieldLayout} layout - Object containing the layout of the field
 * @param {FieldOptions} [fieldOptions] Various options for the field
 * @returns {React.ReactElement} A custom renderer for the MUI TextField component
 */
const typeaheadRenderer = ({ label, id, name, disabled, choices, required, placeholder, helperText, altHelperText, iconHelperText, multiple }, fieldOptions, finalId) => {
  const WrappedTypeahead = ({ field, field: { onChange }, fieldState: { error } }) => {
    // value is passed in via the react hook form inside of field
    // Ref is needed by the typeahead / autoComplete component and is passed in via props spreading
    const dataAttrs = {
      'data-src-field': name
    };

    return (
      // We need to manually connect a few props here for react hook form
      <Typeahead
        id={finalId}
        name={finalId || name}
        {...dataAttrs}
        {...field}
        multiple={multiple}
        sx={{ width: '100%' }}
        disabled={disabled}
        items={choices || []}
        label={label}
        isRequired={!!required}
        fieldOptions={fieldOptions}
        helperText={helperText}
        altHelperText={altHelperText}
        iconHelperText={iconHelperText}
        // These are props that are passed to the MUI TextField rendered by Typeahead
        textFieldProps={{
          id: finalId || name,
          name: finalId || name,
          placeholder: placeholder || `Select ${label}`,
          error: !!error,
          inputRef: field.ref,
        }}
        // hooks-form appears to only want value and not the native onChange
        onChange={(_, newValue) => {
          // Need slightly different logic for multiple.
          if (multiple) {
            // The internal autocomplete component returns the ENTIRE object. So we map it consistently to just the id / value
            const nextValue = newValue.map((v) => {
              if (isObject(v)) {
                return v.id || v.value;
              }
              return v;
            });
            onChange(nextValue);
          } else {
            let nextValue = newValue?.id || newValue?.value || newValue;
            // We need to set the value to null if it is empty string or undefined to correctly set 'unselected' state
            // a value of 0 is valid
            if (nextValue === '' || nextValue === undefined) {
              nextValue = null;
            }
            onChange(nextValue);
          }
        }}
        error={error}
      />
    );
  };

  WrappedTypeahead.propTypes = {
    field: PropTypes.shape({
      id: PropTypes.string,
      value: PropTypes.any,
      onChange: PropTypes.func,
      ref: PropTypes.any
    }),
    fieldState: PropTypes.shape({
      error: PropTypes.any
    })
  };

  return WrappedTypeahead;
};

/**
 * This is a custom renderer for MUI Checkboxes to work with react-hook-form
 * @function
 * @param {FieldLayout} layout - Object containing the layout of the field
 * @param {FieldOptions} [fieldOptions] Various options for the field
 * @returns {React.ReactElement} A custom renderer for the MUI Checkbox component
 */
const checkboxRenderer = (layout, fieldOptions, finalId) => {
  const { label, disabled, choices = [], required, helperText, iconHelperText, altHelperText } = layout;

  const Checkboxes = ({ field, fieldState: { error } }) => {

    // FormControl expects error to be a boolean. If it's an object, it will throw an error
    return (
      <>
        <FormControl
          data-src-field={finalId || field.id}
          error={!!error}
          disabled={disabled}
          component="fieldset"
          variant="standard"
        >
          <AnyFieldLabel
            asFormInput={true}
            htmlFor={finalId || field.name}
            error={!!error}
            label={label}
            required={!!required}
            disabled={disabled}
            iconText={iconHelperText}
            fieldOptions={fieldOptions}
            helperText={helperText}
          />
          <FormGroup>
            {choices.length === 0 && <FormHelperText>There are no options to select</FormHelperText>}
            {choices?.map((item) => (
              <FormControlLabel
                key={item.id}
                control={<Checkbox
                  data-src-checkbox={item.id}
                  onBlur={field.onBlur}
                  checked={field?.value?.includes(item.id)}
                  onChange={(e) => {
                    field.onChange(handleMultiSelectChange(field, item.id));
                  }}
                />}
                label={item.label}
              />
            ))}
            {altHelperText && <FormHelperText error={false}>{altHelperText}</FormHelperText>}
            <FormErrorMessage error={error} />
          </FormGroup>
        </FormControl>
      </>
    );
  };

  Checkboxes.propTypes = {
    field: PropTypes.object,
    fieldState: PropTypes.object,
  };
  return Checkboxes;
};

export default AnyField;