Coursemology/coursemology2

View on GitHub
client/app/bundles/course/assessment/submission/components/FileInput.jsx

Summary

Maintainability
B
5 hrs
Test Coverage
import { Component } from 'react';
import Dropzone from 'react-dropzone';
import { Controller, useFormContext } from 'react-hook-form';
import { defineMessages, FormattedMessage } from 'react-intl';
import FileUpload from '@mui/icons-material/FileUpload';
import { Card, CardContent, Chip, Typography } from '@mui/material';
import PropTypes from 'prop-types';

import Prompt from 'lib/components/core/dialogs/Prompt';
import formTranslations from 'lib/translations/form';

import { MEGABYTES_TO_BYTES } from '../constants';

import {
  ErrorCodes,
  FileTooLargeErrorPromptContent,
  TooManyFilesErrorPromptContent,
} from './DropzoneErrorComponent';

const translations = defineMessages({
  uploadDisabled: {
    id: 'course.assessment.submission.FileInput.uploadDisabled',
    defaultMessage: 'File upload disabled',
  },
  uploadLabel: {
    id: 'course.assessment.submission.FileInput.uploadLabel',
    defaultMessage: 'Drag and drop or click to upload files',
  },
  fileUploadErrorTitle: {
    id: 'course.assessment.submission.FileInput.fileUploadErrorTitle',
    defaultMessage: 'Error in Uploading Files',
  },
});

const styles = {
  chip: {
    margin: 4,
  },
  paper: {
    display: 'flex',
    height: 100,
    marginTop: 10,
    marginBottom: 10,
    alignItems: 'center',
    justifyContent: 'center',
    textAlign: 'center',
  },
  wrapper: {
    display: 'flex',
    flexWrap: 'wrap',
  },
};

const isFileTooLarge = (file) =>
  file.errors.some((error) => error.code === ErrorCodes.FileTooLarge);

const initialErrorState = {
  [ErrorCodes.FileTooLarge]: [],
  [ErrorCodes.TooManyFiles]: 0,
};

class FileInput extends Component {
  constructor(props) {
    super(props);
    this.state = {
      dropzoneActive: false,
      errors: initialErrorState,
    };
  }

  onDragEnter() {
    this.setState({ dropzoneActive: true });
  }

  onDragLeave() {
    this.setState({ dropzoneActive: false });
  }

  onDrop(files) {
    const {
      onDropCallback,
      disabled,
      field: { onChange },
    } = this.props;
    this.setState({ dropzoneActive: false });
    if (!disabled) {
      onDropCallback(files);
      return onChange(files.length > 0 ? files : null);
    }
    return () => {};
  }

  onDropRejected(filesRejected) {
    const { maxAttachmentsAllowed } = this.props;
    const tooLargeFiles = filesRejected
      .filter((file) => isFileTooLarge(file))
      .map((file) => file.file.name);
    this.setState({
      errors: {
        [ErrorCodes.FileTooLarge]: tooLargeFiles,
        [ErrorCodes.TooManyFiles]:
          filesRejected.length > maxAttachmentsAllowed
            ? filesRejected.length
            : 0,
      },
    });
  }

  errorExists() {
    const { errors } = this.state;
    return (
      errors[ErrorCodes.FileTooLarge].length > 0 ||
      errors[ErrorCodes.TooManyFiles] > 0
    );
  }

  displayFileNames(files) {
    const { disabled } = this.props;
    const { dropzoneActive } = this.state;
    if (dropzoneActive) {
      return <FileUpload style={{ width: 60, height: 60 }} />;
    }

    if (!files || !files.length) {
      return (
        <Typography>
          {disabled ? (
            <FormattedMessage {...translations.uploadDisabled} />
          ) : (
            <FormattedMessage {...translations.uploadLabel} />
          )}
        </Typography>
      );
    }
    return (
      <div style={styles.wrapper}>
        {files.map((f) => (
          <Chip
            key={f.name}
            disabled={disabled}
            label={f.name}
            style={styles.chip}
          />
        ))}
      </div>
    );
  }

