department-of-veterans-affairs/vets-website

View on GitHub
src/applications/simple-forms/40-0247/components/FileField.jsx

Summary

Maintainability
F
2 wks
Test Coverage
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
/* Customized copy of Platform's FileField component
  * - Adds new optional prop `ariaLabelAdditionalText` to append additional
  *   text to the upload button's aria-label attribute.
*/
import PropTypes from 'prop-types';
import React, { useEffect, useState, useRef } from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { VaModal } from '@department-of-veterans-affairs/component-library/dist/react-bindings';

import { toggleValues } from 'platform/site-wide/feature-toggles/selectors';
import get from 'platform/utilities/data/get';
import set from 'platform/utilities/data/set';
import unset from 'platform/utilities/data/unset';
import {
  displayFileSize,
  focusElement,
  scrollTo,
  scrollToFirstError,
} from 'platform/utilities/ui';

import { FILE_UPLOAD_NETWORK_ERROR_MESSAGE } from 'platform/forms-system/src/js/constants';
import { $ } from 'platform/forms-system/src/js/utilities/ui';
import {
  ShowPdfPassword,
  PasswordLabel,
  PasswordSuccess,
  readAndCheckFile,
  checkTypeAndExtensionMatches,
  checkIsEncryptedPdf,
  FILE_TYPE_MISMATCH_ERROR,
} from 'platform/forms-system/src/js/utilities/file';
import { usePreviousValue } from 'platform/forms-system/src/js/helpers';

/**
 * Modal content callback
 * @typedef ModalContent
 * @type {function}
 * @property {string} fileName - name of file to be removed
 * @returns {JSX} - default='<span>We’ll delete the uploaded file
 *  <strong>{fileName}</strong></span>'
 */
/**
 * UI options used in FileField
 * @typedef uiOptions
 * @type {object}
 * @property {string} buttonText='Upload' - upload button text
 * @property {string} addAnotherLabel='Upload another' - upload another text,
 *  replaces upload button text when greater than one upload is showing
 * @property {string} ariaLabelAdditionalText='' - additional screen-reader text to be appended to upload button's aria-label attribute.
 * @property {string} tryAgain='Try again' - button in enableShortWorkflow
 * @property {string} newFile='Upload a new file' - button in enableShortWorkflow
 * @property {string} cancel='Cancel' - button visible while uploading & in enableShortWorkflow
 * @property {string} delete='Delete file' - delete button text
 * @property {string} modalTitle='Are you sure you want to delete this file?' -
 *  delete confirmation modal title
 * @property {ModalContent} modalContent - delete confirmation modal content
 * @property {string} yesButton='Yes, delete this' - modal Yes button text
 * @property {string} noButton='No, keep this' - modal No button text
 */
/**
 * FormData of supported files
 * @typeof Files
 * @type {object}
 * @property {string} name - file name
 * @property {boolean} uploading - flag indicating that an upload is in
 *  progress
 * @property {string} confirmationCode - uuid of uploaded file
 * @property {string} attachmentId - form ID set by user
 * @property {string} errorMessage - error message string returned from API
 * @property {boolean} isEncrypted - (Encrypted PDF only; pre-upload only)
 *  encrypted state of the file
 * @property {DOMFileObject} file - (Encrypted PDF only) File object, used
 *  when user submits password
 */
