SumOfUs/Champaign

View on GitHub
app/javascript/legacy/member-facing/backbone/action_form.js

Summary

Maintainability
C
1 day
Test Coverage
import $ from 'jquery';
import I18n from 'champaign-i18n';
import React from 'react';
import { render } from 'react-dom';
import _ from 'lodash';
import Backbone from 'backbone';
import ee from '../../../shared/pub_sub';
import ErrorDisplay from '../../../shared/show_errors';
import MobileCheck from './mobile_check';
import GlobalEvents from '../../../shared/global_events';
import ComponentWrapper from '../../../components/ComponentWrapper';
import ConsentComponent from '../../../components/consent/ConsentComponent';
import ExistingMemberConsent from '../../../components/consent/ExistingMemberConsent';
import {
  changeCountry,
  changeVariant,
  resetState,
  showConsentRequired,
  toggleModal,
} from '../../../state/consent';
import { resetMember } from '../../../state/member/reducer';
import { actionFormUpdated } from '../../../state/fundraiser/actions';

const ActionForm = Backbone.View.extend({
  el: 'form.action-form',
  HIDDEN_FIELDS: [
    'source',
    'akid',
    'referrer_id',
    'rid',
    'bucket',
    'referring_akid',
  ],

  events: {
    'click .action-form__submit-button': 'onClickSubmit',
    'click .action-form__clear-form': 'clearForm',
    'change .action-form__dropdown[name="country"]': 'handleCountryChange',
    'ajax:success': 'handleSuccess',
    'ajax:error': 'handleFailure',
    'ajax:send': 'disableButton',
  },

  globalEvents: {
    'form:clear': 'clearForm',
    'form:step_change': 'handleStepChange',
    'form:submit_action_form': 'submitForm',
  },

  // options: object with any of the following keys
  //    akid: the actionkitid (akid) to save with the user request
  //    source: the referring source to save
  //    outstandingFields: the names of step 2 form fields that aren't satisfied by
  //      the values in the member hash.
  //    member: an object with fields that will prefill the form
  //    referring_akid: if passed, submitted to the server in the form
  //    referrer_id: if passed, submitted to the server in the form
  //    bucket: if passed, submitted to the server in the form
  //    location: a hash of location values inferred from the user's request
  //    skipPrefill: boolean, will not prefill if true
  //    async: when true the form will validate by default.
  //      To submit trigger event `form:submit_action_form`
  initialize(options = {}) {
    this.$ = $;
    this.store = window.champaign.store;
    this.member = options.member;
    this.variant = options.variant || 'simple';
    this.async = options.async || false;
    this.insertHiddenFields(options);
    this.applyDisplayModeToFields(options.member);
    this.url = this.$el.attr('action');
    if (!options.skipPrefill) {
      this.prefillAsPossible(options);
    }
    if (!MobileCheck.isMobile()) {
      this.selectizeDropdowns();
    }
    this.$submitButton = this.$('.action-form__submit-button');
    this.$formWrapper = this.$('.form-wrapper');
    this.$petitionBarComponent = this.$('.petition-bar__main')[0];
    this.buttonText = this.$submitButton.text();
    this.setupState();
    this.enableGDPRConsent();
    GlobalEvents.bindEvents(this);
  },

  defaultAction() {
    if (this.isConsentNeededForExistingMember() || this.async) {
      return 'validate';
    }
    return 'submit';
  },

  state() {
    if (!this.store) return null;
    return this.store.getState();
  },

  setupState() {
    this.store.dispatch(changeVariant(this.variant));
  },

  isMemberPresent() {
    return !_.isEmpty(this.member);
  },

  isConsentNeededForExistingMember() {
    const { member, consent } = this.state();
    return member && consent.isRequiredExisting;
  },

  resetState() {
    if (this.store) {
      this.store.dispatch(resetState());
      this.store.dispatch(changeVariant(this.variant));
    }
  },

  submitForm() {
    _.delay(() => this.$el.submit(), 300);
  },

  handleCountryChange(event) {
    if (this.store) {
      this.store.dispatch(changeCountry(event.target.value) || null);
    }
  },

  onClickSubmit(event) {
    if (this.defaultAction() === 'validate') {
      event.preventDefault();
      event.stopPropagation();
      this.validateForm().then(() => {
        if (this.isConsentNeededForExistingMember()) {
          this.store.dispatch(toggleModal(true));
        } else {
          Backbone.trigger('form:validated');
        }
      });
    }

    const consentState = this.state().consent;

    if (consentState.isRequiredNew && consentState.consented === null) {
      event.preventDefault();
      event.stopPropagation();
      this.store.dispatch(showConsentRequired(true));
      const consentComponent = document.getElementsByClassName(
        'ConsentControls'
      )[0];
      this.$petitionBarComponent.scrollTo({
        top: consentComponent.offsetTop,
        behavior: 'smooth',
      });
      this.$submitButton.blur();
    }
  },

  validateForm() {
    return $.post(`${this.url}/validate`, this.$el.serialize()).then(
      undefined,
      data => {
        this.handleFailure({ target: this.$el }, data);
        throw data;
      }
    );
  },

  // Looks at the display-mode for each field and hides them accordingly
  applyDisplayModeToFields(member) {
    if (this.isMemberPresent()) {
      this.$el.find('[data-display-mode="new_members_only"]').hide(0);
    } else {
      this.$el.find('[data-display-mode="recognized_members_only"]').hide(0);
    }
  },

  // prefills based on outstandingFields and member, returns true or false to indicate
  // the form can now be safely hidden from the user
  prefillAsPossible(options) {
    if (this.formCanAutocomplete(options.outstandingFields, options.member)) {
      this.completePrefill(options.member, options.location);
      if (this.formFieldCount() > 0) {
        this.showFormClearer(options.member);
      }
      this.$el.data('prefilled', true);
      return true;
    } else {
      this.partialPrefill(
        options.member,
        options.location,
        options.outstandingFields
      );
      return false;
    }
  },

  selectizeDropdowns() {
    try {
      $(this.$el)
        .find('.action-form__country-selector, .action-form__dropdown')
        .selectize();
    } catch (e) {
      return;
    }
  },

  clearFormErrors() {
    ErrorDisplay.clearErrors(this.$('form'));
  },

  formCanAutocomplete(outstandingFields, member) {
    return (
      _.isArray(outstandingFields) &&
      outstandingFields.length === 0 &&
      (this.formFieldCount() === 0 || _.isObject(member))
    );
  },

  clearForm() {
    this.store.dispatch(resetMember());
    const $fields_holder = $(this.$el).find('.form__group--prefilled');
    $fields_holder.removeClass('form__group--prefilled');
    $fields_holder
      .find(
        'input[type="text"], input[type="email"], input[type="tel"], select'
      )
      .val('')
      .trigger('change');
    $fields_holder.find('input[type="checkbox"]').attr('checked', false);

    $fields_holder.find('select').each((ii, el) => {
      el.selectedIndex = -1;
    });
    $fields_holder.find('.selectized').each((ii, el) => {
      el.selectize.clear();
    });
    $fields_holder.parents('form').trigger('reset');
    $('.action-form__welcome-text').addClass('hidden-irrelevant');
    this.renameActionKitIdToReferringId();
    this.member = {};
    this.resetState();
    this.enableGDPRConsent();
    Backbone.trigger('sidebar:height_change');
  },

  completePrefill(prefillValues, unvalidatedPrefillValues) {
    $(this.$el)
      .find('.action-form__field-container')
      .addClass('form__group--prefilled');
    this.partialPrefill(prefillValues, unvalidatedPrefillValues, []);

    // DESIRED BUT WEIRD BEHAVIOR - unhide empty fields,
    //   radio buttons, check boxes, and instructions
    const $empties = this.$('.action-form__field-container')
      .find('input, textarea, select')
      .filter(function(ii, el) {
        const val = $(this).val();
        return val === null || val.length === 0;
      });
    const $checkboxes = this.$('.action-form__field-container').find(
      '.checkbox-label, .radio-container, .form__instruction'
    );
    $($.merge($empties, $checkboxes))
      .parents('.action-form__field-container')
      .removeClass('form__group--prefilled');
  },

  // prefillValues - an object mapping form names to prefill values
  // fieldsToSkipPrefill - a list of names of fields that were not
  //    satisfied when the form was validated with prefillValues
  // unvalidatedPrefillValues - values that were not passed through
  //    the form validator, so should be prefilled even if the field
  //    name comes up in fieldsToSkipPrefill.
  partialPrefill(
    prefillValues,
    unvalidatedPrefillValues = {},
    fieldsToSkipPrefill = []
  ) {
    if (!_.isObject(prefillValues)) {
      return;
    }
    fieldsToSkipPrefill = fieldsToSkipPrefill || [];
    this.$('input[type=text], input[type=email], input[type=tel], select').each(
      (ii, field) => {
        const $field = $(field);
        const name = $field.prop('name');
        if (unvalidatedPrefillValues.hasOwnProperty(name)) {
          // weird edge case handling - if the name field is country and the country code is
          // the 'Reserved' country code, don't prefill since it's not a real code.
          const isUnknownCountry =
            name.match('country') && unvalidatedPrefillValues[name] == 'RD';
          if (!isUnknownCountry) {
            $field.val(unvalidatedPrefillValues[name]).trigger('change');
          }
        }
        if (
          prefillValues.hasOwnProperty(name) &&
          fieldsToSkipPrefill.indexOf(name) === -1
        ) {
          $field.val(prefillValues[name]).trigger('change');
        }
      }
    );
  },

  formFieldCount() {
    return this.$('.action-form__field-container').length;
  },

  showFormClearer(member) {
    // don't bind to this.$ so it can be anywhere on the page
    $('.action-form__welcome-name').text(member.welcome_name);
    $('.action-form__welcome-text').removeClass('hidden-irrelevant');
  },

  insertHiddenFields(options) {
    for (let ii = 0; ii < this.HIDDEN_FIELDS.length; ii++) {
      const field = this.HIDDEN_FIELDS[ii];
      if (options[field] && this.$el) {
        this.insertHiddenInput(field, options[field], this.$el);
      }
    }

    if (this.consentNeeded) {
      this.insertHiddenInput('consented', options.member.consented, this.$el);
    }
  },

  insertHiddenInput(name, value, element) {
    $('<input>')
      .attr({
        type: 'hidden',
        name: name,
        value: value,
      })
      .appendTo(element);
  },

  renameActionKitIdToReferringId() {
    const $action_kit_hidden = $('input[name="akid"]');
    if ($action_kit_hidden) {
      $action_kit_hidden.attr('name', 'referring_akid');
    }
  },

  handleSuccess(e, data) {
    // FIXME: we should return consistently from the backend
    ee.emit('action:submitted_success');
    Backbone.trigger('form:submitted', e, data);
    this.store.dispatch(actionFormUpdated(this.formData()));
  },

  formData() {
    return _.reduce(
      $(this.el).serializeArray(),
      (reducedData, arrayItem) => ({
        ...reducedData,
        [arrayItem.name]: arrayItem.value,
      }),
      {}
    );
  },

  handleFailure(e, data) {
    ErrorDisplay.show(e, data);
    this.enableButton();
    this.$petitionBarComponent.scrollTo({ top: 0, behavior: 'smooth' });
    this.$submitButton.blur();
  },

  handleStepChange(step) {
    if (step <= 3) {
      this.enableButton();
    }
  },

  disableButton() {
    this.$submitButton.text(I18n.t('form.processing'));
    this.$submitButton.addClass('button--disabled');
  },

  enableButton() {
    this.$submitButton.text(this.buttonText);
    this.$submitButton.removeClass('button--disabled');
  },

  enableGDPRConsent() {
    if (this.gdprContainer) return;
    this.gdprContainer = document.createElement('div');
    this.$formWrapper.append(this.gdprContainer);

    render(
      <ComponentWrapper store={window.champaign.store} locale={I18n.locale}>
        <ConsentComponent />
        <ExistingMemberConsent />
      </ComponentWrapper>,
      this.gdprContainer
    );
  },
});

export default ActionForm;