Home Reference Source

src/components/editgrid/EditGrid.js

import _ from 'lodash';
import NativePromise from 'native-promise-only';
import NestedArrayComponent from '../_classes/nestedarray/NestedArrayComponent';
import Component from '../_classes/component/Component';
import Alert from '../alert/Alert';
import { fastCloneDeep, Evaluator, getArrayFromComponentPath } from '../../utils/utils';
import templates from './templates';

const EditRowState = {
  New: 'new',
  Editing: 'editing',
  Saved: 'saved',
  Removed: 'removed',
  Draft: 'draft',
};

export default class EditGridComponent extends NestedArrayComponent {
  static schema(...extend) {
    return NestedArrayComponent.schema({
      type: 'editgrid',
      label: 'Edit Grid',
      key: 'editGrid',
      clearOnHide: true,
      input: true,
      tree: true,
      removeRow: 'Cancel',
      defaultOpen: false,
      openWhenEmpty: false,
      modal: false,
      components: [],
      inlineEdit: false,
      templates: {
        header: EditGridComponent.defaultHeaderTemplate,
        row: EditGridComponent.defaultRowTemplate,
        footer: '',
      },
    }, ...extend);
  }

  static get builderInfo() {
    return {
      title: 'Edit Grid',
      icon: 'tasks',
      group: 'data',
      documentation: 'http://help.form.io/userguide/#editgrid',
      weight: 30,
      schema: EditGridComponent.schema(),
    };
  }

  static get defaultHeaderTemplate() {
    return `<div class="row">
  {% (component.components || []).forEach(function(component) { %}
    <div class="col-sm-2">{{ component.label }}</div>
  {% }) %}
</div>`;
  }

  static get defaultRowTemplate() {
    return `<div class="row">
  {% instance.eachComponent(function(component) { %}
    <div class="col-sm-2">
      {{ component.getView(component.dataValue) }}
    </div>
  {% }, rowIndex) %}
  {% if (!instance.options.readOnly && !instance.originalComponent.disabled) { %}
    <div class="col-sm-2">
      <div class="btn-group pull-right">
        <button class="btn btn-default btn-light btn-sm editRow"><i class="{{ iconClass('edit') }}"></i></button>
        {% if (!instance.hasRemoveButtons || instance.hasRemoveButtons()) { %}
          <button class="btn btn-danger btn-sm removeRow"><i class="{{ iconClass('trash') }}"></i></button>
        {% } %}
      </div>
    </div>
  {% } %}
</div>`;
  }

  get defaultDialogTemplate() {
    return  `
    <h3 ref="dialogHeader">${this.t('Do you want to clear data?')}</h3>
    <div style="display:flex; justify-content: flex-end;">
      <button ref="dialogCancelButton" class="btn btn-secondary">${this.t('Cancel')}</button>
      <button ref="dialogYesButton" class="btn btn-primary">${this.t('Yes, delete it')}</button>
    </div>
  `;
  }

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

  get emptyValue() {
    return [];
  }

  get editgridKey() {
    return `editgrid-${this.key}`;
  }

  get rowRef() {
    return `${this.editgridKey}-row`;
  }

  get rowElements() {
    return this.refs[this.rowRef];
  }

  get addRowRef() {
    return `${this.editgridKey}-addRow`;
  }

  get addRowElements() {
    return this.refs[this.addRowRef];
  }

  get saveRowRef() {
    return `${this.editgridKey}-saveRow`;
  }

  get saveRowElements() {
    return this.refs[this.saveRowRef];
  }

  get cancelRowRef() {
    return `${this.editgridKey}-cancelRow`;
  }

  get cancelRowElements() {
    return this.refs[this.cancelRowRef];
  }

  get inlineEditMode() {
    return this.component.inlineEdit;
  }

  get saveEditMode() {
    return !this.inlineEditMode;
  }

  get minLength() {
    return _.get(this.component, 'validate.minLength', 0);
  }

  get data() {
    return this._data;
  }

