frontend/source/js/data-capture/upload.js

Summary

Maintainability
B
5 hrs
Test Coverage
/* global $, window, document */
/* eslint-disable prefer-destructuring */


import * as supports from './feature-detection';

import dispatchBubbly from './custom-event';

const HAS_BROWSER_SUPPORT = supports.dragAndDrop() && supports.formData()
                            && supports.dataTransfer();

/**
 * UploadInput represents a <input is="upload-input" type="file"> web
 * component.
 *
 * Unlike a standard file input, it allows for the file to be set by
 * client code via the `upgradedValue` property. However, in order for the
 * input to be writable, client code must call its `upgrade()` method and
 * manually submit the `upgradedValue` to a server via ajax.
 */

export class UploadInput extends window.HTMLInputElement {
  createdCallback() {
    this.isUpgraded = false;
    this._upgradedValue = null;
  }

  attachedCallback() {
    if (this.getAttribute('type') !== 'file') {
      throw new Error('<input is="upload-input"> must have type "file".');
    }
    if (this.hasAttribute('multiple')) {
      throw new Error('<input is="upload-input"> does not currently '
                      + 'support the "multiple" attribute.');
    }
    dispatchBubbly(this, 'uploadinputready');
  }

  upgrade() {
    this.isUpgraded = true;
    $(this).on('change', () => {
      this.upgradedValue = this.files[0];
    });
    if (this.hasAttribute('required')) {
      // We don't want the browser to enforce the required attribute, because
      // if we programmatically set our file (e.g. via a drag-and-drop)
      // the browser will still think the file input is empty.

      this.removeAttribute('required');

      // Instead, we'll set our own custom validity manually, assuming the
      // user's browser supports HTML5 form validation.

      if (this.setCustomValidity) {
        this.setCustomValidity('Please choose a file.');
      }
    }
  }

  get upgradedValue() {
    return this._upgradedValue;
  }

  set upgradedValue(file) {
    if (!file) {
      return;
    }

    if (!this.isFileValid(file)) {
      dispatchBubbly(this, 'invalidfile');
      return;
    }

    this._upgradedValue = file;
    $(this).val('');

    if (this.setCustomValidity) {
      this.setCustomValidity('');
    }

    dispatchBubbly(this, 'changefile', {
      detail: file,
    });
  }

  isFileValid(file) {
    const accepts = $(this).attr('accept');
    if (!accepts || !accepts.length) {
      // nothing specified, so just return true
      return true;
    }
    const fileType = file.type.toLowerCase();
    const fileName = file.name.toLowerCase();
    const acceptsList = accepts.split(',').map(s => s.trim().toLowerCase());

    return acceptsList.some(extOrType => (
      (fileType === extOrType)
      || (fileName.lastIndexOf(extOrType, fileName.length - extOrType.length) !== -1)
    ));
  }
}

UploadInput.prototype.SOURCE_FILENAME = __filename;

document.registerElement('upload-input', {
  extends: 'input',
  prototype: UploadInput.prototype,
});

/**
 * UploadWidget represents a <upload-widget> web component, which provides
 * a large area for users to drag-and-drop files into. It also serves as a
 * sort of view into the state of a nested <input is="upload-input">, telling
 * the user the name of the current file to be uploaded, and reporting
 * errors (such as invalid file type) if necessary.
 *
 * An <upload-widget> must be nested within a <form is="ajax-form"> in order
 * to work properly, since supporting HTML5 drag-and-drop necessitates
 * submitting the dropped file via ajax.
 */

export class UploadWidget extends window.HTMLElement {
  static stopAndPrevent(event) {
    event.stopPropagation();
    event.preventDefault();
  }

  _checkForAjaxFormParent() {
    if (this.uploadInput.form
        && this.uploadInput.form.getAttribute('is') !== 'ajax-form') {
      if (window.console && window.console.log) {
        window.console.log(
          'Warning: <upload-widget> must have a '
          + '<form is="ajax-form"> parent in order to support '
          + 'drag-and-drop.',
        );
      }
      return false;
    }
    return true;
  }

