Home Reference Source

src/components/_classes/nested/NestedComponent.js

'use strict';
import _ from 'lodash';
import Field from '../field/Field';
import Components from '../../Components';
import NativePromise from 'native-promise-only';
import { getArrayFromComponentPath, getStringFromComponentPath } from '../../../utils/utils';

export default class NestedComponent extends Field {
  static schema(...extend) {
    return Field.schema({
      tree: false
    }, ...extend);
  }

  constructor(component, options, data) {
    super(component, options, data);
    this.type = 'components';
    this._collapsed = !!this.component.collapsed;
  }

  get defaultSchema() {
    return NestedComponent.schema();
  }

  get schema() {
    const schema = super.schema;
    const components = _.uniqBy(this.getComponents(), 'component.key');
    schema.components = _.map(components, 'schema');
    return schema;
  }

  get collapsed() {
    return this._collapsed;
  }

  set collapsed(value) {
    this._collapsed = value;
    this.redraw();
    if (!value && !this.pristine) {
      this.checkValidity(this.data, true);
    }
  }

  set visible(value) {
    // DO NOT CALL super here.  There is an issue where clearOnHide was getting triggered with
    // subcomponents because the "parentVisible" flag was set to false when it should really be
    // set to true.
    const visibilityChanged = this._visible !== value;
    this._visible = value;
    const isVisible = this.visible;
    const forceShow = this.options.show && this.options.show[this.component.key];
    const forceHide = this.options.hide && this.options.hide[this.component.key];
    this.components.forEach(component => {
      // Set the parent visibility first since we may have nested components within nested components
      // and they need to be able to determine their visibility based on the parent visibility.
      component.parentVisible = isVisible;

      const conditionallyVisible = component.conditionallyVisible();
      if (forceShow || conditionallyVisible) {
        component.visible = true;
      }
      else if (forceHide || !isVisible || !conditionallyVisible) {
        component.visible = false;
      }
      // If hiding a nested component, clear all errors below.
      if (!component.visible) {
        component.error = '';
      }
    });
    if (visibilityChanged) {
      this.clearOnHide();
      this.redraw();
    }
  }

  get visible() {
    return super.visible;
  }

  set parentVisible(value) {
    super.parentVisible = value;
    this.components.forEach(component => component.parentVisible = this.visible);
  }

  get parentVisible() {
    return super.parentVisible;
  }

  get disabled() {
    return super.disabled;
  }

  set disabled(disabled) {
    super.disabled = disabled;
    this.components.forEach((component) => component.parentDisabled = disabled);
  }

  set parentDisabled(value) {
    super.parentDisabled = value;
    this.components.forEach(component => {
      component.parentDisabled = this.disabled;
    });
  }

  get parentDisabled() {
    return super.parentDisabled;
  }

  get ready() {
    return NativePromise.all(this.getComponents().map(component => component.ready));
  }

  get currentForm() {
    return super.currentForm;
  }

  set currentForm(instance) {
    super.currentForm = instance;
    this.getComponents().forEach(component => {
      component.currentForm = instance;
    });
  }

  get rowIndex() {
    return this._rowIndex;
  }

  set rowIndex(value) {
    this._rowIndex = value;
    this.eachComponent((component) => {
      component.rowIndex = value;
    });
  }

  componentContext() {
    return this._data;
  }

  get data() {
    return this._data;
  }

  set data(value) {
    this._data = value;
    this.eachComponent((component) => {
      component.data = this.componentContext(component);
    });
  }

  getComponents() {
    return this.components || [];
  }

  /**
   * Perform a deep iteration over every component, including those
   * within other container based components.
   *
   * @param {function} fn - Called for every component.
   */
  everyComponent(fn, options) {
    const components = this.getComponents();
    _.each(components, (component, index) => {
      if (fn(component, components, index) === false) {
        return false;
      }

      if (typeof component.everyComponent === 'function') {
        if (component.everyComponent(fn, options) === false) {
          return false;
        }
      }
    });
  }

  hasComponent(component) {
    let result = false;

    this.everyComponent((comp) => {
      if (comp === component) {
        result = true;
        return false;
      }
    });

    return result;
  }

  flattenComponents() {
    const result = {};

    this.everyComponent((component) => {
      result[component.component.flattenAs || component.key] = component;
    });

    return result;
  }

  /**
   * Perform an iteration over each component within this container component.
   *
   * @param {function} fn - Called for each component
   */
  eachComponent(fn) {
    _.each(this.getComponents(), (component, index) => {
      if (fn(component, index) === false) {
        return false;
      }
    });
  }

