Source

stories/ClusterField.jsx

/** @module ClusterField */
// Third party
import React from 'react';
import PropTypes from 'prop-types';
import { useFieldArray, useFormContext } from 'react-hook-form';

// MUI components
import { Divider, FormHelperText, useTheme, useMediaQuery, Typography } from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2';

// SRC Components
import AnyField from './AnyField';
import Button from './Button';
import AnyFieldLabel from './AnyFieldLabel';

// Hooks, helpers, and constants
import { getFieldValue } from '../hooks';
import { createRowFields } from '../helpers';

/**
 * ClusterField component will render a field that contains a list of subfields
 * @function
 * @param {object} props - props object
 * @param {object} props.control - react-hook-form control object
 * @param {object} props.field - field object
 * @param {object} props.props - props object
 * @param {object} props.options - options object
 * @param {Function} props.renderAddButton - render function for the add button
 * @param {Function} props.renderRemoveButton - render function for the remove button
 * @returns {React.ReactElement}
 */
const ClusterField = ({ control, field, options, ...props }) => {
  const theme = useTheme();
  const inlineAllowed = useMediaQuery(theme.breakpoints.up('sm'));

  const { renderAddButton, renderRemoveButton } = options || {};
  // const columns = options?.clusterColumnCount || 1;
  // let colSize = 12 / fields.length;
  // Get all errors from react-hook-form formState and the trigger function from useFormContext
  const { useFormObject } = useFormContext();

  const layout = field?.render || {};

  const { formState, trigger, clearErrors } = useFormObject;
  const { errors } = formState;

  // Find any errors for the cluster field
  const error = errors[layout?.name];
  const subFields = field?.subFields || [];
  let { clusterColumnCount, inline } = layout;
  if (clusterColumnCount === undefined) {
    clusterColumnCount = options.clusterColumnCount || 1;
  }

  const clusterName = field.render.name;

  const rows = createRowFields(subFields, clusterColumnCount, inline);

  // Create an object with the default values set for each field in the cluster
  const initValues = {};
  subFields.forEach((subField) => {
    initValues[subField.render?.name] = getFieldValue(subField, {}).value;
  });
  /**
    * It is important this is NOT an empty object.
    * If an empty object is used in conjuction with defaultValues (i.e. editing) as fields are added and removed
    * the defaultValues will be added to newly appended fields in their respective indexes.
    * Example (with defaultValues):
    * 1. Existing fields: [{name: 'test1'}, {name: 'test2'}]
    * 2. Delete a cluster
    * 3. Current fields: [{name: 'test1'}]
    * 4. Add a cluster
    * 5. The current are now fields: [{name: 'test1'}, {name: 'test2'}]
    * Instead of the expected: [{name: 'test1'}, {name: ''}]
  */

  // Get the fields and append / remove functions from useFieldArray
  // "fields" from useFieldArray is an array of objects that contain the values of the fields
  // Not to be confused with the "field" prop that is passed into this component
  const { fields, append, remove } = useFieldArray({
    control,
    name: layout.name,
    shouldUnregister: true,
  });

  // This should happen in the parent component, but this is a fallback
  // Note this return MUST happen after all the hook calls or lil React will lose its mind
  if (layout.hidden) {
    return null;
  }

  // If the renderAddButton / renderRemoveButton props exist and are functions, use them, otherwise use the default render functions
  const addButtonRender = renderAddButton && renderAddButton instanceof Function ? renderAddButton : renderDefaultAddButton;

  // A default click handler for the add button
  // If a custom renderAddButton is passed in it will still be passed this method as a prop 'onClick'
  const addClick = (layout, initValues, fields) => {
    // append should NOT be called with an empty object
    // If it is, there is a chance for zombie data.
    // Focus the first field in the newly appended cluster. This seems to only work with textfields at the moment.
    // MUI Datepicker and Selects do not seem to work with this.
    append(initValues, {focusName: `${layout.name}.${fields.length}.${subFields[1].id}`});

    if (layout?.name) {
      if (fields?.length === 0) {
        clearErrors(layout?.name);
      }
    }
  };

  const removeButtonRender = renderRemoveButton && renderRemoveButton instanceof Function ? renderRemoveButton : renderDefaultRemoveButton;

  // A default click handler for the remove button
  // If a custom renderRemoveButton is passed in it will still be passed this method as a prop 'onClick'
  const removeClick = (layout, index, fields) => {
    remove(index);

    if (layout?.name && fields?.length === 1) {
      trigger(layout.name);
    }
  };

  const WrapperType = inline && inlineAllowed ? InlineWrapper : Wrapper;

  return (
    <>
      <Grid xs={12} {...props}>
        <AnyFieldLabel
          className="cluster-field-label"
          htmlFor={layout.name}
          label={layout.label}
          required={!!layout.required}
          disabled={layout.disabled}
          iconText={layout.iconHelperText}
          error={!!error}
          helperText={layout.helperText}
        />
        {error && <FormHelperText error={true}>{error?.message}</FormHelperText>}
      </Grid>
      <Grid
        data-what="all the clusters"
        data-cluster={`${clusterName}-container`}
        spacing={2}
        xs={12}
        sx={{ paddingTop: '0px' }}
      >
        {fields.length > 0 && (
          <>
            {
              fields.map((cluster, index) => {
                const rowProps = {
                  index,
                  control, options, layout,
                  otherProps: props,
                };

                const removeButton = removeButtonRender({ layout, remove, trigger, index, inlineAllowed, onClick: () => removeClick(layout, index, fields) });

                return <WrapperType clusterName={clusterName} clusterId={cluster.id} key={cluster.id} rows={rows} rowProps={rowProps} removeButton={removeButton} />;
              })
            }
          </>
        )}
        {fields.length === 0 && (
          <Typography data-cluster={`${clusterName}-empty-text`} variant="clusterEmptyText">{layout?.emptyText || 'You have not added any items.'}</Typography>
        )
        }
      </Grid>
      <Grid xs={12} sx={{ paddingTop: '0px' }} data-cluster={`${clusterName}-add-area`}>
        <Divider sx={{ width: '100%', marginBottom: '8px' }} />
        {addButtonRender({ layout, append, trigger, initValues, onClick: () => addClick(layout, initValues, fields) })}
      </Grid>
      {layout?.altHelperText && <FormHelperText error={false}>{layout?.altHelperText}</FormHelperText>}
    </>
  );
};

