department-of-veterans-affairs/vets-website

View on GitHub
src/platform/forms/save-in-progress/RoutedSavableApp.jsx

Summary

Maintainability
C
1 day
Test Coverage
import React from 'react';
import PropTypes from 'prop-types';
import Scroll from 'react-scroll';
import { withRouter } from 'react-router';
import { connect } from 'react-redux';

import FormApp from 'platform/forms-system/src/js/containers/FormApp';
import {
  getNextPagePath,
  checkValidPagePath,
} from 'platform/forms-system/src/js/routing';

import scrollToTop from 'platform/utilities/ui/scrollToTop';
import environment from 'platform/utilities/environment';
import { getScrollOptions } from 'platform/utilities/ui';
import { restartShouldRedirect } from 'platform/site-wide/wizard';
import {
  LOAD_STATUSES,
  PREFILL_STATUSES,
  SAVE_STATUSES,
  setFetchFormStatus,
  fetchInProgressForm,
} from './actions';

import { isInProgressPath } from '../helpers';
import { getSaveInProgressState } from './selectors';
import { APP_TYPE_DEFAULT } from '../../forms-system/src/js/constants';

const { Element } = Scroll;

/*
 * Primary component for a schema generated form app.
 */
class RoutedSavableApp extends React.Component {
  constructor(props) {
    super(props);
    this.FormApp = props.FormApp || FormApp;
    this.location = props.location || window.location;
  }

  /* eslint-disable-next-line camelcase */
  UNSAFE_componentWillMount() {
    window.addEventListener('beforeunload', this.onbeforeunload);
    if (window.History) {
      window.History.scrollRestoration = 'manual';
    }

    // If we start in the middle of a form, redirect to the beginning or load
    //  saved form / prefill
    // If we're in production, we'll redirect if we start in the middle of a form
    // In development, we won't redirect unless we append the URL with `?redirect`
    const { currentLocation, formConfig } = this.props;
    const { additionalRoutes = [] } = formConfig;
    const additionalSafePaths =
      additionalRoutes && additionalRoutes.map(route => route.path);
    const trimmedPathname = currentLocation.pathname.replace(/\/$/, '');
    const resumeForm = trimmedPathname.endsWith('resume');
    const devRedirect =
      (!environment.isLocalhost() &&
        !currentLocation.search.includes('skip')) ||
      currentLocation.search.includes('redirect');
    const goToStartPage = resumeForm || devRedirect;
    if (
      isInProgressPath(currentLocation.pathname, additionalSafePaths) &&
      goToStartPage
    ) {
      // We started on a page that isn't the first, so after we know whether
      //  we're logged in or not, we'll load or redirect as needed.
      this.shouldRedirectOrLoad = true;
    }
  }

  componentDidMount() {
    // When a user isn't logged in, the profile finishes loading before the component mounts
    if (!this.props.profileIsLoading && this.shouldRedirectOrLoad) {
      this.redirectOrLoad(this.props);
    }
  }

