Home Reference Source

src/components/editrgrid/EditGrid.js

import _ from 'lodash';

import {FormioComponents} from '../Components';
import FormioUtils from '../../utils';

export class EditGridComponent extends FormioComponents {
  constructor(component, options, data) {
    super(component, options, data);
    this.type = 'datagrid';
    this.editRows = [];
    if (this.options.components) {
      this.create = _.bind(this.options.components.create, this.options.components, _, this.options, _, true);
    }
  }

  get emptyValue() {
    return [];
  }

  build() {
    this.createElement();
    this.createLabel(this.element);
    this.buildTable();
    this.createDescription(this.element);
    this.createAddButton();
    this.element.appendChild(this.errorContainer = this.ce('div', {class: 'has-error'}));
  }

  buildTable() {
    if (this.tableElement) {
      this.tableElement.innerHTML = '';
    }

    let tableClass = 'editgrid-listgroup list-group ';
    _.each(['striped', 'bordered', 'hover', 'condensed'], (prop) => {
      if (this.component[prop]) {
        tableClass += `table-${prop} `;
      }
    });
    this.tableElement = this.ce('ul', {class: tableClass}, [
      this.headerElement = this.createHeader(),
      this.rowElements = _.map(this.rows, this.createRow.bind(this)),
      this.footerElement = this.createFooter(),
    ]);

    this.element.appendChild(this.tableElement);
  }

  createHeader() {
    const templateHeader = _.get(this.component, 'templates.header');
    if (!templateHeader) {
      return this.text('');
    }
    return this.ce('li', {class: 'list-group-item list-group-header'}, this.renderTemplate(templateHeader, {
      components: this.component.components,
      util: FormioUtils,
      value: this.dataValue,
      data: this.data
    }));
  }

  get defaultRowTemplate() {
    return `<div class="row">
      {% util.eachComponent(components, function(component) { %}
        <div class="col-sm-2">
          {{ getView(component, row[component.key]) }}
        </div>
      {% }) %}
      <div class="col-sm-2">
        <div class="btn-group pull-right">
          <div class="btn btn-default editRow">Edit</div>
          <div class="btn btn-danger removeRow">Delete</div>
        </div>
      </div>
    </div>`;
  }

  createRow(row, rowIndex) {
    const wrapper = this.ce('li', {class: 'list-group-item'});
    const rowTemplate = _.get(this.component, 'templates.row', this.defaultRowTemplate);

    // Store info so we can detect changes later.
    wrapper.rowData = row;
    wrapper.rowIndex = rowIndex;
    wrapper.rowOpen = this.editRows[rowIndex].isOpen;
    this.editRows[rowIndex].components = [];

    if (wrapper.rowOpen) {
      wrapper.appendChild(
        this.ce('div', {class: 'editgrid-edit'},
          this.ce('div', {class: 'editgrid-body'},
            [
              this.component.components.map(comp => {
                const component = _.cloneDeep(comp);
                component.row = `${this.row}-${rowIndex}`;
                const options = _.clone(this.options);
                options.name += `[${rowIndex}]`;
                const instance = this.createComponent(component, options, this.editRows[rowIndex].data);
                this.editRows[rowIndex].components.push(instance);
                return instance.element;
              }),
              this.ce('div', {class: 'editgrid-actions'},
                [
                  this.ce('div', {
                    class: 'btn btn-primary',
                    onClick: this.saveRow.bind(this, rowIndex)
                  }, this.component.saveRow || 'Save'),
                  ' ',
                  this.component.removeRow ?
                    this.ce('div', {
                      class: 'btn btn-danger',
                      onClick: this.cancelRow.bind(this, rowIndex)
                    }, this.component.removeRow || 'Cancel')
                    : null
                ]
              )
            ]
          )
        )
      );
    }
    else {
      const create = this.create;
      wrapper.appendChild(
        this.renderTemplate(rowTemplate,
          {
            data: this.data,
            row,
            rowIndex,
            components: this.component.components,
            getView(component, data) {
              return create(component, data).getView(data);
            },
            util: FormioUtils
          },
          [
            {
              class: 'removeRow',
              event: 'click',
              action: this.removeRow.bind(this, rowIndex)
            },
            {
              class: 'editRow',
              event: 'click',
              action: this.editRow.bind(this, rowIndex)
            }
          ]
        )
      );
    }
    wrapper.appendChild(this.editRows[rowIndex].errorContainer = this.ce('div', {class: 'has-error'}));
    this.checkData(this.data, {noValidate: true}, rowIndex);
    return wrapper;
  }