  /**
   * Returns a component provided a key. This performs a deep search within the
   * component tree.
   *
   * @param {string} key - The key of the component to retrieve.
   * @param {function} fn - Called with the component once found.
   * @return {Object} - The component that is located.
   */
  getComponent(path, fn, originalPath) {
    originalPath = originalPath || getStringFromComponentPath(path);
    path = getArrayFromComponentPath(path);
    const pathStr = originalPath;
    let key = path.shift();
    const remainingPath = path;
    let comp = null;
    let possibleComp = null;

    if (_.isNumber(key)) {
      key = remainingPath.shift();
    }

    if (!_.isString(key)) {
      return comp;
    }

    this.everyComponent((component, components) => {
      const matchPath = component.hasInput && component.path ? pathStr.includes(component.path) : true;
      if (component.component.key === key) {
        possibleComp = component;
        if (matchPath) {
          comp = component;
          if (remainingPath.length > 0 && 'getComponent' in component) {
            comp = component.getComponent(remainingPath, fn, originalPath);
          }
          else if (fn) {
            fn(component, components);
          }
          return false;
        }
      }
    });

    if (!comp) {
      comp = possibleComp;
    }

    return comp;
  }

  /**
   * Return a component provided the Id of the component.
   *
   * @param {string} id - The Id of the component.
   * @param {function} fn - Called with the component once it is retrieved.
   * @return {Object} - The component retrieved.
   */
  getComponentById(id, fn) {
    let comp = null;
    this.everyComponent((component, components) => {
      if (component.id === id) {
        comp = component;
        if (fn) {
          fn(component, components);
        }
        return false;
      }
    });
    return comp;
  }

  /**
   * Return a path of component's value.
   *
   * @param {Object} component - The component instance.
   * @return {string} - The component's value path.
   */
  calculateComponentPath(component) {
    let path = '';
    if (component.component.key) {
      let thisPath = this;
      while (thisPath && !thisPath.allowData && thisPath.parent) {
        thisPath = thisPath.parent;
      }
      const rowIndex = component.row ? `[${Number.parseInt(component.row)}]` : '';
      path = thisPath.path ? `${thisPath.path}${rowIndex}.` : '';
      path += component._parentPath && component.component.shouldIncludeSubFormPath ? component._parentPath : '';
      path += component.component.key;
      return path;
    }
  }

  /**
   * Create a new component and add it to the components array.
   *
   * @param component
   * @param data
   */
  createComponent(component, options, data, before) {
    if (!component) {
      return;
    }
    options = options || this.options;
    data = data || this.data;
    options.parent = this;
    options.parentVisible = this.visible;
    options.root = this.root || this;
    options.skipInit = true;
    if (!this.isInputComponent && this.component.shouldIncludeSubFormPath) {
      component.shouldIncludeSubFormPath = true;
    }
    const comp = Components.create(component, options, data, true);

    const path = this.calculateComponentPath(comp);
    if (path) {
      comp.path = path;
    }
    comp.init();
    if (component.internal) {
      return comp;
    }

    if (before) {
      const index = _.findIndex(this.components, { id: before.id });
      if (index !== -1) {
        this.components.splice(index, 0, comp);
      }
      else {
        this.components.push(comp);
      }
    }
    else {
      this.components.push(comp);
    }
    return comp;
  }

  getContainer() {
    return this.element;
  }

  get componentComponents() {
    return this.component.components || [];
  }

  get nestedKey() {
    return `nested-${this.key}`;
  }

  get templateName() {
    return 'container';
  }

  init() {
    this.components = this.components || [];
    this.addComponents();
    return super.init();
  }

  /**
   *
   * @param element
   * @param data
   */
  addComponents(data, options) {
    data = data || this.data;
    options = options || this.options;
    if (options.components) {
      this.components = options.components;
    }
    else {
      const components = this.hook('addComponents', this.componentComponents, this) || [];
      components.forEach((component) => this.addComponent(component, data));
    }
  }

  /**
   * Add a new component to the components array.
   *
   * @param {Object} component - The component JSON schema to add.
   * @param {Object} data - The submission data object to house the data for this component.
   * @param {HTMLElement} before - A DOM element to insert this element before.
   * @return {Component} - The created component instance.
   */
  addComponent(component, data, before, noAdd) {
    data = data || this.data;
    if (this.options.parentPath) {
      component.shouldIncludeSubFormPath = true;
    }
    component = this.hook('addComponent', component, data, before, noAdd);
    const comp = this.createComponent(component, this.options, data, before ? before : null);
    if (noAdd) {
      return comp;
    }
    return comp;
  }