  set data(value) {
    this._data = value;

    const data = this.dataValue;

    (this.editRows || []).forEach((row, index) => {
      const rowData = data[index];

      row.data = rowData;
      row.components.forEach((component) => {
        component.data = rowData;
      });
    });
  }

  get iteratableRows() {
    return this.editRows;
  }

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

    _.times(this.minLength - defaultValue.length, () => defaultValue.push({}));

    return defaultValue;
  }

  constructor(...args) {
    super(...args);
    this.type = 'editgrid';
  }

  loadRefs(element, refs) {
    super.loadRefs(element, refs);

    const massageContainerRef = 'messageContainer';

    if (refs[`${ massageContainerRef }`] === 'single') {
      this.refs[`${ massageContainerRef }`] = element.querySelector(`:scope > [ref="${ massageContainerRef }"]`);
    }
  }

  hasRemoveButtons() {
    return !this.component.disableAddingRemovingRows &&
      !this.options.readOnly &&
      !this.disabled &&
      this.fullMode &&
      (this.dataValue.length > _.get(this.component, 'validate.minLength', 0));
  }

  init() {
    if (this.builderMode) {
      this.editRows = [];
      return super.init();
    }

    this.components = this.components || [];
    const dataValue = this.dataValue || [];
    const openWhenEmpty = !dataValue.length && this.component.openWhenEmpty;
    if (openWhenEmpty) {
      const dataObj = {};
      this.editRows = [
        {
          components: this.createRowComponents(dataObj, 0),
          data: dataObj,
          state: EditRowState.New,
          backup: null,
          error: null,
        },
      ];
      if (this.inlineEditMode) {
        this.dataValue.push(dataObj);
      }
    }
    else {
      this.editRows = dataValue.map((row, rowIndex) => ({
        components: this.createRowComponents(row, rowIndex),
        data: row,
        state: EditRowState.Saved,
        backup: null,
        error: null,
      }));
    }

    this.checkData();
  }

  isOpen(editRow) {
    return [EditRowState.New, EditRowState.Editing].includes(editRow.state);
  }

  render(children) {
    if (this.builderMode) {
      return super.render();
    }

    const dataValue = this.dataValue || [];
    const headerTemplate = Evaluator.noeval ? templates.header : _.get(this.component, 'templates.header');
    return super.render(children || this.renderTemplate('editgrid', {
      ref: {
        row: this.rowRef,
        addRow: this.addRowRef,
        saveRow: this.saveRowRef,
        cancelRow: this.cancelRowRef,
      },
      header: this.renderString(headerTemplate, {
        components: this.component.components,
        value: dataValue,
      }),
      footer: this.renderString(_.get(this.component, 'templates.footer'), {
        components: this.component.components,
        value: dataValue,
      }),
      rows: this.editRows.map(this.renderRow.bind(this)),
      openRows: this.editRows.map((row) => this.isOpen(row)),
      errors: this.editRows.map((row) => row.error),
      hasAddButton: this.hasAddButton(),
      hasRemoveButtons: this.hasRemoveButtons(),
    }));
  }

  attach(element) {
    if (this.builderMode) {
      return super.attach(element);
    }

    this.loadRefs(element, {
      [this.addRowRef]: 'multiple',
      [this.saveRowRef]: 'multiple',
      [this.cancelRowRef]: 'multiple',
      [this.rowRef]: 'multiple',
    });

    this.addRowElements.forEach((addButton) => {
      this.addEventListener(addButton, 'click', () => this.addRow());
    });

    let openRowCount = 0;
    this.rowElements.forEach((row, rowIndex) => {
      const editRow = this.editRows[rowIndex];
      if (this.isOpen(editRow)) {
        this.attachComponents(row, editRow.components);
        this.addEventListener(this.saveRowElements[openRowCount], 'click', () => this.saveRow(rowIndex));
        this.addEventListener(this.cancelRowElements[openRowCount], 'click', () => this.cancelRow(rowIndex));
        openRowCount++;
      }
      else {
        // Attach edit and remove button events.
        [
          {
            className: 'removeRow',
            event: 'click',
            action: () => this.removeRow(rowIndex),
          },
          {
            className: 'editRow',
            event: 'click',
            action: () => {
              this.editRow(rowIndex).then(()=> {
                if (this.component.rowDrafts) {
                  this.validateRow(editRow, false);

                  if (this.component.modal && editRow.errors && !!editRow.errors.length ) {
                    this.alert.showErrors(editRow.errors, false);
                  }
                }
              });
            },
          },
        ].forEach(({
          className,
          event,
          action,
        }) => {
          const elements = row.getElementsByClassName(className);
          Array.prototype.forEach.call(elements, (element) => {
            this.addEventListener(element, event, action);
          });
        });
      }
    });

    // Add open class to the element if any edit grid row is open
    if (openRowCount) {
      this.addClass(this.refs.component, `formio-component-${this.component.type}-row-open`);
    }
    else {
      this.removeClass(this.refs.component, `formio-component-${this.component.type}-row-open`);
    }

    return super.attach(element);
  }

  flattenRowDataValue(dataValue) {
    const flattened = {};

    Object.keys(dataValue).forEach((key) => {
      if (_.isObject(dataValue[key]) && !_.isNil(dataValue[key])) {
        Object.assign(flattened, this.flattenRowDataValue(dataValue[key]));
      }
      else {
        flattened[key] = dataValue[key];
      }
    });

    return flattened;
  }

  renderRow(row, rowIndex) {
    const dataValue = this.dataValue || [];
    if (this.isOpen(row)) {
      return this.renderComponents(row.components);
    }
    else {
      const flattenedComponents = this.flattenComponents(rowIndex);
      const rowTemplate = Evaluator.noeval ? templates.row : _.get(this.component, 'templates.row', EditGridComponent.defaultRowTemplate);

      return this.renderString(
        rowTemplate,
        {
          row: dataValue[rowIndex] || {},
          data: this.data,
          rowIndex,
          components: this.component.components,
          flattenedComponents,
          getView: (component, data) => {
            const instance = flattenedComponents[component.key];
            let view = instance ? instance.getView(data || instance.dataValue) : '';

            if (instance && instance.widget && (view !== '--- PROTECTED ---' )) {
              if (_.isArray(view)) {
                view = view.map((value) => instance.widget.getValueAsString(value));
              }
              else {
                view = instance.widget.getValueAsString(view);
              }
            }

            return view;
          },
          state: this.editRows[rowIndex].state,
        },
      );
    }
  }

  eachComponent(fn, rowIndex) {
    _.each(this.getComponents(rowIndex), (component, index) => {
      if (fn(component, index) === false) {
        return false;
      }
    });
  }

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

  flattenComponents(rowIndex) {
    const result = {};

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

    return result;
  }

  getComponents(rowIndex) {
    // Ensure editrows is set.
    this.editRows = this.editRows || [];
    return this.builderMode
      ? super.getComponents()
      : _.isNumber(rowIndex)
        ? (this.editRows[rowIndex].components || [])
        : this.editRows.reduce((result, row) => result.concat(row.components || []), []);
  }

  destroyComponents(rowIndex) {
    if (this.builderMode) {
      return super.destroyComponents();
    }

    const components = this.getComponents(rowIndex).slice();
    components.forEach((comp) => comp.destroy());
  }

  addRow() {
    if (this.options.readOnly) {
      return;
    }

    const dataObj = {};
    const rowIndex = this.editRows.length;
    const editRow = {
      components: this.createRowComponents(dataObj, rowIndex),
      data: dataObj,
      state: EditRowState.New,
      backup: null,
      error: null,
    };
    this.editRows.push(editRow);

    if (this.inlineEditMode) {
      this.dataValue.push(dataObj);
      this.triggerChange();
    }
    this.emit('editGridAddRow', {
      component: this.component,
      row: editRow,
    });
    this.checkRow('checkData', null, {}, editRow.data, editRow.components);
    if (this.component.modal) {
      this.addRowModal(rowIndex);
    }
    else {
      this.redraw();
    }
    return editRow;
  }

  addRowModal(rowIndex) {
    const modalContent =  this.ce('div');
    const editRow = this.editRows[rowIndex];
    editRow.willBeSaved = false;
    const { components } = editRow;
    modalContent.innerHTML = this.renderComponents(components);
    const dialog = this.component.modal ? this.createModal(modalContent, {}, () => this.showDialog(rowIndex)) : undefined;
    if (this.alert) {
      this.alert.clear();
      this.alert = null;
    }
    this.alert = new Alert(dialog.refs.dialogContents, this);

    this.addEventListener(dialog, 'close', () => {
      if (!editRow.willBeSaved) {
        this.cancelRow(rowIndex);
      }
      if (this.alert) {
        this.alert.clear();
        this.alert = null;
      }
    });

    dialog.refs.dialogContents.appendChild(this.ce('button', {
      class: 'btn btn-primary',
      onClick: () => {
        if (this.validateRow(editRow, true) || this.component.rowDrafts) {
          editRow.willBeSaved = true;
          dialog.close();
          this.saveRow(rowIndex);
        }
        else {
          this.alert.showErrors(editRow.errors, false);
        }
      },
    }, this.component.saveRow || 'Save'));

    return this.attachComponents(modalContent, components);
  }

  showDialog(rowIndex) {
    const editRow = this.editRows[rowIndex];
    if (_.isEqual(editRow.backup, editRow.data)) {
      return NativePromise.resolve();
    }

    const wrapper = this.ce('div', { ref: 'confirmationDialog' });
    const dialogContent =this.component.dialogTemplate || this.defaultDialogTemplate;

    wrapper.innerHTML = dialogContent;
    wrapper.refs = {};
    this.loadRefs.call(wrapper, wrapper, {
      dialogHeader: 'single',
      dialogCancelButton: 'single',
      dialogYesButton: 'single',
    });

    const dialog = this.createModal(wrapper);
    const close = (event) => {
      event.preventDefault();
      dialog.close();
    };
    let dialogResult;

    const promise = new NativePromise((resolve, reject) => {
      dialogResult = { resolve, reject };
    });

    this.addEventListener(wrapper.refs.dialogYesButton, 'click', (event) => {
      close(event);
      dialogResult.resolve();
    });
    this.addEventListener(wrapper.refs.dialogCancelButton, 'click', (event) => {
      close(event);
      dialogResult.reject();
    });
    return promise;
  }

  editRow(rowIndex) {
    const editRow = this.editRows[rowIndex];
    const isAlreadyEditing = editRow.state === EditRowState.Editing || editRow.state === EditRowState.New;
    if (!editRow || isAlreadyEditing) {
      return;
    }
    editRow.prevState = editRow.state;

    if (!this.options.readOnly) {
      editRow.state = EditRowState.Editing;
    }

    const dataSnapshot = fastCloneDeep(editRow.data);

    if (this.inlineEditMode) {
      editRow.backup = dataSnapshot;
    }
    else {
      editRow.backup = editRow.data;
      editRow.data = dataSnapshot;
      this.restoreRowContext(editRow);
    }

    if (this.component.modal) {
      return this.addRowModal(rowIndex);
    }

    return this.redraw();
  }

  clearErrors(rowIndex) {
    const editRow = this.editRows[rowIndex];
    if (editRow && Array.isArray(editRow.components)) {
      editRow.components.forEach((comp) => {
        comp.setPristine(true);
        comp.setCustomValidity('');
      });
    }
  }

  cancelRow(rowIndex) {
    if (this.options.readOnly) {
      return;
    }

    const editRow = this.editRows[rowIndex];
    switch (editRow.state) {
      case EditRowState.New: {
        editRow.state = EditRowState.Removed;

        this.clearErrors(rowIndex);
        this.destroyComponents(rowIndex);
        if (this.inlineEditMode) {
          this.splice(rowIndex);
        }
        this.editRows.splice(rowIndex, 1);
        break;
      }
      case EditRowState.Editing: {
        editRow.state = editRow.prevState;

        if (this.inlineEditMode) {
          this.dataValue[rowIndex] = editRow.backup;
        }
        editRow.data = editRow.backup;
        editRow.backup = null;
        this.restoreRowContext(editRow);
        if (!this.component.rowDrafts) {
          this.clearErrors(rowIndex);
        }
        break;
      }
    }

    this.checkValidity(null, true);
    this.redraw();

    if (this.component.rowDrafts) {
      this.checkValidity(this.data, false);
    }
  }

  saveRow(rowIndex) {
    const editRow = this.editRows[rowIndex];

    if (this.options.readOnly) {
      return;
    }

    const isRowValid = this.validateRow(editRow, true);

    if (!this.component.rowDrafts) {
      if (!isRowValid) {
        return false;
      }
    }

    if (this.saveEditMode) {
      const dataValue = this.dataValue || [];
      switch (editRow.state) {
        case EditRowState.New: {
          const newIndex = dataValue.length;
          dataValue.push(editRow.data);
          if (rowIndex !== newIndex) {
            this.editRows.splice(rowIndex, 1);
            this.editRows.splice(newIndex, 0, editRow);
          }
          break;
        }
        case EditRowState.Editing: {
          dataValue[rowIndex] = editRow.data;
          break;
        }
      }
    }

    editRow.state = this.component.rowDrafts && !isRowValid ? EditRowState.Draft : EditRowState.Saved;
    editRow.backup = null;

    this.updateValue();
    this.triggerChange();
    if (this.component.rowDrafts) {
      editRow.components.forEach(comp => comp.setPristine(this.pristine));
    }
    this.checkValidity(null, true);
    this.redraw();

    return true;
  }

  beforeFocus(component) {
    if ('beforeFocus' in this.parent) {
      this.parent.beforeFocus(this);
    }
    const relativePath = this.getRelativePath(component.path);
    const arrayPath = getArrayFromComponentPath(relativePath);
    if (_.isNumber(arrayPath[0])) {
      this.editRow(arrayPath[0]);
    }
  }

  updateComponentsRowIndex(components, rowIndex) {
    components.forEach((component, colIndex) => {
      component.rowIndex = rowIndex;
      component.row = `${rowIndex}-${colIndex}`;
    });
  }

  updateRowsComponents(rowIndex) {
    this.editRows.slice(rowIndex).forEach((row, index) => {
      this.updateComponentsRowIndex(row.components, index);
    });
  }

  baseRemoveRow(rowIndex) {
    const editRow = this.editRows[rowIndex];

    editRow.state = EditRowState.Removed;
    this.destroyComponents(rowIndex);

    return editRow;
  }

  removeRow(rowIndex) {
    if (this.options.readOnly) {
      return;
    }

    this.baseRemoveRow(rowIndex);
    this.splice(rowIndex);
    this.editRows.splice(rowIndex, 1);
    this.updateRowsComponents(rowIndex);
    this.updateValue();
    this.triggerChange();
    this.checkValidity(null, true);
    this.checkData();
    this.redraw();
  }

  createRowComponents(row, rowIndex) {
    return this.component.components.map((col, colIndex) => {
      const column = _.clone(col);
      const options = _.clone(this.options);
      options.name += `[${rowIndex}]`;
      options.row = `${rowIndex}-${colIndex}`;
      options.onChange = (flags, changed, modified) => {
        const editRow = this.editRows[rowIndex];
        if (this.inlineEditMode) {
          this.triggerRootChange(flags, changed, modified);
        }
        else if (editRow) {
          this.checkRow('checkData', null, {
            ...flags,
            changed,
          }, editRow.data, editRow.components);
        }
      };

      const comp = this.createComponent(_.assign({}, column, {
        row: options.row,
      }), options, row);
      comp.rowIndex = rowIndex;
      return comp;
    });
  }

  hasOpenRows() {
    return this.editRows.some(row => this.isOpen(row));
  }

  validateRow(editRow, dirty) {
    let valid = true;
    const errorsSnapshot = [...this.errors];

    if (editRow.state === EditRowState.Editing || dirty || (editRow.state === EditRowState.Draft && !this.pristine && !this.root.pristine && !this.hasOpenRows())) {
      editRow.components.forEach(comp => {
        if (!this.component.rowDrafts) {
          comp.setPristine(!dirty);
        }

        valid &= comp.checkValidity(null, dirty, editRow.data);
      });
    }

    if (this.component.validate && this.component.validate.row) {
      valid = this.evaluate(this.component.validate.row, {
        valid,
        row: editRow.data
      }, 'valid', true);
      if (valid.toString() !== 'true') {
        editRow.error = valid;
        valid = false;
      }
      else {
        editRow.error = null;
      }
      if (valid === null) {
        valid = `Invalid row validation for ${this.key}`;
      }
    }

    editRow.errors = !valid ? this.errors.filter((err) => !errorsSnapshot.includes(err)) : null;

    return !!valid;
  }

  checkValidity(data, dirty, row) {
    data = data || this.rootValue;
    row = row || this.data;

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

    return this.checkComponentValidity(data, dirty, row);
  }

  checkComponentValidity(data, dirty, row) {
    if (!super.checkComponentValidity(data, dirty, row)) {
      return false;
    }

    if (this.shouldSkipValidation(data, dirty, row)) {
      return true;
    }

    let rowsValid = true;
    let rowsEditing = false;

    this.editRows.forEach((editRow, index) => {
      // Trigger all errors on the row.
      const rowValid = this.validateRow(editRow, dirty);

      rowsValid &= rowValid;

      const rowRefs = this.refs[`editgrid-${this.component.key}-row`];

      if (rowRefs) {
        const rowContainer = rowRefs[index];

        if (rowContainer) {
          const errorContainer = rowContainer.querySelector('.editgrid-row-error');

          if (!rowValid ) {
            errorContainer.textContent = 'Invalid row. Please correct it or delete.';
          }
        }
      }
      // If this is a dirty check, and any rows are still editing, we need to throw validation error.
      rowsEditing |= (dirty && this.isOpen(editRow));
    });

    if (!rowsValid) {
      this.setCustomValidity('Please correct invalid rows before proceeding.', dirty);
      return false;
    }
    else if (rowsEditing && this.saveEditMode) {
      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;
  }

  setValue(value, flags = {}) {
    if (!value) {
      value = this.defaultValue;
    }

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

    const changed = this.hasChanged(value, this.dataValue);
    this.dataValue = value;
    // Refresh editRow data when data changes.
    this.dataValue.forEach((row, rowIndex) => {
      const editRow = this.editRows[rowIndex];
      if (editRow) {
        editRow.data = row;
        this.restoreRowContext(editRow, flags);
        editRow.state = EditRowState.Saved;
        editRow.backup = null;
        editRow.error = null;
      }
      else {
        this.editRows[rowIndex] = {
          components: this.createRowComponents(row, rowIndex),
          data: row,
          state: EditRowState.Saved,
          backup: null,
          error: null,
        };
      }
    });
    let { length: dataLength } = this.dataValue;

    // If the last row is a new row, then do not remove it.
    if (this.editRows[dataLength] && (this.editRows[dataLength].state === EditRowState.New)) {
      dataLength = (dataLength + 1);
    }
    this.editRows.slice(dataLength).forEach((editRow, index) => this.baseRemoveRow(dataLength + index));
    this.editRows = this.editRows.slice(0, dataLength);
    this.updateOnChange(flags, changed);
    this.checkData();
    if (changed || flags.resetValue) {
      this.rebuild();
    }
    return changed;
  }

  restoreRowContext(editRow, flags = {}) {
    editRow.components.forEach((component) => {
      component.data = editRow.data;
      this.setNestedValue(component, editRow.data, flags);
    });
  }
}

EditGridComponent.prototype.hasChanged = Component.prototype.hasChanged;