  createFooter() {
    const footerTemplate = _.get(this.component, 'templates.footer');
    if (!footerTemplate) {
      return this.text('');
    }
    return this.ce('li', {class: 'list-group-item list-group-footer'}, this.renderTemplate(footerTemplate, {
      components: this.component.components,
      util: FormioUtils,
      value: this.dataValue,
      data: this.data
    }));
  }

  checkData(data, flags = {}, index) {
    let valid = true;
    if (flags.noCheck) {
      return;
    }

    // Update the value.
    let changed = this.updateValue({
      noUpdateEvent: true
    });

    // Iterate through all components and check conditions, and calculate values.
    this.editRows[index].components.forEach(comp => {
      changed |= comp.calculateValue(data, {
        noUpdateEvent: true
      });
      comp.checkConditions(this.editRows[index].data);
      if (!flags.noValidate) {
        valid &= comp.checkValidity(this.editRows[index].data, !this.editRows[index].isOpen);
      }
    });

    valid &= this.validateRow(index, false);

    // Trigger the change if the values changed.
    if (changed) {
      this.triggerChange(flags);
    }

    // Return if the value is valid.
    return valid;
  }

  createAddButton() {
    this.element.appendChild(this.ce('div', {class: 'editgrid-add'},
      this.ce('button', {
        class: 'btn btn-primary',
        role: 'button',
        onClick: this.addRow.bind(this)
      },
      [
        this.ce('span', {class: this.iconClass('plus'), 'aria-hidden': true}),
        ' ',
        this.t(this.component.addAnother ? this.component.addAnother : 'Add Another', {})
      ])
    ));
  }

  refreshDOM() {
    const newHeader = this.createHeader();
    this.tableElement.replaceChild(newHeader, this.headerElement);
    this.headerElement = newHeader;

    const newFooter = this.createFooter();
    this.tableElement.replaceChild(newFooter, this.footerElement);
    this.footerElement = newFooter;

    this.editRows.forEach((editRow, rowIndex) => {
      if (!editRow.element) {
        // New row
        editRow.element = this.createRow(editRow.data, rowIndex);
        this.tableElement.insertBefore(editRow.element, this.tableElement.children[rowIndex + 1]);
      }
      else if (
        editRow.element.rowData !== editRow.data ||
        editRow.element.rowIndex !== rowIndex ||
        editRow.element.rowOpen !== editRow.isOpen
      ) {
        // Row has changed due to an edit or delete.
        this.removeRowComponents(rowIndex);
        const newRow = this.createRow(editRow.data, rowIndex);
        this.tableElement.replaceChild(newRow, editRow.element);
        editRow.element = newRow;
      }
    });
  }

  addRow() {
    if (this.options.readOnly) {
      return;
    }
    this.editRows.push({
      isOpen: true,
      data: {}
    });
    this.updateValue();
    this.refreshDOM();
  }

  editRow(rowIndex) {
    this.editRows[rowIndex].isOpen = true;
    this.editRows[rowIndex].data = _.cloneDeep(this.dataValue[rowIndex]);
    this.refreshDOM();
  }

  cancelRow(rowIndex) {
    if (this.options.readOnly) {
      this.editRows[rowIndex].isOpen = false;
      this.removeRowComponents(rowIndex);
      this.refreshDOM();
      return;
    }
    this.removeRowComponents(rowIndex);
    // Remove if new.
    if (!this.dataValue[rowIndex]) {
      this.removeChildFrom(this.editRows[rowIndex].element, this.tableElement);
      this.editRows.splice(rowIndex, 1);
      this.splice(rowIndex);
    }
    else {
      this.editRows[rowIndex].isOpen = false;
      this.editRows[rowIndex].data = this.dataValue[rowIndex];
    }
    this.refreshDOM();
  }

  saveRow(rowIndex) {
    if (this.options.readOnly) {
      this.editRows[rowIndex].isOpen = false;
      this.removeRowComponents(rowIndex);
      this.refreshDOM();
      return;
    }
    if (!this.validateRow(rowIndex, true)) {
      return;
    }
    this.removeRowComponents(rowIndex);
    this.dataValue[rowIndex] = this.editRows[rowIndex].data;
    this.editRows[rowIndex].isOpen = false;
    this.checkValidity(this.data, true);
    this.updateValue();
    this.refreshDOM();
  }

  removeRow(rowIndex) {
    if (this.options.readOnly) {
      return;
    }
    this.removeRowComponents(rowIndex);
    this.splice(rowIndex);
    this.removeChildFrom(this.editRows[rowIndex].element, this.tableElement);
    this.editRows.splice(rowIndex, 1);
    this.updateValue();
    this.refreshDOM();
  }