const FileField = props => {
  const {
    enableShortWorkflow,
    errorSchema,
    formContext,
    formData = [],
    idSchema,
    onBlur,
    onChange,
    registry,
    schema,
    uiSchema,
  } = props;

  const files = formData || [];
  const [progress, setProgress] = useState(0);
  const [uploadRequest, setUploadRequest] = useState(null);
  const [isUploading, setIsUploading] = useState(
    files.some(file => file.uploading),
  );
  const [showRemoveModal, setShowRemoveModal] = useState(false);
  const [removeIndex, setRemoveIndex] = useState(null);
  const [initialized, setInitialized] = useState(false);

  const previousValue = usePreviousValue(formData);
  const fileInputRef = useRef(null);
  const fileButtonRef = useRef(null);

  const uiOptions = uiSchema?.['ui:options'];

  const maxItems = schema.maxItems || Infinity;
  const { SchemaField } = registry.fields;
  const attachmentIdRequired = schema.additionalItems.required
    ? schema.additionalItems.required.includes('attachmentId')
    : false;
  const uswds = uiOptions.uswds || null;

  const content = {
    upload: uiOptions.buttonText || 'Upload',
    uploadAnother: uiOptions.addAnotherLabel || 'Upload another',
    ariaLabelAdditionalText: uiOptions.ariaLabelAdditionalText || '',
    passwordLabel: fileName => `Add a password for ${fileName}`,
    tryAgain: 'Try again',
    tryAgainLabel: fileName => `Try uploading ${fileName} again`,
    newFile: 'Upload a new file',
    cancel: 'Cancel',
    cancelLabel: fileName => `Cancel upload of ${fileName}`,
    delete: 'Delete file',
    deleteLabel: fileName => `Delete ${fileName}`,
    modalTitle:
      uiOptions.modalTitle || 'Are you sure you want to delete this file?',
    modalContent: fileName =>
      uiOptions.modalContent?.(fileName || 'Unknown') || (
        <span>
          We’ll delete the uploaded file{' '}
          <strong>{fileName || 'Unknown'}</strong>
        </span>
      ),
    yesButton: 'Yes, delete this file',
    noButton: 'No, keep this',
    error: 'Error',
  };

  const Tag = formContext.onReviewPage && formContext.reviewMode ? 'dl' : 'div';

  // hide upload & delete buttons on review & submit page when reviewing
  const showButtons = !formContext.reviewMode && !isUploading;

  const titleString =
    typeof uiSchema['ui:title'] === 'string'
      ? uiSchema['ui:title']
      : schema.title;

  const getFileListId = index => `${idSchema.$id}_file_${index}`;

  // This is always true if enableShortWorkflow is not enabled
  // If enabled, do not allow upload if any error exist
  const checkUploadVisibility = () =>
    !enableShortWorkflow ||
    (enableShortWorkflow &&
      !files.some((file, index) => {
        const errors =
          errorSchema?.[index]?.__errors ||
          [file.errorMessage].filter(error => error);

        return errors.length > 0;
      }));

  const focusAddAnotherButton = () => {
    // Add a timeout to allow for the upload button to reappear in the DOM
    // before trying to focus on it
    setTimeout(() => {
      // focus on upload button, not the label
      focusElement(
        // including `#upload-button` because RTL can't access the shadowRoot
        'button, #upload-button',
        {},
        $(`#upload-button`)?.shadowRoot,
      );
    }, 100);
  };

  const updateProgress = percent => {
    setProgress(percent);
  };

  useEffect(
    () => {
      const prevFiles = previousValue || [];
      fileButtonRef?.current?.classList?.toggle(
        'vads-u-display--none',
        !checkUploadVisibility(),
      );
      if (initialized && files.length !== prevFiles.length) {
        focusAddAnotherButton();
      }

      const hasUploading = files.some(file => file.uploading);
      const wasUploading = prevFiles.some(file => file.uploading);
      setIsUploading(hasUploading);
      if (hasUploading && !wasUploading) {
        setProgress(0);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [formData],
  );

  useEffect(
    () => {
      // The File object is not preserved in the save-in-progress data
      // We need to remove these entries; an empty `file` is included in the
      // entry, but if API File Object still exists (within the same session), we
      // can't use Object.keys() on it because it returns an empty array
      const newData = files.filter(
        // keep - file may not exist (already uploaded)
        // keep - file may contain File object; ensure name isn't empty
        // remove - file may be an empty object
        data => !data.file || (data.file?.name || '') !== '',
      );
      if (newData.length !== files.length) {
        onChange(newData);
      }
      setInitialized(true);
    },
    [files, onChange],
  );

  /**
   * Add file to list and upload
   * @param {Event} event - DOM File upload event
   * @param {number} index - uploaded file index, if already uploaded
   * @param {string} password - encrypted PDF password, only defined by
   *   `onSubmitPassword` function
   * @listens
   */
  const onAddFile = async (event, index = null, password) => {
    if (event.target?.files?.length) {
      const currentFile = event.target.files[0];
      const allFiles = props.formData || [];
      const addUiOptions = props.uiSchema['ui:options'];
      // needed for FileField unit tests
      const { mockReadAndCheckFile } = uiOptions;

      let idx = index;
      if (idx === null) {
        idx = allFiles.length === 0 ? 0 : allFiles.length;
      }

      let checkResults;
      const checks = { checkTypeAndExtensionMatches, checkIsEncryptedPdf };

      if (currentFile.type === 'testing') {
        // Skip read file for Cypress testing
        checkResults = {
          checkTypeAndExtensionMatches: true,
          checkIsEncryptedPdf: false,
        };
      } else {
        // read file mock for unit testing
        checkResults =
          typeof mockReadAndCheckFile === 'function'
            ? mockReadAndCheckFile()
            : await readAndCheckFile(currentFile, checks);
      }

      // 20MB in bytes
      const BYTES_LIMIT = 20 * 1024 * 1024;

      // Check to see if the file size is greater than 20MB
      if (currentFile.size > BYTES_LIMIT) {
        allFiles[idx] = {
          file: currentFile,
          name: currentFile.name,
          errorMessage: 'File size must not be greater than 20.0 MB.',
        };
        props.onChange(allFiles);
        return;
      }

      if (!checkResults.checkTypeAndExtensionMatches) {
        allFiles[idx] = {
          file: currentFile,
          name: currentFile.name,
          errorMessage: FILE_TYPE_MISMATCH_ERROR,
        };
        props.onChange(allFiles);
        return;
      }

      // Check if the file is an encrypted PDF
      if (
        currentFile.name?.endsWith('pdf') &&
        !password &&
        checkResults.checkIsEncryptedPdf
      ) {
        allFiles[idx] = {
          file: currentFile,
          name: currentFile.name,
          isEncrypted: true,
        };

        props.onChange(allFiles);
        // wait for user to enter a password before uploading
        return;
      }

      setUploadRequest(
        props.formContext.uploadFile(
          currentFile,
          addUiOptions,
          updateProgress,
          file => {
            // formData is undefined initially
            const newData = props.formData || [];
            newData[idx] = { ...file, isEncrypted: !!password };
            onChange(newData);
            // Focus on the 'Cancel' button when a file is being uploaded
            if (file.uploading) {
              $('.schemaform-file-uploading .cancel-upload')?.focus();
            }
            // Focus on the file card after the file has finished uploading
            if (!file.uploading) {
              $(getFileListId(idx))?.focus();
            }
            setUploadRequest(null);
          },
          () => {
            setUploadRequest(null);
          },
          formContext.trackingPrefix,
          password,
          props.enableShortWorkflow,
        ),
      );
    }
  };

  const onSubmitPassword = (file, index, password) => {
    if (file && password) {
      onAddFile({ target: { files: [file] } }, index, password);
    }
  };

  const onAttachmentIdChange = (index, value) => {
    if (!value) {
      props.onChange(unset([index, 'attachmentId'], props.formData));
    } else {
      props.onChange(set([index, 'attachmentId'], value, props.formData));
    }
  };

  const onAttachmentNameChange = (index, value) => {
    if (!value) {
      props.onChange(unset([index, 'name'], props.formData));
    } else {
      props.onChange(set([index, 'name'], value, props.formData));
    }
  };

  const removeFile = (index, focusAddButton = true) => {
    const newFileList = props.formData.filter((__, idx) => index !== idx);
    if (!newFileList.length) {
      props.onChange();
    } else {
      props.onChange(newFileList);
    }

    // clear file input value; without this, the user won't be able to open the
    // upload file window
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }

    // When other actions follow removeFile, we do not want to apply this focus
    if (focusAddButton) {
      focusAddAnotherButton();
    }
  };

  const openRemoveModal = index => {
    setRemoveIndex(index);
    setShowRemoveModal(true);
  };

  const closeRemoveModal = ({ remove = false } = {}) => {
    const idx = removeIndex;
    setRemoveIndex(null);
    setShowRemoveModal(false);
    if (remove) {
      removeFile(idx);
    } else {
      setTimeout(() => {
        focusElement(
          'button, .delete-upload',
          {},
          $(`#${getFileListId(idx)} .delete-upload`)?.shadowRoot,
        );
      });
    }
  };

  const cancelUpload = index => {
    if (uploadRequest) {
      uploadRequest.abort();
    }
    removeFile(index);
  };

  const retryLastUpload = (index, file) => {
    onAddFile({ target: { files: [file] } }, index);
  };

  const deleteThenAddFile = index => {
    removeFile(index, false);
    fileInputRef.current?.click();
  };

  const getRetryFunction = (allowRetry, index, file) => {
    return allowRetry
      ? () => retryLastUpload(index, file)
      : () => deleteThenAddFile(index);
  };

  const uploadText = content[files.length > 0 ? 'uploadAnother' : 'upload'];

  return (
    <div
      className={
        formContext.reviewMode ? 'schemaform-file-upload-review' : undefined
      }
    >
      <VaModal
        clickToClose
        status="warning"
        modalTitle="Are you sure you want to delete this file?"
        primaryButtonText={content.yesButton}
        secondaryButtonText={content.noButton}
        onCloseEvent={closeRemoveModal}
        onPrimaryButtonClick={() => closeRemoveModal({ remove: true })}
        onSecondaryButtonClick={closeRemoveModal}
        visible={showRemoveModal}
        uswds={uswds}
      >
        <p>
          {removeIndex !== null
            ? content.modalContent(files[removeIndex]?.name)
            : null}
        </p>
      </VaModal>
      {files.length > 0 && (
        <ul className="schemaform-file-list">
          {files.map((file, index) => {
            const errors =
              errorSchema?.[index]?.__errors ||
              [file.errorMessage].filter(error => error);
            const hasErrors = errors.length > 0;
            const itemClasses = classNames('va-growable-background', {
              'schemaform-file-error usa-input-error':
                hasErrors && !file.uploading,
            });
            const itemSchema = schema.items[index];
            const attachmentIdSchema = {
              $id: `${idSchema.$id}_${index}_attachmentId`,
            };
            const attachmentNameSchema = {
              $id: `${idSchema.$id}_${index}_attachmentName`,
            };
            const attachmentIdErrors = get(
              [index, 'attachmentId'],
              errorSchema,
            );
            const attachmentNameErrors = get([index, 'name'], errorSchema);
            const showPasswordInput =
              file.isEncrypted && !file.confirmationCode;
            const showPasswordSuccess =
              file.isEncrypted && file.confirmationCode;
            const description =
              (!file.uploading && uiOptions.itemDescription) || '';

            const fileListId = getFileListId(index);
            const fileNameId = `${idSchema.$id}_file_name_${index}`;

            if (hasErrors) {
              setTimeout(() => {
                scrollToFirstError();
                if (enableShortWorkflow) {
                  const retryButton = $(`[name="retry_upload_${index}"]`);
                  if (retryButton) {
                    focusElement('button', {}, retryButton?.shadowRoot);
                  }
                } else if (showPasswordInput) {
                  focusElement(`#${fileListId} .usa-input-error-message`);
                } else {
                  focusElement('.usa-input-error, .input-error-date, [error]');
                }
              }, 250);
            } else if (showPasswordInput) {
              setTimeout(() => {
                const passwordInput = $(`[name="get_password_${index}"]`);
                if (passwordInput) {
                  focusElement('input', {}, passwordInput?.shadowRoot);
                  scrollTo(`get_password_${index}"]`);
                }
              }, 100);
            }

            const allowRetry = errors[0] === FILE_UPLOAD_NETWORK_ERROR_MESSAGE;

            const retryButtonText =
              content[allowRetry ? 'tryAgain' : 'newFile'];
            const deleteButtonText =
              content[enableShortWorkflow && hasErrors ? 'cancel' : 'delete'];

            const getUiSchema = innerUiSchema =>
              typeof innerUiSchema === 'function'
                ? innerUiSchema({ fileId: fileNameId, index })
                : innerUiSchema;

            // make index available to widgets in attachment ui schema
            const indexedRegistry = {
              ...registry,
              formContext: {
                ...registry.formContext,
                pagePerItemIndex: index,
              },
            };

            return (
              <li key={index} id={fileListId} className={itemClasses}>
                {file.uploading && (
                  <div className="schemaform-file-uploading">
                    <strong
                      id={fileNameId}
                      className="dd-privacy-hidden"
                      data-dd-action-name="file name"
                    >
                      {file.name}
                    </strong>
                    <br />
                    {/* no USWDS v3 "activity progress bar" */}
                    <va-progress-bar percent={progress} />
                    <va-button
                      secondary
                      class="cancel-upload vads-u-width--auto"
                      onClick={() => {
                        cancelUpload(index);
                      }}
                      label={content.cancelLabel(file.name)}
                      text={content.cancel}
                      uswds={uswds}
                    />
                  </div>
                )}
                {description && <p>{description}</p>}
                {!file.uploading && (
                  <>
                    <strong
                      id={fileNameId}
                      className="dd-privacy-hidden"
                      data-dd-action-name="file name"
                    >
                      {file.name}
                    </strong>
                    {file?.size && <div> {displayFileSize(file.size)}</div>}
                  </>
                )}
                {(showPasswordInput || showPasswordSuccess) && (
                  <PasswordLabel />
                )}
                {showPasswordSuccess && <PasswordSuccess />}
                {!hasErrors &&
                  !showPasswordInput &&
                  get('properties.attachmentId', itemSchema) && (
                    <Tag className="schemaform-file-attachment review">
                      <SchemaField
                        name="attachmentId"
                        required={attachmentIdRequired}
                        schema={itemSchema.properties.attachmentId}
                        uiSchema={getUiSchema(uiOptions.attachmentSchema)}
                        errorSchema={attachmentIdErrors}
                        idSchema={attachmentIdSchema}
                        formData={formData[index].attachmentId}
                        onChange={value => onAttachmentIdChange(index, value)}
                        onBlur={onBlur}
                        registry={indexedRegistry}
                        disabled={props.disabled}
                        readonly={props.readonly}
                      />
                    </Tag>
                  )}
                {!hasErrors &&
                  !showPasswordInput &&
                  uiOptions.attachmentName && (
                    <Tag className="schemaform-file-attachment review">
                      <SchemaField
                        name="attachmentName"
                        required
                        schema={itemSchema.properties.name}
                        uiSchema={getUiSchema(uiOptions.attachmentName)}
                        errorSchema={attachmentNameErrors}
                        idSchema={attachmentNameSchema}
                        formData={formData[index].name}
                        onChange={value => onAttachmentNameChange(index, value)}
                        onBlur={onBlur}
                        registry={indexedRegistry}
                        disabled={props.disabled}
                        readonly={props.readonly}
                      />
                    </Tag>
                  )}
                {!file.uploading &&
                  hasErrors && (
                    <span className="usa-input-error-message" role="alert">
                      <span className="sr-only">Error</span> {errors[0]}
                    </span>
                  )}
                {showPasswordInput && (
                  <ShowPdfPassword
                    file={file.file}
                    index={index}
                    onSubmitPassword={onSubmitPassword}
                    passwordLabel={content.passwordLabel(file.name)}
                    uswds={uswds}
                  />
                )}
                {!formContext.reviewMode &&
                  !isUploading && (
                    <div className="vads-u-margin-top--2">
                      {hasErrors &&
                        enableShortWorkflow && (
                          <va-button
                            name={`retry_upload_${index}`}
                            class="retry-upload vads-u-width--auto vads-u-margin-right--2"
                            onClick={getRetryFunction(
                              allowRetry,
                              index,
                              file.file,
                            )}
                            label={
                              allowRetry
                                ? content.tryAgainLabel(file.name)
                                : content.newFile
                            }
                            text={retryButtonText}
                            uswds={uswds}
                          />
                        )}
                      <va-button
                        secondary
                        class="delete-upload vads-u-width--auto"
                        onClick={() => {
                          openRemoveModal(index);
                        }}
                        label={content.deleteLabel(file.name)}
                        text={deleteButtonText}
                        uswds={uswds}
                      />
                    </div>
                  )}
              </li>
            );
          })}
        </ul>
      )}
      {// Don't render an upload button on review & submit page while in
      // review mode
      showButtons && (
        <>
          {(maxItems === null || files.length < maxItems) &&
            // Prevent additional upload if any upload has error state
            checkUploadVisibility() && (
              <label
                id={`${idSchema.$id}_add_label`}
                htmlFor={idSchema.$id}
                className="upload-button-label vads-u-display--inline-block"
              >
                <va-button
                  id="upload-button"
                  ref={fileButtonRef}
                  secondary
                  class="vads-u-padding-x--0 vads-u-padding-y--1"
                  onClick={() => fileInputRef?.current?.click()}
                  label={`${uploadText}. ${titleString || ''}. ${
                    content.ariaLabelAdditionalText
                  }`}
                  text={uploadText}
                  uswds={uswds}
                />
              </label>
            )}
          <input
            type="file"
            ref={fileInputRef}
            accept={uiOptions.fileTypes.map(item => `.${item}`).join(',')}
            className="vads-u-display--none"
            id={idSchema.$id}
            name={idSchema.$id}
            onChange={onAddFile}
          />
        </>
      )}
    </div>
  );
};

FileField.propTypes = {
  schema: PropTypes.object.isRequired,
  onChange: PropTypes.func.isRequired,
  disabled: PropTypes.bool,
  enableShortWorkflow: PropTypes.bool,
  errorSchema: PropTypes.object,
  formContext: PropTypes.shape({
    onReviewPage: PropTypes.bool,
    reviewMode: PropTypes.bool,
    trackingPrefix: PropTypes.string,
    uploadFile: PropTypes.func,
  }),
  formData: PropTypes.array,
  idSchema: PropTypes.object,
  readonly: PropTypes.bool,
  registry: PropTypes.shape({
    fields: PropTypes.shape({
      SchemaField: PropTypes.func,
    }),
    formContext: PropTypes.shape({}),
  }),
  requiredSchema: PropTypes.object,
  uiSchema: PropTypes.object,
  onBlur: PropTypes.func,
};

const mapStateToProps = state => ({
  enableShortWorkflow: toggleValues(state).file_upload_short_workflow_enabled,
});

export { FileField };

export default connect(mapStateToProps)(FileField);