  render() {
    const {
      disabled,
      fieldState: { error },
      field: { value },
      isMultipleAttachmentsAllowed,
      maxAttachmentsAllowed,
      maxAttachmentSize,
      numAttachments,
    } = this.props;
    const { errors } = this.state;

    return (
      <div>
        <Dropzone
          disabled={disabled}
          // 0 means no limit to the maxFiles
          // ref: https://github.com/react-dropzone/react-dropzone/blob/master/examples/maxFiles/README.md
          maxFiles={maxAttachmentsAllowed ?? 0}
          maxSize={maxAttachmentSize * MEGABYTES_TO_BYTES}
          multiple={isMultipleAttachmentsAllowed}
          onDragEnter={() => this.onDragEnter()}
          onDragLeave={() => this.onDragLeave()}
          onDrop={(files) => this.onDrop(files)}
          onDropRejected={(filesRejected) => this.onDropRejected(filesRejected)}
        >
          {({ getRootProps, getInputProps }) => (
            <Card
              {...getRootProps({
                className: `dropzone-input select-none ${
                  !disabled && 'cursor-pointer'
                }`,
                style: styles.paper,
              })}
            >
              <input {...getInputProps()} />
              <CardContent>{this.displayFileNames(value)}</CardContent>
            </Card>
          )}
        </Dropzone>
        <Prompt
          cancelLabel={<FormattedMessage {...formTranslations.close} />}
          onClose={() => this.setState({ errors: initialErrorState })}
          open={this.errorExists()}
          title={<FormattedMessage {...translations.fileUploadErrorTitle} />}
        >
          <div className="space-y-4">
            {errors[ErrorCodes.TooManyFiles] > 0 && (
              <TooManyFilesErrorPromptContent
                maxAttachmentsAllowed={maxAttachmentsAllowed}
                numAttachments={numAttachments}
                numFiles={errors[ErrorCodes.TooManyFiles]}
              />
            )}
            {errors[ErrorCodes.FileTooLarge].length > 0 && (
              <FileTooLargeErrorPromptContent
                maxAttachmentSize={maxAttachmentSize}
                tooLargeFiles={errors[ErrorCodes.FileTooLarge]}
              />
            )}
          </div>
        </Prompt>

        {error || ''}
      </div>
    );
  }
}

FileInput.propTypes = {
  disabled: PropTypes.bool,
  isMultipleAttachmentsAllowed: PropTypes.bool,
  maxAttachmentsAllowed: PropTypes.number,
  maxAttachmentSize: PropTypes.number,
  numAttachments: PropTypes.number,
  fieldState: PropTypes.shape({
    error: PropTypes.bool,
  }).isRequired,
  field: PropTypes.shape({
    onChange: PropTypes.func,
    value: PropTypes.arrayOf(PropTypes.object),
  }).isRequired,
  onDropCallback: PropTypes.func,
};

FileInput.defaultProps = {
  disabled: false,
  onDropCallback: () => {},
};

const FileInputField = (props) => {
  const {
    disabled,
    isMultipleAttachmentsAllowed,
    maxAttachmentsAllowed,
    maxAttachmentSize,
    name,
    numAttachments,
    onChangeCallback,
    onDropCallback,
  } = props;
  const { control } = useFormContext();

  return (
    <Controller
      control={control}
      name={name}
      render={({ field, fieldState }) => (
        <FileInput
          disabled={disabled}
          field={{
            ...field,
            onChange: (event) => {
              field.onChange(event);
              if (onChangeCallback) {
                onChangeCallback();
              }
            },
          }}
          fieldState={fieldState}
          isMultipleAttachmentsAllowed={isMultipleAttachmentsAllowed}
          maxAttachmentsAllowed={maxAttachmentsAllowed}
          maxAttachmentSize={maxAttachmentSize}
          numAttachments={numAttachments}
          onDropCallback={onDropCallback}
        />
      )}
    />
  );
};

FileInputField.propTypes = {
  name: PropTypes.string.isRequired,
  isMultipleAttachmentsAllowed: PropTypes.bool,
  maxAttachmentsAllowed: PropTypes.number,
  maxAttachmentSize: PropTypes.number,
  numAttachments: PropTypes.number,
  disabled: PropTypes.bool.isRequired,
  onChangeCallback: PropTypes.func,
  onDropCallback: PropTypes.func,
};

export default FileInputField;