  beforeFocus() {
    if (this.parent && 'beforeFocus' in this.parent) {
      this.parent.beforeFocus(this);
    }
  }

  render(children) {
    // If already rendering, don't re-render.
    return super.render(children || this.renderTemplate(this.templateName, {
      children: this.renderComponents(),
      nestedKey: this.nestedKey,
      collapsed: this.options.pdf ? false : this.collapsed,
    }));
  }

  renderComponents(components) {
    components = components || this.getComponents();
    const children = components.map(component => component.render());
    return this.renderTemplate('components', {
      children,
      components,
    });
  }

  attach(element) {
    const superPromise = super.attach(element);

    this.loadRefs(element, {
      header: 'single',
      collapsed: this.collapsed,
      [this.nestedKey]: 'single',
    });

    let childPromise = NativePromise.resolve();
    if (this.refs[this.nestedKey]) {
      childPromise = this.attachComponents(this.refs[this.nestedKey]);
    }

    if (this.component.collapsible && this.refs.header) {
      this.addEventListener(this.refs.header, 'click', () => {
        this.collapsed = !this.collapsed;
      });
    }

    return NativePromise.all([
      superPromise,
      childPromise,
    ]);
  }

  attachComponents(element, components, container) {
    components = components || this.components;
    container = container || this.component.components;

    element = this.hook('attachComponents', element, components, container, this);
    if (!element) {
      // Return a non-resolving promise.
      return (new NativePromise(() => {}));
    }

    let index = 0;
    const promises = [];
    Array.prototype.slice.call(element.children).forEach(child => {
      if (!child.getAttribute('data-noattach') && components[index]) {
        promises.push(components[index].attach(child));
        index++;
      }
    });
    return NativePromise.all(promises);
  }

  /**
   * Remove a component from the components array.
   *
   * @param {Component} component - The component to remove from the components.
   * @param {Array<Component>} components - An array of components to remove this component from.
   */
  removeComponent(component, components) {
    components = components || this.components;
    component.destroy();
    _.remove(components, { id: component.id });
  }

  /**
   * Removes a component provided the API key of that component.
   *
   * @param {string} key - The API key of the component to remove.
   * @param {function} fn - Called once the component is removed.
   * @return {null}
   */
  removeComponentByKey(key, fn) {
    const comp = this.getComponent(key, (component, components) => {
      this.removeComponent(component, components);
      if (fn) {
        fn(component, components);
      }
    });
    if (!comp) {
      if (fn) {
        fn(null);
      }
      return null;
    }
  }

  /**
   * Removes a component provided the Id of the component.
   *
   * @param {string} id - The Id of the component to remove.
   * @param {function} fn - Called when the component is removed.
   * @return {null}
   */
  removeComponentById(id, fn) {
    const comp = this.getComponentById(id, (component, components) => {
      this.removeComponent(component, components);
      if (fn) {
        fn(component, components);
      }
    });
    if (!comp) {
      if (fn) {
        fn(null);
      }
      return null;
    }
  }

  updateValue(value, flags = {}) {
    return this.components.reduce((changed, comp) => {
      return comp.updateValue(null, flags) || changed;
    }, super.updateValue(value, flags));
  }

  shouldSkipValidation(data, dirty, row) {
    // Nested components with no input should not be validated.
    if (!this.component.input) {
      return true;
    }
    else {
      return super.shouldSkipValidation(data, dirty, row);
    }
  }

  checkData(data, flags, row, components) {
    if (this.builderMode) {
      return true;
    }
    data = data || this.rootValue;
    flags = flags || {};
    row = row || this.data;
    components = components && _.isArray(components) ? components : this.getComponents();
    const isValid = components.reduce((valid, comp) => {
      return comp.checkData(data, flags, row) && valid;
    }, super.checkData(data, flags, row));

    this.checkModal(isValid, this.isDirty);
    return isValid;
  }

  checkModal(isValid, dirty) {
    if (!this.component.modalEdit || !this.componentModal) {
      return;
    }
    const messages = this.errors;
    this.clearErrorClasses(this.refs.openModalWrapper);
    this.error = '';
    if (!isValid && (dirty || !this.isPristine && !!messages.length)) {
      this.error = {
        component: this.component,
        level: 'hidden',
        message: this.t('Fix the errors')
      };
      this.setErrorClasses([this.refs.openModal], dirty, !isValid, !!messages.length, this.refs.openModalWrapper);
    }
    this.setOpenModalElement();
  }