/**
 * @typedef {Object} ClusterRowWrapperProps
 * @property {string} clusterName - The name of the cluster (i.e. the name of the field)
 * @property {string} clusterId - The unique id of the cluster created by react-hook-form
 * @property {Array} rows - An array of row objects
 * @property {Object} rowProps - The props to pass to the ClusterRow component
 * @property {React.ReactElement} removeButton - The remove button to render
 */

/**
 * A wrapper for the ClusterRow component that renders the rows inline
 * @function InlineWrapper
 * @param {ClusterRowWrapperProps} props
 * @returns {React.ReactElement} - The rendered component
 */

const InlineWrapper = ({ clusterName, clusterId, rows, rowProps, removeButton }) => {
  const theme = useTheme();
  const { clusterRowRemove } = theme;
  const sx = clusterRowRemove || {
    paddingTop:2,
    paddingBottom:2,
    paddingLeft:2,
    paddingRight:0
  };

  return (
    <>
      {
        rows.map((rowItem, rIndex) => {
          return (
            <Grid container key={`${rIndex}-${clusterId}`} data-cluster={`${clusterName}-wrapper`}>
              <Grid container data-cluster={`${clusterName}-row-wrapper`} rowSpacing={1} columnSpacing={2} xs sx={{ paddingLeft: '0px', paddingRight: '0px' }}>
                <ClusterRow
                  id={clusterId}
                  clusterName={clusterName}
                  row={rowItem}
                  {...rowProps}
                />
              </Grid>
              <Grid container data-cluster={`${clusterName}-row-remove-wrapper`} rowSpacing={1} columnSpacing={2} xs sx={{ paddingLeft: '0px', paddingRight: '0px' }} style={{ maxWidth: '100px' }}>
                <Grid sx={sx} data-cluster-remove={`${clusterName}-remove`}>
                  {removeButton}
                </Grid>
              </Grid>
            </Grid>
          );
        })
      }
    </>
  );
};

const WRAPPER_PROPS = {
  clusterId: PropTypes.string,
  clusterName: PropTypes.string,
  rows: PropTypes.array,
  rowProps: PropTypes.object,
  removeButton: PropTypes.node,
};

InlineWrapper.propTypes = WRAPPER_PROPS;

/**
 * A wrapper for the ClusterRow and remove button
 * @function Wrapper
 * @param {ClusterRowWrapperProps}
 * @returns {React.ReactElement} - The rendered component
 */
const Wrapper = ({ clusterId, clusterName, rows, rowProps, removeButton }) => {
  return (
    <Grid container spacing={2} xs={12} sx={{ padding: '0px' }} key={clusterId} data-cluster={`${clusterName}-wrapper`}>
      {rows.map((rowItem, rIndex) => {
        return (
          <ClusterRow
            id={clusterId}
            clusterName={clusterName}
            row={rowItem}
            key={`${clusterId}-row-${rIndex}`}
            {...rowProps}
          />
        );
      })}
      <Grid data-cluster={`${clusterName}-row-remove-wrapper`} xs={12}>
        {removeButton}
      </Grid>
    </Grid>
  );
};

Wrapper.propTypes = WRAPPER_PROPS;


ClusterField.propTypes = {
  control: PropTypes.object,
  field: PropTypes.shape({
    type: PropTypes.number,
    render: PropTypes.object,
    subFields: PropTypes.array,
  }),
  options: PropTypes.shape({
    clusterColumnCount: PropTypes.number,
    renderAddButton: PropTypes.func,
    renderRemoveButton: PropTypes.func,
  }),
  renderAddButton: PropTypes.func,
  renderRemoveButton: PropTypes.func,
};


