Source: review.js

/* global $, config, libreviews */
/* eslint prefer-reflect: "off" */
/**
 * Immediately invoked function expression that initializes various event
 * handlers that are part of the review form, including:
 * - management of client-side drafts
 * - "star rating" selector
 * - mode switcher for source selection and integration with lookup adapters
 *
 * @namespace Review
 */
(function() {

  'use strict';

  require('es6-promise').polyfill();

  const NativeLookupAdapter = require('./adapters/native-lookup-adapter');
  const OpenStreetMapLookupAdapter = require('./adapters/openstreetmap-lookup-adapter');
  const WikidataAutocompleteAdapter = require('./adapters/wikidata-autocomplete-adapter');
  const OpenLibraryAutocompleteAdapter = require('./adapters/openlibrary-autocomplete-adapter');

  // All adapters will be tried against a provided review subject URL. If they
  // support it, they will perform a parallel, asynchronous lookup. The array order
  // matters in that the first element in the array (not the first to return
  // a result) will be used for the review subject metadata. The native (lib.reviews)
  // lookup therefore always takes precedence.
  const adapters = [
    new NativeLookupAdapter(),
    new OpenStreetMapLookupAdapter(),
    new WikidataAutocompleteAdapter(updateURLAndReviewSubject, '#review-search-database'),
    new OpenLibraryAutocompleteAdapter(updateURLAndReviewSubject, '#review-search-database')
  ];

  /**
   * URL of the most recently used lookup from an adapter.
   *
   * @type {String}
   * @memberof Review
   */
  let lastLookup;

  /**
   * Most recently selected adapter.
   *
   * @type {Object}
   * @memberof Review
   */
  let lastAdapter;

  // Our form's behavior depends significantly on whether we're creating
  // a new review, or editing an old one.
  let editing = config.editing;
  let textFields = editing ? '#review-title,#review-text' :
    '#review-url,#review-title,#review-text';

  /**
   * Holds instance of external library for client-side storage of new reviews
   * in progress.
   *
   * @memberof Review
   */
  let sisyphus;

  // Add inputs used only with JS here so they don't appear/conflict when JS is disabled
  $('#star-rating-control').append(`<input id="review-rating" name="review-rating" type="hidden" data-required>`);

  /**
   * Rating value of previous POST request, if any.
   *
   * @memberof Review
   */
  let postRating = $('#star-rating-control').attr('data-post') || '';

  // Register event handlers

  $('[id^=star-button-]')
    .mouseout(clearStars)
    .mouseover(indicateStar)
    .click(selectStar)
    .keyup(maybeSelectStar);

  // Little "X" icon that makes previously looked up information from Wikidata
  // and other sources go away and clears out the URL field
  $('#remove-resolved-info')
    .click(removeResolvedInfo)
    .keyup(maybeRemoveResolvedInfo);

  // Highlight rating from POST request
  if (postRating)
    selectStar.apply($(`#star-button-${postRating}`)[0]);

  // We only have to worry about review subject lookup for new reviews,
  // not when editing existing reviews
  if (!editing) {
    initializeURLValidation();
    $(textFields).change(hideAbandonDraft);
    $('#review-url').keyup(function(event) {
      handleURLLookup.call(this, event, true); // Suppresses modal while typing
    });
    $('#review-url').keyup(handleURLFixes);
    $('#review-url').change(handleURLLookup);
    $('#review-url').change(handleURLValidation);
    $('#dismiss-draft-notice').click(hideDraftNotice);
    $('#abandon-draft').click(emptyAllFormFields);
    $('#add-http').click(addHTTP);
    $('#add-https').click(addHTTPS);

    // When writing a review of an existing thing, the URL field is not
    // available, so do not attempt to store data to it.
    let maybeExcludeURL = '';
    if (!$('#review-url').length)
      maybeExcludeURL = ',#review-url';

    // Persist form in local storage
    sisyphus = $('#review-form').sisyphus({
      onBeforeRestore: restoreDynamicFields,
      onRestore: processLoadedData,
      excludeFields: $('[data-ignore-autosave]' + maybeExcludeURL)
    });

    // Wire up mode switcher for source of review subject: URL or database
    $('#review-via-url').conditionalSwitcherClick(activateReviewViaURL);
    $('#review-via-database').conditionalSwitcherClick(activateReviewViaDatabase);

    // Wire up source selector dropdown for "review via database" workflow
    $('#source-selector-dropdown').change(selectSource);

    // Bubbling interferes with autocomplete's window-level click listener
    $('#source-selector-dropdown').click(function(event) {
      event.stopPropagation();
    });

    // Initialize search autocomplete for currently selected source
    selectSource.apply($('#source-selector-dropdown')[0]);
  }

  // In case we're in preview mode and have a URL, make sure we fire the URL
  // validation/lookup handlers
  if ($('#preview-contents').length && $('#review-url').val()) {
    handleURLLookup.apply($('#review-url')[0]);
    handleURLValidation.apply($('#review-url')[0]);
  }

  /**
   * Hide information indicating that an unsaved draft is available.
   *
   * @memberof Review
   */
  function hideDraftNotice() {
    if ($('#draft-notice').is(':visible'))
      $('#draft-notice').fadeOut(200);
  }

  /**
   * Remove a previously resolved review subject from a review.
   *
   * @memberof Review
   */
  function removeResolvedInfo() {
    clearResolvedInfo();
    $('#review-url').val('');
    // Trigger change-related event handlers
    handleURLLookup.apply($('#review-url')[0]);
    handleURLValidation.apply($('#review-url')[0]);
    // Update saved data w/ empty URL
    sisyphus.saveAllData();

    // Focus back on URL field.
    // We don't clear out any search fields in case the user quickly wants
    // to get back to the text they previously entered.
    $('#review-via-url').click();
  }

  /**
   * Keyboard handler that triggers {@link Review.removeResolvedInfo}
   * on `[Enter]` and `[Space]`.
   *
   * @memberof Review
   */
  function maybeRemoveResolvedInfo() {
    if (event.keyCode == 13 || event.keyCode == 32)
      removeResolvedInfo();
  }

  /**
   * Hide the option for abandoning a draft once the user has started editing
   * it.
   *
   * @memberof Review
   */
  function hideAbandonDraft() {
    if ($('#abandon-draft').is(':visible'))
      $('#abandon-draft').fadeOut(200);
  }

  /**
   * Add a HTTP or HTTPS prefix to the review URL.
   * @param {String} protocol - "http" or "https"
   *
   * @memberof Review
   */
  function addProtocol(protocol) {
    $('#review-url').val(protocol + '://' + $('#review-url').val());
    $('#review-url').trigger('change');
  }

  /**
   * Add HTTP prefix to the review URL field and focus on it.
   * @param {Event} event the click event on the "Add HTTP" link
   *
   * @memberof Review
   */
  function addHTTP(event) {
    addProtocol('http');
    $('#review-label').focus();
    event.preventDefault();
  }

  /**
   * Add HTTPS prefix to the review URL field and focus on it.
   * @param {Event} event the click event on the "Add HTTPS" link
   *
   * @memberof Review
   */
  function addHTTPS(event) {
    addProtocol('https');
    $('#review-label').focus();
    event.preventDefault();
  }

  /**
   * Ask all available adapters for information about the currently provided
   * URL, via asynchronous lookup. Note this is separate from the search
   * option -- we look up information, e.g., from Wikidata even if the user just
   * puts in a Wikidata URL.
   *
   * @param {Event} [_event]
   *  the event that triggered the lookup; not used
   * @param {Boolean} [suppressModal=false]
   *  Suppress the modal shown if the user has previously reviewed this subject.
   *  Overkill while typing, so suppressed there.
   *
   * @memberof Review
   */
  function handleURLLookup(_event, suppressModal) {
    let inputEle = this;
    let inputURL = inputEle.value;
    let promises = [];

    // Avoid duplicate lookups on keyup
    if (inputURL === lastLookup)
      return;

    // Track lookups, regardless of success or failure
    lastLookup = inputURL;

    if (!inputURL) {
      clearResolvedInfo();
      return;
    }

    // We look up this URL using all adapters that support it. The native
    // adapter performs its own URL schema validation, and other adapters
    // are more restrictive.
    adapters.forEach(adapter => {
      if (adapter.ask && adapter.lookup && adapter.ask(inputURL))
        promises.push(adapter.lookup(inputURL));
    });

    // We use a mapped array so we can catch failing promises and pass along
    // the error as payload, instead of aborting the whole process if even
    // one of the queries fails.
    Promise
      .all(promises.map(promise => promise.catch(error => ({ error }))))
      .then(results => {
        // If the URL field has been cleared since the user started the query,
        // don't bother with the result of the lookup
        if (!inputEle.value)
          return;
        // Use first valid result in order of the array. Since the native lookup
        // is the first array element, it will take precedence over any adapters.
        for (let result of results) {
          if (result.data && result.data.label) {

            // User has previously reviewed this subject
            if (result.data.thing && typeof result.data.thing.reviews == 'object' &&
              result.data.thing.reviews.length) {
              if (!suppressModal) {
                showModalForEditingExistingReview(result.data);
                return;
              } else {
                // Don't show modal - perhaps user is still typing.
                // May have to show modal later, so don't cache lookup
                lastLookup = undefined;
              }
            }
            updateReviewSubject({
              url: inputURL,
              label: result.data.label,
              description: result.data.description, // may be undefined
              subtitle: result.data.subtitle,
              thing: result.data.thing // may be undefined
            });
            return;
          }
          clearResolvedInfo();
        }
      });
  }

  /**
   * Show modal that asks the user to edit an existing review of the given
   * subject, or to pick another review subject.
   *
   * @param {Object} data
   *  data used for the redirect
   * @param {Object[]} data.reviews
   *  array of reviews by the current user for this thing. Under normal
   *  circumstances there should only be 1, but this is not a hard constraint
   *  at the database level.
   */
  function showModalForEditingExistingReview(data) {
    const $modal = $(`<div class="hidden-regular" id="review-modal"></div>`)
      .append('<p>' + window.libreviews.msg('previously reviewed') + '</p>')
      .append('<p><b>' + data.label + '</b></p>')
      .append('<p>' + window.libreviews.msg('abandon form changes') + '</p>')
      .append('<button class="pure-button pure-button-primary button-rounded" id="edit-existing-review">' +
        window.libreviews.msg('edit review') + '</button>')
      .append('&nbsp;<a href="#" id="pick-different-subject">' + window.libreviews.msg('pick a different subject') + '</a>');
    $modal.insertAfter('#review-subject');
    $modal.modal({
      escapeClose: false,
      clickClose: false,
      showClose: false
    });
    $('#edit-existing-review').click(() => {
      // Clear draft
      sisyphus.manuallyReleaseData();
      window.location.assign(`/review/${data.thing.reviews[0].id}/edit`);
    });
    $('#pick-different-subject').click(event => {
      $.modal.close();
      $modal.remove();
      $('#review-url').val('');
      $('#review-url').trigger('change');
      event.preventDefault();
    });
    $modal.lockTab();
  }

  /**
   * Clean out any old URL metadata and show the label field again
   *
   * @memberof Review
   */
  function clearResolvedInfo() {
    $('.resolved-info').empty();
    $('#review-subject').hide();
    $('.review-label-group').show();
    window.libreviews.repaintFocusedHelp();
  }

  /**
   * Show warning and helper links as appropriate for a given subject URL.
   *
   * @memberof Review
   */
  function handleURLValidation() {
    let inputURL = this.value;

    if (inputURL && !libreviews.validateURL(inputURL)) {
      $('#review-url-error').show();
      if (!libreviews.urlHasSupportedProtocol(inputURL)) {
        $('.helper-links').show();
        $('#add-https').focus();
      } else {
        $('.helper-links').hide();
      }
    } else {
      $('#review-url-error').hide();
      $('.helper-links').hide();
    }
  }


  /**
   * Update UI based on URL corrections of validation problems. This is
   * registered on keyup for the URL input field, so corrections are instantly
   * detected.
   *
   * @memberof Review
   */
  function handleURLFixes() {
    let inputURL = this.value;
    if ($('#review-url-error').is(':visible')) {
      if (libreviews.validateURL(inputURL)) {
        $('#review-url-error').hide();
        $('.helper-links').hide();
      }
    }
  }

  /**
   * Callback called from adapters for adding informationed obtained via an
   * adapter to the form. Except for the URL, the information is not actually
   * associated with the review, since the corresponding server-side adapter
   * will perform its own deep lookup.
   *
   * @param {Object} data - Data obtained by the adapter
   * @memberof Review
   */
  function updateURLAndReviewSubject(data) {
    if (!data.url)
      throw new Error('To update a URL, we must get one.');
    $('#review-url').val(data.url);

    // Re-validate URL. There shouldn't be any problems with the new URL, so
    // this will mainly clear out old validation errors.
    handleURLValidation.apply($('#review-url')[0]);

    // If user has previously reviewed this, they need to choose to pick a
    // different subject or to edit their existing review
    if (data.thing && data.thing.reviews) {
      // Previous adapter may have loaded info
      clearResolvedInfo();
      showModalForEditingExistingReview(data);
      return;
    }

    updateReviewSubject(data);
    // Make sure we save draft in case user aborts here
    sisyphus.saveAllData();
  }

  /**
   * Handle the non-URL information obtained via the
   * {@link Review.updateURLAndReviewSubject} callback, or via the
   * {@link Review.handleURLLookup} handler on the URL input field.
   *
   * @param {Object} data - Data obtained by the adapter
   * @memberof Review
   */
  function updateReviewSubject(data) {
    const { url, label, description, subtitle, thing } = data;
    if (!label)
      throw new Error('Review subject must have a label.');
    let wasFocused = $('#resolved-url a').is(':focus');
    $('.resolved-info').empty();
    $('#resolved-url').append(`<a href="${url}" target="_blank">${label}</a>`);
    if (description)
      $('#resolved-description').html(description);
    if (subtitle)
      $('#resolved-subtitle').html(`<i>${subtitle}</i>`);

    if (thing) {
      $('#resolved-thing').append(`<a href="/${thing.urlID}" target="_blank">${libreviews.msg('more info')}</a>`);
    }
    $('#review-subject').show();
    if (wasFocused)
      $('#resolved-url a').focus();
    $('.review-label-group').hide();
    // We don't want to submit previously entered label data
    $('#review-label').val('');
    // If now hidden field is focused, focus on title field instead (next in form)
    if ($('#review-label').is(':focus') || document.activeElement === document.body)
      $('#review-title').focus();
  }


  /**
   * Clear out old draft
   *
   * @param {Event} event - click event from the "Clear draft" button
   * @memberof Review
   */
  function emptyAllFormFields(event) {
    clearStars();
    $('#review-url,#review-title,#review-text,#review-rating').val('');
    $('#review-url').trigger('change');
    for (let rte in window.libreviews.activeRTEs)
      window.libreviews.activeRTEs[rte].reRender();
    sisyphus.manuallyReleaseData();
    hideDraftNotice();
    event.preventDefault();
  }


  /**
   * Restore fields that were dynamically added by JavaScript and not part of
   * the original form.
   *
   * @memberof Review
   */
  function restoreDynamicFields() {
    const uploadRegex = /^\[id=review-form\].*\[name=(uploaded-file-.*?)\]$/;
    for (let key in localStorage) {
      if (uploadRegex.test(key)) {
        const fieldName = key.match(uploadRegex)[1];
        $('#review-form').append(`<input type="hidden" name="${fieldName}">`);
      }
    }
  }

  /**
   * Load draft data into the review form.
   *
   * @memberof Review
   */
  function processLoadedData() {
    let rating = Number($('#review-rating').val());

    // Trim just in case whitespace got persisted
    $('input[data-auto-trim],textarea[data-auto-trim]').each(window.libreviews.trimInput);

    // Only show notice if we've actually recovered some data
    if (rating || $('#review-url').val() || $('#review-title').val() || $('#review-text').val()) {
      if (rating)
        selectStar.apply($(`#star-button-${rating}`)[0]);

      $('#draft-notice').show();
      // Repaint help in case it got pushed down
      window.libreviews.repaintFocusedHelp();
    }

    // Show URL issues if appropriate
    if ($('#review-url').length) {
      handleURLLookup.apply($('#review-url')[0]);
      handleURLValidation.apply($('#review-url')[0]);
    }
  }


  /**
   * Replace star images with their placeholder versions
   *
   * @param {Number} start - rating from which to start clearing
   * @memberof Review
   */
  function clearStars(start) {

    if (!start || typeof start !== "number")
      start = 1;
    for (let i = start; i < 6; i++)
      replaceStar(i, `/static/img/star-placeholder.svg`, 'star-holder');
  }


  /**
   * replaceStar - Helper function to replace individual star image
   *
   * @param {Number} id - number of the star to replace
   * @param {String} src - value for image source attribute
   * @param {String} className - CSS class to assign to element
   * @memberof Review
   */
  function replaceStar(id, src, className) {
    $(`#star-button-${id}`)
      .attr('src', src)
      .removeClass()
      .addClass(className);
  }


  /**
   * Handler to restore star rating to a previously selected setting.
   *
   * @memberof Review
   */
  function restoreSelected() {
    let selectedStar = $('#star-rating-control').attr('data-selected');
    if (selectedStar)
      selectStar.apply($(`#star-button-${selectedStar}`)[0]);
  }

  /**
   * Handler to "preview" a star rating, used on mouseover.
   *
   * @returns {Number} - the number value of the selected star
   * @memberof Review
   */
  function indicateStar() {
    // We want to set all stars to the color of the selected star
    let selectedStar = Number(this.id.match(/\d/)[0]);
    for (let i = 1; i <= selectedStar; i++)
      replaceStar(i, `/static/img/star-${selectedStar}-full.svg`, 'star-full');
    if (selectedStar < 5)
      clearStars(selectedStar + 1);
    return selectedStar;
  }


  /**
   * Key handler for selecting stars with `[Enter]` or `[Space]`.
   *
   * @param  {Event} event - the key event
   */
  function maybeSelectStar(event) {
    if (event.keyCode == 13 || event.keyCode == 32)
      selectStar.apply(this);
  }

  /**
   * Actually apply a star rating.
   *
   * @memberof Review
   */
  function selectStar() {
    let selectedStar = indicateStar.apply(this);
    $('#star-rating-control').attr('data-selected', selectedStar);
    $('#star-rating-control img[id^=star-button-]')
      .off('mouseout')
      .mouseout(restoreSelected);
    $('#review-rating').val(selectedStar);
    $('#review-rating').trigger('change');
  }


  /**
   * Add template for URL validation errors, including "Add HTTP" and "Add HTTPS"
   * helper links.
   *
   * @memberof Review
   */
  function initializeURLValidation() {
    $('#url-validation').append(
      `<div id="review-url-error" class="validation-error">${libreviews.msg('not a url')}</div>` +
      `<div class="helper-links"><a href="#" id="add-https">${libreviews.msg('add https')}</a> &ndash; <a href="#" id="add-http">${libreviews.msg('add http')}</a></div>`
    );
  }

  /**
   * Click handler for activating the form for reviewing a subject by specifying
   * a URL
   *
   * @memberof Review
   */
  function activateReviewViaURL() {
    $('#review-via-database-inputs').addClass('hidden');
    $('#review-via-url-inputs').removeClass('hidden');
    $('.review-label-group').removeClass('hidden-regular');
    $('#source-selector').toggleClass('hidden', true);
    if (!$('#review-url').val())
      $('#review-url').focus();
  }


  /**
   * Click handler for activating the form for reviewing a subject by selecting
   * it from an external database.
   *
   * @param  {Event} event - the click event
   * @memberof Review
   */
  function activateReviewViaDatabase(event) {
    // Does not conflict with other hide/show actions on this group
    $('.review-label-group').addClass('hidden-regular');
    $('#review-via-url-inputs').addClass('hidden');
    $('#review-via-database-inputs').removeClass('hidden');
    // Focusing pops the selection back up, so this check is extra important here
    $('#source-selector').toggleClass('hidden', false);
    if (!$('#review-search-database').val()) {
      $('#review-search-database').focus();
      // Ensure focus event to mount autocomplete widget is triggered
      const focusEvent = new FocusEvent('focus', {
        view: window,
        bubbles: false,
        cancelable: true
      });
      $('#review-search-database')[0].dispatchEvent(focusEvent);
    }
    // Suppress event bubbling up to window, which the AC widget listens to, and
    // which would unmount the autocomplete function
    event.stopPropagation();
  }


  /**
   * Handler for selecting a source from the "database sources" dropdown,
   * which requires lookup using an adapter plugin.
   *
   * @memberof Review
   */
  function selectSource() {
    let sourceID = $(this).val();

    let adapter;
    // Locate adapter responsible for source declared in dropdown
    for (let a of adapters) {
      if (a.getSourceID() == sourceID) {
        adapter = a;
        break;
      }
    }

    if (lastAdapter && lastAdapter.removeAutocomplete)
      lastAdapter.removeAutocomplete();

    if (adapter && adapter.setupAutocomplete) {
      adapter.setupAutocomplete();
      if (lastAdapter) {
        // Re-run search, unless this is the first one. Sets focus on input.
        if (adapter.runAutocomplete)
          adapter.runAutocomplete();

        // Change help text
        $('#review-search-database-help .help-heading')
          .html(libreviews.msg(`review via ${adapter.getSourceID()} help label`));

        $('#review-search-database-help .help-paragraph')
          .html(libreviews.msg(`review via ${adapter.getSourceID()} help text`));

        // Change input placeholder
        $('#review-search-database').attr('placeholder',
          libreviews.msg(`start typing to search ${adapter.getSourceID()}`));

        window.libreviews.repaintFocusedHelp();
      }
    }

    // Track usage of the adapter so we can run functions on change
    lastAdapter = adapter;
  }

}());