department-of-veterans-affairs/vets-website

View on GitHub
src/applications/appeals/shared/components/ContestableIssues.jsx

Summary

Maintainability
D
2 days
Test Coverage
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { VaModal } from '@department-of-veterans-affairs/component-library/dist/react-bindings';

import set from 'platform/utilities/data/set';
import { setData } from 'platform/forms-system/src/js/actions';
import { focusElement, scrollTo } from 'platform/utilities/ui';

import { LAST_ISSUE, MAX_LENGTH, REVIEW_ISSUES, SELECTED } from '../constants';
import { FETCH_CONTESTABLE_ISSUES_FAILED } from '../actions';
import { IssueCard } from './IssueCard';
import {
  ContestableIssuesLegend,
  ApiFailureAlert,
  NoEligibleIssuesAlert,
  NoneSelectedAlert,
  MaxSelectionsAlert,
  removeModalContent,
} from '../content/contestableIssues';
import { focusIssue } from '../utils/focus';
import {
  calculateIndexOffset,
  getSelected,
  someSelected,
} from '../utils/issues';
import { isEmptyObject } from '../utils/helpers';

/**
 * ContestableIssues - Form system parameters passed into this widget
 * @param {Boolean} autofocus - should auto focus
 * @param {Boolean} disabled -  is disabled?
 * @param {Object} formContext -  state
 * @param {Object} formData - All form data
 * @param {String} id - ID base for form elements
 * @param {String} label - label text
 * @param {func} onBlur - blur callback
 * @param {Object} options - ui:options
 * @param {String} placeholder - placeholder text
 * @param {Boolean} readonly - readonly state
 * @param {Object} registry - contains definitions, fields, widgets & templates
 * @param {Boolean} required - Show required flag
 * @param {Object} schema - array schema
 * @return {JSX}
 */