/**
 * ClusterRow component will render a row of fields
 * @function ClusterRow
 * @param {object} props - props object
 * @param {string} props.id - id of the cluster
 * @param {string} props.clusterName - name of the cluster
 * @param {number} props.index - index of the cluster
 * @param {object} props.row - row object
 * @param {object} props.row.fields - array of fields
 * @param {boolean} props.row.solitary - solitary boolean
 * @param {number} [props.row.size] - size number
 * @param {number} [props.row.maxColumns] - maxColumns number
 * @param {object} props.control - control object
 * @param {object} [props.options] - options object
 * @param {object} [props.layout] - layout object
 * @param {object} [props.otherProps] - otherProps object
 * @returns {React.ReactElement} - React element
 */
const ClusterRow = ({ id, clusterName, layout, row, control, index, options, otherProps }) => {
  const { fields, solitary, size, maxColumns } = row;
  let colSize = 12 / fields.length;
  if (solitary && !isNaN(size)) {
    colSize = parseInt(size);
  }

  const theme = useTheme();
  const { clusterRow } = theme;
  const sx = clusterRow || {
    paddingTop:2,
    paddingBottom:2,
    paddingLeft:1,
    paddingRight:0
  };

  return (
    <>
      {fields.map((field) => {
        // At xs size, we force one column
        // At sm size, we do not allow more than 2 columns
        // At md size and up, we allow you to specify the number of columns
        return (
          <Grid data-cluster={`${clusterName}-row`} data-cluster-row={`${clusterName}-${field.render?.name}`} sx={sx} xs={Math.max(colSize, 12)} sm={Math.max(colSize, 6)} md={colSize} key={`${id}.${field.render?.name}`}>
            <AnyField
              isNested={true}
              data-cluster={`${clusterName}-field`}
              data-cluster-field={`${clusterName}-${field.render?.name}`}
              nestedName={`${clusterName}.${index}.${field.render?.name}`}
              control={control}
              layout={field.render}
              options={options} {...otherProps}
            />
          </Grid>
        );
      })}
    </>
  );
};

ClusterRow.propTypes = {
  id: PropTypes.string,
  index: PropTypes.number,
  clusterName: PropTypes.string,
  row: PropTypes.shape({
    fields: PropTypes.array,
    solitary: PropTypes.bool,
    size: PropTypes.number,
    maxColumns: PropTypes.number,
  }),
  control: PropTypes.object,
  options: PropTypes.shape({
    clusterColumnCount: PropTypes.number,
    renderAddButton: PropTypes.func,
    renderRemoveButton: PropTypes.func,
  }),
  layout: PropTypes.object,
  otherProps: PropTypes.object,
};

/**
 * @typedef {object} RenderAddButtonProps
 * @property {object} layout - layout object
 * @property {string} layout.addLabel - label for the add button
 * @property {Function} append - append function from useFieldArray
 * @property {Function} trigger - trigger function from useFormContext
 * @property {object} initValues - initial values for the new batch of fields
 *
 */

/**
 * A component to render a cluster of fields
 * @function
 * @param {RenderAddButtonProps} props - props object
 * @returns {React.ReactElement} - React element of the button and divider
 */
const renderDefaultAddButton = ({ layout, onClick }) => {
  const { addLabel } = layout || {};

  return (
    <Button data-cluster-add={`${layout?.name}-add-button`} variant="clusterAdd" onClick={onClick}>{addLabel || '+ Add Row'}</Button>
  );
};

/**
 * Trash can icon via Heroicons
 * @function TrashCanIcon
 * @returns {React.ReactElement} - React element
 */
const TrashCanIcon = () => {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5">
      <path fillRule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clipRule="evenodd" />
    </svg>
  );
};

/**
 * @typedef {object} RenderRemoveButtonProps
 * @property {object} layout - layout object
 * @property {string} layout.removeLabel - label for the remove button
 * @property {Function} remove - remove function from useFieldArray
 * @property {Function} trigger - trigger function from useFormContext
 * @property {number} index - index of the field
 * @property {Function} onClick - onClick function for the button
 */

/**
 * The default remove button for the ClusterField component
 * @function
 * @param {RenderRemoveButtonProps} props
 * @param {boolean} props.inlineAllowed - whether the button can be rendered inline
 * @param {boolean} props.inline - whether the button is rendered inline
 * @param {Function} props.onClick - onClick function for the button
 * @param {Function} props.remove - remove function from useFieldArray
 * @param {Function} props.trigger - trigger function from useFormContext
 * @param {number} props.index - index of the field
 * @returns {React.ReactElement} - React element of the button
 */
const renderDefaultRemoveButton = ({ layout, onClick, inlineAllowed }) => {
  const { removeLabel, inline } = layout || {};
  const canInline = inlineAllowed && inline;

  let renderLabel = removeLabel || 'Remove';
  const buttonProps = { onClick };
  if (canInline) {
    buttonProps.variant = 'inlineClusterRemove';
    renderLabel = <TrashCanIcon />;
  }

  return (
    <>
      {!canInline && <Divider />}
      {canInline && <AnyFieldLabel sx={{ opacity: 0 }} label="Remove" htmlFor="" />}
      <Button data-cluster-remove={layout?.name} {...buttonProps}>{renderLabel}</Button>
    </>
  );
};

export default ClusterField;