  checkConditions(data, flags, row) {
    this.getComponents().forEach(comp => comp.checkConditions(data, flags, row));
    return super.checkConditions(data, flags, row);
  }

  clearOnHide(show) {
    super.clearOnHide(show);
    if (this.component.clearOnHide) {
      if (this.allowData && !this.hasValue()) {
        this.dataValue = this.defaultValue;
      }
      if (this.hasValue()) {
        this.restoreComponentsContext();
      }
    }
    this.getComponents().forEach(component => component.clearOnHide(show));
  }

  restoreComponentsContext() {
    this.getComponents().forEach((component) => component.data = this.dataValue);
  }

  /**
   * Allow components to hook into the next page trigger to perform their own logic.
   *
   * @return {*}
   */
  beforePage(next) {
    return NativePromise.all(this.getComponents().map((comp) => comp.beforePage(next)));
  }

  /**
   * Allow components to hook into the submission to provide their own async data.
   *
   * @return {*}
   */
  beforeSubmit() {
    return NativePromise.all(this.getComponents().map((comp) => comp.beforeSubmit()));
  }

  calculateValue(data, flags, row) {
    // Do not iterate into children and calculateValues if this nested component is conditionally hidden.
    if (!this.conditionallyVisible()) {
      return false;
    }
    return this.getComponents().reduce(
      (changed, comp) => comp.calculateValue(data, flags, row) || changed,
      super.calculateValue(data, flags, row)
    );
  }

  isLastPage() {
    return this.pages.length - 1 === this.page;
  }

  isValid(data, dirty) {
    return this.getComponents().reduce(
      (valid, comp) => comp.isValid(data, dirty) && valid,
      super.isValid(data, dirty)
    );
  }

  checkValidity(data, dirty, row, silentCheck) {
    if (!this.checkCondition(row, data)) {
      this.setCustomValidity('');
      return true;
    }

    const isValid = this.getComponents().reduce(
      (check, comp) => comp.checkValidity(data, dirty, row, silentCheck) && check,
      super.checkValidity(data, dirty, row, silentCheck)
    );
    this.checkModal(isValid, dirty);
    return isValid;
  }

  checkAsyncValidity(data, dirty, row, silentCheck) {
    return this.ready.then(() => {
      const promises = [super.checkAsyncValidity(data, dirty, row, silentCheck)];
      this.eachComponent((component) => promises.push(component.checkAsyncValidity(data, dirty, row, silentCheck)));
      return NativePromise.all(promises).then((results) => results.reduce((valid, result) => (valid && result), true));
    });
  }

  setPristine(pristine) {
    super.setPristine(pristine);
    this.getComponents().forEach((comp) => comp.setPristine(pristine));
  }

  get isPristine() {
    return this.pristine && this.getComponents().every((c) => c.isPristine);
  }

  get isDirty() {
    return this.dirty && this.getComponents().every((c) => c.isDirty);
  }

  detach() {
    this.components.forEach(component => {
      component.detach();
    });
    super.detach();
  }

  clear() {
    this.components.forEach(component => {
      component.clear();
    });
    super.clear();
  }

  destroy() {
    this.destroyComponents();
    super.destroy();
  }

  destroyComponents() {
    const components = this.getComponents().slice();
    components.forEach((comp) => this.removeComponent(comp, this.components));
    this.components = [];
  }

  get errors() {
    const thisErrors = this.error ? [this.error] : [];
    return this.getComponents()
      .reduce((errors, comp) => errors.concat(comp.errors || []), thisErrors)
      .filter(err => err.level !== 'hidden');
  }

  getValue() {
    return this.data;
  }

  resetValue() {
    this.getComponents().forEach((comp) => comp.resetValue());
    this.unset();
    this.setPristine(true);
  }

  get dataReady() {
    return NativePromise.all(this.getComponents().map((component) => component.dataReady));
  }

  setNestedValue(component, value, flags = {}) {
    component._data = this.componentContext(component);
    if (component.type === 'button') {
      return false;
    }
    if (component.type === 'components') {
      return component.setValue(value, flags);
    }
    else if (value && component.hasValue(value)) {
      return component.setValue(_.get(value, component.key), flags);
    }
    else if (!this.rootPristine || component.visible) {
      flags.noValidate = !flags.dirty;
      flags.resetValue = true;
      return component.setValue(component.defaultValue, flags);
    }
  }

  setValue(value, flags = {}) {
    if (!value) {
      return false;
    }
    return this.getComponents().reduce((changed, component) => {
      return this.setNestedValue(component, value, flags, changed) || changed;
    }, false);
  }
}