  /* eslint-disable-next-line camelcase */
  UNSAFE_componentWillReceiveProps(newProps) {
    // When a user is logged in, the profile finishes loading after the component
    //  has mounted, so we check here.
    // If we're done loading the profile, check to see if we should load or redirect
    if (
      this.props.profileIsLoading &&
      !newProps.profileIsLoading &&
      this.shouldRedirectOrLoad
    ) {
      this.redirectOrLoad(newProps);
    }

    const status = newProps.loadedStatus;
    if (
      status === LOAD_STATUSES.success &&
      newProps.currentLocation &&
      newProps.currentLocation.pathname.endsWith('resume')
    ) {
      newProps.router.replace(newProps.returnUrl);
    } else if (status === LOAD_STATUSES.success) {
      if (newProps.formConfig.onFormLoaded) {
        // The onFormLoaded callback should handle navigating to the start of the form
        newProps.formConfig.onFormLoaded(newProps);
      } else {
        // Check that returnUrl is an active page. If not, return to first page
        // after intro page
        const isValidReturnUrl = checkValidPagePath(
          newProps.routes[newProps.routes.length - 1].pageList,
          newProps.formData,
          newProps.returnUrl,
        );
        newProps.router.push(
          isValidReturnUrl
            ? newProps.returnUrl
            : this.getFirstNonIntroPagePath(newProps),
        );
      }
      // Set loadedStatus in redux to not-attempted to not show the loading page
      newProps.setFetchFormStatus(LOAD_STATUSES.notAttempted);
    } else if (
      newProps.prefillStatus !== this.props.prefillStatus &&
      newProps.prefillStatus === PREFILL_STATUSES.unfilled
    ) {
      let newRoute;
      const { formConfig = {} } = newProps;
      const { saveInProgress = {} } = formConfig;
      if (
        newProps.isStartingOver &&
        typeof saveInProgress.restartFormCallback === 'function' &&
        restartShouldRedirect(formConfig.wizardStorageKey)
      ) {
        // Restart callback returns a new route
        newRoute = saveInProgress?.restartFormCallback();
      }

      // Form restart redirects to new route or the first page after the intro
      newProps.router.push(newRoute || this.getFirstNonIntroPagePath(newProps));
    } else if (
      status !== LOAD_STATUSES.notAttempted &&
      status !== LOAD_STATUSES.pending &&
      status !== this.props.loadedStatus &&
      !this.location.pathname.endsWith('/error')
    ) {
      let action = 'push';
      if (this.location.pathname.endsWith('resume')) {
        action = 'replace';
      }
      newProps.router[action](`${newProps.formConfig.urlPrefix || ''}error`);
    }
  }

  // should scroll up to top while user is waiting for form to load or save
  componentDidUpdate(oldProps) {
    if (
      (oldProps.loadedStatus !== this.props.loadedStatus &&
        this.props.loadedStatus === LOAD_STATUSES.pending) ||
      (oldProps.savedStatus !== this.props.savedStatus &&
        this.props.savedStatus === SAVE_STATUSES.pending)
    ) {
      scrollToTop('topScrollElement', getScrollOptions());
    }

    if (
      this.props.savedStatus !== oldProps.savedStatus &&
      this.props.savedStatus === SAVE_STATUSES.success
    ) {
      this.props.router.push(
        `${this.props.formConfig.urlPrefix || ''}form-saved`,
      );
    }
  }

  // I’m not convinced this is ever executed
  componentWillUnmount() {
    this.removeOnbeforeunload();
  }

  onbeforeunload = e => {
    const { currentLocation = {}, autoSavedStatus, formConfig } = this.props;

    const isCypressRunningInCI =
      typeof Cypress !== 'undefined' && Cypress.env('CI');

    // Disable browser window unload alert. This may prevent 40 minute timeout
    // errors in CI
    if (
      formConfig.dev?.disableWindowUnloadInCI &&
      (isCypressRunningInCI ||
        currentLocation.href?.startsWith('http://localhost'))
    ) {
      return null;
    }

    const { additionalRoutes = [] } = formConfig;
    const appType = formConfig?.customText?.appType || APP_TYPE_DEFAULT;
    const trimmedPathname = currentLocation.pathname.replace(/\/$/, '');
    const additionalSafePaths = additionalRoutes.map(route => route.path);
    let message;
    if (
      autoSavedStatus !== SAVE_STATUSES.success &&
      isInProgressPath(trimmedPathname, additionalSafePaths)
    ) {
      message = `Are you sure you wish to leave this ${appType}? All progress will be lost.`;
      // Chrome requires this to be set
      e.returnValue = message; // eslint-disable-line no-param-reassign
    }
    return message;
  };

  // eslint-disable-next-line class-methods-use-this
  getFirstNonIntroPagePath(props) {
    return getNextPagePath(
      props.routes[props.routes.length - 1].pageList,
      props.formData,
      `${props.formConfig?.urlPrefix || '/'}introduction`,
    );
  }

  removeOnbeforeunload = () => {
    window.removeEventListener('beforeunload', this.onbeforeunload);
  };