const ContestableIssues = props => {
  const {
    id,
    options,
    formContext = {},
    setFormData,
    formData,
    apiLoadStatus,
  } = props;

  const [showErrorModal, setShowErrorModal] = useState(false);
  const [showRemoveModal, setShowRemoveModal] = useState(false);
  const [removeIndex, setRemoveIndex] = useState(null);
  const [editState] = useState(window.sessionStorage.getItem(LAST_ISSUE));

  useEffect(
    () => {
      if (editState) {
        focusIssue();
      }
    },
    [editState],
  );

  const onReviewPage = formContext?.onReviewPage || false;
  window.sessionStorage.setItem(REVIEW_ISSUES, onReviewPage);

  // inReviewMode = true (review page view, not in edit mode)
  // inReviewMode = false (in edit mode)
  const inReviewMode = (onReviewPage && formContext.reviewMode) || false;
  const showCheckbox = !onReviewPage || (onReviewPage && !inReviewMode);
  const { submitted } = formContext;
  const loadedIssues = formData.contestedIssues || [];

  // combine all issues for viewing
  const items = loadedIssues
    .map(item => ({
      ...item?.attributes,
      [SELECTED]: item?.[SELECTED],
    }))
    .concat(formData.additionalIssues || []);

  const hasSelected = someSelected(items);
  const showApiFailure =
    !submitted &&
    !onReviewPage &&
    apiLoadStatus === FETCH_CONTESTABLE_ISSUES_FAILED;
  const showNoneSelected =
    !submitted && !onReviewPage && formData.contestedIssues?.length === 0;
  const showEditModeError =
    !showNoneSelected && !hasSelected && (onReviewPage || submitted);

  useEffect(
    () => {
      if (showEditModeError) {
        focusElement(`va-alert[status="error"] h${onReviewPage ? 4 : 3}`);
        scrollTo('va-alert[status="error"]');
      }
    },
    [onReviewPage, showEditModeError, submitted],
  );

  if (onReviewPage && inReviewMode && items.length && !hasSelected) {
    return (
      <NoneSelectedAlert
        count={items.length}
        headerLevel={5}
        inReviewMode={inReviewMode}
      />
    );
  }

  const handlers = {
    closeModal: () => setShowErrorModal(false),
    onChange: (index, event) => {
      let { checked } = event.target;
      if (checked && getSelected(formData).length + 1 > MAX_LENGTH.SELECTIONS) {
        setShowErrorModal(true);
        event.preventDefault(); // prevent checking
        checked = false;
      } else if (index < loadedIssues.length) {
        // contestable issue check toggle
        const changedItems = set(
          ['contestedIssues', index, SELECTED],
          checked,
          formData,
        );
        // only changes value (equal to formData.contestedIssues)
        setFormData(changedItems);
      } else {
        // additional issue check toggle
        const adjustedIndex = calculateIndexOffset(index, loadedIssues.length);
        const updatedAdditionalIssues = formData.additionalIssues.map(
          (issue, indx) =>
            adjustedIndex === indx ? { ...issue, [SELECTED]: checked } : issue,
        );
        setFormData({
          ...formData,
          additionalIssues: updatedAdditionalIssues,
        });
      }
    },
    onShowRemoveModal: cardIndex => {
      const adjustedIndex = calculateIndexOffset(
        cardIndex,
        loadedIssues.length,
      );
      setRemoveIndex(adjustedIndex);
      setShowRemoveModal(true);
    },
    onRemoveModalClose: () => {
      focusIssue(
        null,
        null,
        `${loadedIssues.length + removeIndex},remove-cancel`,
      );
      setShowRemoveModal(false);
      setRemoveIndex(null);
    },
    onRemoveIssue: () => {
      const updatedAdditionalIssues = formData.additionalIssues.filter(
        (issue, indx) => removeIndex !== indx,
      );
      setShowRemoveModal(false);
      setRemoveIndex(null);
      // setTimeout needed to allow rerender
      setTimeout(() => {
        focusIssue(null, null, -1);
      });

      setFormData({
        ...formData,
        additionalIssues: updatedAdditionalIssues,
      });
    },
  };

  const content = items.map((item, index) => {
    const itemIsSelected = !!item?.[SELECTED];
    const hideCard = (inReviewMode && !itemIsSelected) || isEmptyObject(item);

    const cardProps = {
      id,
      index,
      item,
      key: index,
      options,
      showCheckbox,
      onReviewPage,
      // props.testChange for testing
      onChange: props.testChange || handlers.onChange,
      onRemove: handlers.onShowRemoveModal,
    };

    // Don't show un-selected ratings in review mode
    return hideCard ? null : <IssueCard {...cardProps} />;
  });

  return (
    <>
      <div name="eligibleScrollElement" />
      {showApiFailure && <ApiFailureAlert />}
      {showNoneSelected && !showApiFailure && <NoEligibleIssuesAlert />}
      {showEditModeError && (
        <NoneSelectedAlert
          count={formData.contestedIssues?.length || 0}
          headerLevel={onReviewPage ? 4 : 3}
          inReviewMode={inReviewMode}
        />
      )}
      <fieldset className="review-fieldset">
        <ContestableIssuesLegend
          onReviewPage={onReviewPage}
          inReviewMode={inReviewMode}
        />
        <VaModal
          clickToClose
          status="warning"
          modalTitle={removeModalContent.title}
          primaryButtonText={removeModalContent.yesButton}
          secondaryButtonText={removeModalContent.noButton}
          onCloseEvent={handlers.onRemoveModalClose}
          onPrimaryButtonClick={handlers.onRemoveIssue}
          onSecondaryButtonClick={handlers.onRemoveModalClose}
          visible={showRemoveModal}
          uswds
        >
          <p>
            {removeIndex !== null
              ? removeModalContent.description(
                  formData.additionalIssues[removeIndex].issue,
                )
              : null}
          </p>
        </VaModal>
        <ul className="issues remove-bullets vads-u-border-top--1px vads-u-border-color--gray-light">
          {content}
        </ul>
        {onReviewPage && inReviewMode ? null : (
          <Link
            className="add-new-issue vads-c-action-link--green"
            to={{
              pathname: '/add-issue',
              search: `?index=${items.length}`,
            }}
          >
            Add a new issue
          </Link>
        )}
        {showErrorModal && (
          <MaxSelectionsAlert
            showModal
            closeModal={handlers.closeModal}
            appName={props.appName}
          />
        )}
      </fieldset>
    </>
  );
};

ContestableIssues.propTypes = {
  additionalIssues: PropTypes.array,
  apiLoadStatus: PropTypes.string,
  appName: PropTypes.string,
  formContext: PropTypes.shape({
    onReviewPage: PropTypes.bool,
    reviewMode: PropTypes.bool,
    submitted: PropTypes.bool,
  }),
  formData: PropTypes.shape({
    contestedIssues: PropTypes.array,
    additionalIssues: PropTypes.array,
  }),
  id: PropTypes.string,
  options: PropTypes.shape({}),
  setFormData: PropTypes.func,
  testChange: PropTypes.func,
};

const mapStateToProps = state => ({
  apiLoadStatus: state.contestableIssues?.status || '',
  formData: state.form?.data || {},
});

const mapDispatchToProps = {
  setFormData: setData,
};

export { ContestableIssues };

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(ContestableIssues);