  removeRowComponents(rowIndex) {
    // Clean up components list.
    this.editRows[rowIndex].components.forEach(comp => {
      this.removeComponent(comp, this.components);
    });
    this.editRows[rowIndex].components = [];
  }

  validateRow(rowIndex, dirty) {
    let check = true;
    this.editRows[rowIndex].components.forEach(comp => {
      comp.setPristine(!dirty);
      check &= comp.checkValidity(this.editRows[rowIndex].data, dirty);
    });

    if (this.component.validate && this.component.validate.row) {
      let custom = this.component.validate.row;
      custom = custom.replace(/({{\s+(.*)\s+}})/, (match, $1, $2) => {
        return this.editRows[rowIndex].data[$2];
      });
      let valid;
      try {
        const row = this.editRows[rowIndex].data;
        const data = this.data;
        valid = new Function('row', 'data', `${custom}; return valid;`)(row, data);
      }
      catch (e) {
        /* eslint-disable no-console, no-undef */
        console.warn(`A syntax error occurred while computing custom values in ${this.component.key}`, e);
        /* eslint-enable no-console */
      }
      this.editRows[rowIndex].errorContainer.innerHTML = '';
      if (valid !== true) {
        this.editRows[rowIndex].errorContainer.appendChild(
          this.ce('div', {class: 'editgrid-row-error help-block'}, valid)
        );
        return false;
      }
    }

    return check;
  }

  checkValidity(data, dirty) {
    if (!FormioUtils.checkCondition(this.component, data, this.data)) {
      return true;
    }

    let rowsValid = true;
    let rowsClosed = true;
    this.editRows.forEach((editRow, rowIndex) => {
      // Trigger all errors on the row.
      const rowValid = this.validateRow(rowIndex, false);
      // Add has-error class to row.
      if (!rowValid) {
        this.addClass(this.editRows[rowIndex].element, 'has-error');
      }
      else {
        this.removeClass(this.editRows[rowIndex].element, 'has-error');
      }
      rowsValid &= rowValid;

      // Any open rows causes validation to fail.
      rowsClosed &= !editRow.isOpen;
    });

    if (!rowsValid) {
      this.setCustomValidity('Please correct rows before proceeding.', dirty);
      return false;
    }
    else if (!rowsClosed) {
      this.setCustomValidity('Please save all rows before proceeding.', dirty);
      return false;
    }

    const message = this.invalid || this.invalidMessage(data, dirty);
    this.setCustomValidity(message, dirty);
    return true;
  }

  setCustomValidity(message, dirty) {
    if (this.errorElement && this.errorContainer) {
      this.errorElement.innerHTML = '';
      this.removeChildFrom(this.errorElement, this.errorContainer);
    }
    this.removeClass(this.element, 'has-error');
    if (this.options.highlightErrors) {
      this.removeClass(this.element, 'alert alert-danger');
    }
    if (message) {
      this.emit('componentError', this.error);
      this.createErrorElement();
      const errorMessage = this.ce('p', {
        class: 'help-block'
      });
      errorMessage.appendChild(this.text(message));
      this.appendTo(errorMessage, this.errorElement);
      // Add error classes
      this.addClass(this.element, 'has-error');
      if (dirty && this.options.highlightErrors) {
        this.addClass(this.element, 'alert alert-danger');
      }
    }
  }

  get defaultValue() {
    const value = super.defaultValue;
    return Array.isArray(value) ? value : [];
  }

  setValue(value) {
    if (!value) {
      return;
    }
    if (!Array.isArray(value)) {
      if (typeof value === 'object') {
        value = [value];
      }
      else {
        return;
      }
    }

    this.dataValue = value;
    // Refresh editRow data when data changes.
    this.dataValue.forEach((row, rowIndex) => {
      if (this.editRows[rowIndex]) {
        this.editRows[rowIndex].data = row;
      }
      else {
        this.editRows[rowIndex] = {
          isOpen: false,
          data: row
        };
      }
    });
    // Remove any extra edit rows.
    if (this.dataValue.length < this.editRows.length) {
      for (let rowIndex = this.editRows.length - 1; rowIndex >= this.dataValue.length; rowIndex--) {
        this.removeRowComponents(rowIndex);
        this.removeChildFrom(this.editRows[rowIndex].element, this.tableElement);
        this.editRows.splice(rowIndex, 1);
      }
    }
    this.refreshDOM();
  }

  /**
   * Get the value of this component.
   *
   * @returns {*}
   */
  getValue() {
    return this.dataValue;
  }
}