  redirectOrLoad(props) {
    // Stop a user that's been redirected from being redirected again after
    // logging in
    this.shouldRedirectOrLoad = false;

    const firstPagePath =
      props.routes[props.routes.length - 1].pageList[0].path;
    const firstNonIntroPagePath = this.getFirstNonIntroPagePath(props);
    // If we're logged in and have a saved / pre-filled form, load that
    if (props.isLoggedIn) {
      const currentForm = props.formConfig.formId;
      const isSaved = props.savedForms.some(
        savedForm => savedForm.form === currentForm,
      );
      const hasPrefillData = props.prefillsAvailable.includes(currentForm);

      if (isSaved) {
        props.fetchInProgressForm(
          currentForm,
          props.formConfig.migrations,
          false,
          props.formConfig.prefillTransformer,
        );
      } else if (props.skipPrefill) {
        // Just need to go to the page after the introduction
        props.router.replace(firstNonIntroPagePath);
      } else if (hasPrefillData) {
        props.fetchInProgressForm(
          currentForm,
          props.formConfig.migrations,
          true,
          props.formConfig.prefillTransformer,
        );
      } else {
        // No forms to load; go to the beginning
        // If the first page is not the intro and uses `depends`, this will probably break
        props.router.replace(firstPagePath);
      }
    } else {
      // Can't load a form; go to the beginning
      // If the first page is not the intro and uses `depends`, this will probably break
      props.router.replace(firstPagePath);
    }
  }

  render() {
    const { currentLocation, formConfig, children, loadedStatus } = this.props;
    const appType = formConfig?.customText?.appType || APP_TYPE_DEFAULT;
    const trimmedPathname = currentLocation.pathname.replace(/\/$/, '');
    let content;
    const loadingForm =
      trimmedPathname.endsWith('resume') ||
      loadedStatus === LOAD_STATUSES.pending;
    if (
      (!formConfig.disableSave &&
        loadingForm &&
        this.props.prefillStatus === PREFILL_STATUSES.pending) ||
      (!formConfig.disableSave && this.shouldRedirectOrLoad)
    ) {
      content = (
        <va-loading-indicator
          label="Loading"
          message="Retrieving your profile information..."
        />
      );
    } else if (!formConfig.disableSave && loadingForm) {
      content = (
        <va-loading-indicator
          label="Loading"
          message={`Retrieving your saved ${appType}...`}
        />
      );
    } else if (
      !formConfig.disableSave &&
      this.props.savedStatus === SAVE_STATUSES.pending
    ) {
      content = (
        <va-loading-indicator
          label="Loading"
          message={`Saving your ${appType}...`}
        />
      );
    } else {
      content = (
        <this.FormApp formConfig={formConfig} currentLocation={currentLocation}>
          {children}
        </this.FormApp>
      );
    }

    return (
      <div>
        <Element name="topScrollElement" />
        {content}
      </div>
    );
  }
}

const mapDispatchToProps = {
  setFetchFormStatus,
  fetchInProgressForm,
};

export default withRouter(
  connect(
    getSaveInProgressState,
    mapDispatchToProps,
  )(RoutedSavableApp),
);

RoutedSavableApp.propTypes = {
  FormApp: PropTypes.any,
  autoSavedStatus: PropTypes.string,
  children: PropTypes.any,
  currentLocation: PropTypes.shape({
    href: PropTypes.string,
    pathname: PropTypes.string,
    search: PropTypes.string,
  }),
  formConfig: PropTypes.shape({
    additionalRoutes: PropTypes.array,
    customText: PropTypes.shape({
      appType: PropTypes.string,
    }),
    dev: PropTypes.shape({
      disableWindowUnloadInCI: PropTypes.bool,
    }),
    disableSave: PropTypes.bool,
    urlPrefix: PropTypes.string,
  }),
  loadedStatus: PropTypes.string,
  location: PropTypes.object,
  prefillStatus: PropTypes.string,
  profileIsLoading: PropTypes.bool,
  router: PropTypes.shape({
    push: PropTypes.func,
  }),
  savedStatus: PropTypes.string,
};

export { RoutedSavableApp };