  attachedCallback() {
    const $el = $(this);
    const $input = $('input', $el);

    if ($input.length !== 1 || $input.attr('is') !== 'upload-input') {
      throw new Error('<upload-widget> must contain exactly one '
                      + '<input is="upload-input">.');
    }

    this.uploadInput = $input[0];
    this.isDegraded = false;

    let dragCounter = 0;
    let lastDragEnterTarget = null;

    function setCurrentFilename(filename) {
      $('input', $el).nextAll().remove();

      const id = $('input', $el).attr('id');
      const current = $(
        '<div class="upload-current">'
        + '<div class="upload-filename"></div>'
        + '<div class="upload-changer">Not right? '
        + '<label>Choose a different file</label> or drag and drop here.'
        + '</div></div>',
      );
      $('label', current).attr('for', id);
      $('.upload-filename', current).text(filename);
      $el.append(current);
    }

    const finishInitialization = () => {
      if (this.uploadInput instanceof UploadInput) {
        if (!this.isDegraded) {
          this.uploadInput.upgrade();

          const fakeInitialFilename = $el.attr('data-fake-initial-filename');

          if (fakeInitialFilename) {
            setCurrentFilename(fakeInitialFilename);
          }
        }
        dispatchBubbly($el[0], 'uploadwidgetready');
      } else {
        $el.one('uploadinputready', finishInitialization);
      }
    };

    function showInvalidFileMessage() {
      $('input', $el).nextAll().remove();

      const id = $('input', $el).attr('id');
      const err = $(
        '<div class="upload-error">'
        + '<div class="upload-error-message">Sorry, that type of file is not allowed.</div>'
        + 'Please <label>choose a different file</label> or drag and drop one here.'
        + '</div></div>',
      );
      $('label', err).attr('for', id);
      $el.append(err);
    }

    if (!this._checkForAjaxFormParent() || !HAS_BROWSER_SUPPORT
        || supports.isForciblyDegraded(this)) {
      this.isDegraded = true;
      return finishInitialization();
    }

    $('label', $el)
      .after('<span aria-hidden="true"> or drag and drop here.</span>');

    // The content of the upload widget will change when the user chooses
    // a file, so let's make sure screen readers let users know about it.
    // Note that this is also used by CSS rules to determine whether or
    // not the widget has actually been upgraded by JS.
    $el.attr('aria-live', 'polite');

    $el.on('dragenter', (e) => {
      UploadWidget.stopAndPrevent(e);

      // Firefox spuriously fires multiple `dragenter` events, which
      // we need to work around by keeping track of the most recent
      // event target. For more information, see:
      //
      //   https://bugzilla.mozilla.org/show_bug.cgi?id=804036

      if (lastDragEnterTarget !== e.target) {
        dragCounter++;
        $el.addClass('dragged-over');
        lastDragEnterTarget = e.target;
      }
    });
    $el.on('dragleave', () => {
      lastDragEnterTarget = null;
      // http://stackoverflow.com/a/21002544/2422398
      if (--dragCounter === 0) {
        $el.removeClass('dragged-over');
      }
    });
    $el.on('dragover', UploadWidget.stopAndPrevent.bind(this));
    $el.on('drop', (e) => {
      UploadWidget.stopAndPrevent(e);
      $el.removeClass('dragged-over');
      dragCounter = 0;
      lastDragEnterTarget = null;

      this.uploadInput.upgradedValue = e.originalEvent.dataTransfer.files[0];
    });

    $input.on('invalidfile', showInvalidFileMessage);

    $input.on('changefile', (e) => {
      setCurrentFilename(e.originalEvent.detail.name);
    });

    return finishInitialization();
  }
}

UploadWidget.prototype.SOURCE_FILENAME = __filename;

UploadWidget.HAS_BROWSER_SUPPORT = HAS_BROWSER_SUPPORT;

document.registerElement('upload-widget', {
  prototype: UploadWidget.prototype,
});