src/components/datagrid/DataGrid.js
import _ from 'lodash';
import NestedArrayComponent from '../_classes/nestedarray/NestedArrayComponent';
import { fastCloneDeep } from '../../utils/utils';
let dragula;
if (typeof window !== 'undefined') {
// Import from "dist" because it would require and "global" would not be defined in Angular apps.
dragula = require('dragula/dist/dragula');
}
export default class DataGridComponent extends NestedArrayComponent {
static schema(...extend) {
return NestedArrayComponent.schema({
label: 'Data Grid',
key: 'dataGrid',
type: 'datagrid',
clearOnHide: true,
input: true,
tree: true,
components: []
}, ...extend);
}
static get builderInfo() {
return {
title: 'Data Grid',
icon: 'th',
group: 'data',
documentation: '/userguide/#datagrid',
weight: 30,
schema: DataGridComponent.schema()
};
}
constructor(...args) {
super(...args);
this.type = 'datagrid';
this.tabIndex = 0;
}
init() {
this.components = this.components || [];
// Add new values based on minLength.
this.rows = [];
if (this.initRows) {
this.createRows(true);
}
this.visibleColumns = {};
this.checkColumns();
}
get dataValue() {
const dataValue = super.dataValue;
if (!dataValue || !Array.isArray(dataValue)) {
return this.emptyValue;
}
return dataValue;
}
set dataValue(value) {
super.dataValue = value;
}
get defaultSchema() {
return DataGridComponent.schema();
}
get initEmpty() {
return this.component.initEmpty || this.component.noFirstRow;
}
get initRows() {
return this.builderMode || this.path === 'defaultValue' || !this.initEmpty;
}
get emptyValue() {
return this.initEmpty ? [] : [{}];
}
get addAnotherPosition() {
return _.get(this.component, 'addAnotherPosition', 'bottom');
}
get minLength() {
if (this.hasRowGroups()) {
return _.sum(this.getGroupSizes());
}
else {
return _.get(this.component, 'validate.minLength', 0);
}
}
get defaultValue() {
const isBuilderMode = this.builderMode;
const isEmptyInit = this.initEmpty;
// Ensure we have one and only one row in builder mode.
if (isBuilderMode || (isEmptyInit && !this.dataValue.length)) {
return isEmptyInit && !isBuilderMode ? [] : [{}];
}
const value = super.defaultValue;
let defaultValue;
if (Array.isArray(value)) {
defaultValue = value;
}
else if (value && (typeof value === 'object')) {
defaultValue = [value];
}
else {
defaultValue = this.emptyValue;
}
for (let dIndex = defaultValue.length; dIndex < this.minLength; dIndex++) {
defaultValue.push({});
}
return defaultValue;
}
set disabled(disabled) {
super.disabled = disabled;
_.each(this.refs[`${this.datagridKey}-addRow`], (button) => {
button.disabled = disabled;
});
_.each(this.refs[`${this.datagridKey}-removeRow`], (button) => {
button.disabled = disabled;
});
}
get disabled() {
return super.disabled;
}
get datagridKey() {
return `datagrid-${this.key}`;
}
get allowReorder() {
return !this.options.readOnly && _.get(this.component, 'reorder', false);
}
get iteratableRows() {
return this.rows.map((row, index) => ({
components: row,
data: this.dataValue[index],
}));
}
/**
* Split rows into chunks.
* @param {Number[]} groups - array of numbers where each item is size of group
* @param {Array<T>} rows - rows collection
* @return {Array<T[]>}
*/
getRowChunks(groups, rows) {
const [, chunks] = groups.reduce(
([startIndex, acc], size) => {
const endIndex = startIndex + size;
return [endIndex, [...acc, [startIndex, endIndex]]];
}, [0, []]
);
return chunks.map(range => _.slice(rows, ...range));
}
/**
* Create groups object.
* Each key in object represents index of first row in group.
* @return {Object}
*/
getGroups() {
const groups = _.get(this.component, 'rowGroups', []);
const sizes = _.map(groups, 'numberOfRows').slice(0, -1);
const indexes = sizes.reduce((groupIndexes, size) => {
const last = groupIndexes[groupIndexes.length - 1];
return groupIndexes.concat(last + size);
}, [0]);
return groups.reduce(
(gidxs, group, idx) => {
return {
...gidxs,
[indexes[idx]]: group
};
},
{}
);
}
/**
* Retrun group sizes.
* @return {Number[]}
*/
getGroupSizes() {
return _.map(_.get(this.component, 'rowGroups', []), 'numberOfRows');
}
hasRowGroups() {
return _.get(this, 'component.enableRowGroups', false) && !this.builderMode;
}
totalRowsNumber(groups) {
return _.sum(_.map(groups, 'numberOfRows'));
}
setStaticValue(n) {
this.dataValue = _.range(n).map(() => ({}));
}
hasExtraColumn() {
return (this.hasRemoveButtons() || this.canAddColumn);
}
hasRemoveButtons() {
return !this.component.disableAddingRemovingRows &&
!this.options.readOnly &&
!this.disabled &&
this.fullMode &&
(this.dataValue.length > _.get(this.component, 'validate.minLength', 0));
}
hasTopSubmit() {
return this.hasAddButton() && ['top', 'both'].includes(this.addAnotherPosition);
}
hasBottomSubmit() {
return this.hasAddButton() && ['bottom', 'both'].includes(this.addAnotherPosition);
}
get canAddColumn() {
return this.builderMode;
}
render() {
const columns = this.getColumns();
const layoutFixed = columns.some(col => col.type === 'select') || this.component.layoutFixed;
return super.render(this.renderTemplate('datagrid', {
rows: this.getRows(),
columns: columns,
groups: this.hasRowGroups() ? this.getGroups() : [],
visibleColumns: this.visibleColumns,
hasToggle: _.get(this, 'component.groupToggle', false),
hasHeader: this.hasHeader(),
hasExtraColumn: this.hasExtraColumn(),
hasAddButton: this.hasAddButton(),
hasRemoveButtons: this.hasRemoveButtons(),
hasTopSubmit: this.hasTopSubmit(),
hasBottomSubmit: this.hasBottomSubmit(),
hasGroups: this.hasRowGroups(),
numColumns: columns.length + (this.hasExtraColumn() ? 1 : 0),
datagridKey: this.datagridKey,
allowReorder: this.allowReorder,
builder: this.builderMode,
canAddColumn: this.canAddColumn,
tabIndex: this.tabIndex,
placeholder: this.renderTemplate('builderPlaceholder', {
position: this.componentComponents.length,
}),
layoutFixed
}));
}
getRows() {
return this.rows.map(row => {
const components = {};
_.each(row, (col, key) => {
components[key] = col.render();
});
return components;
});
}
getColumns() {
return this.component.components.filter((comp) => {
return (!this.visibleColumns.hasOwnProperty(comp.key) || this.visibleColumns[comp.key]);
});
}
hasHeader() {
return this.component.components.reduce((hasHeader, col) => {
// If any of the components has a title and it isn't hidden, display the header.
return hasHeader || ((col.label || col.title) && !col.hideLabel);
}, false);
}
attach(element) {
this.loadRefs(element, {
[`${this.datagridKey}-row`]: 'multiple',
[`${this.datagridKey}-tbody`]: 'single',
[`${this.datagridKey}-addRow`]: 'multiple',
[`${this.datagridKey}-removeRow`]: 'multiple',
[`${this.datagridKey}-group-header`]: 'multiple',
[this.datagridKey]: 'multiple',
});
if (this.allowReorder) {
this.refs[`${this.datagridKey}-row`].forEach((row, index) => {
row.dragInfo = { index };
});
if (dragula) {
this.dragula = dragula([this.refs[`${this.datagridKey}-tbody`]], {
moves: (_draggedElement, _oldParent, clickedElement) => {
const clickedElementKey = clickedElement.getAttribute('data-key');
const oldParentKey = _oldParent.getAttribute('data-key');
//Check if the clicked button belongs to that container, if false, it belongs to the nested container
if (oldParentKey === clickedElementKey) {
return clickedElement.classList.contains('formio-drag-button');
}
}
}).on('drop', this.onReorder.bind(this));
}
}
this.refs[`${this.datagridKey}-addRow`].forEach((addButton) => {
this.addEventListener(addButton, 'click', this.addRow.bind(this));
});
this.refs[`${this.datagridKey}-removeRow`].forEach((removeButton, index) => {
this.addEventListener(removeButton, 'click', this.removeRow.bind(this, index));
});
if (this.hasRowGroups()) {
this.refs.chunks = this.getRowChunks(this.getGroupSizes(), this.refs[`${this.datagridKey}-row`]);
this.refs[`${this.datagridKey}-group-header`].forEach((header, index) => {
this.addEventListener(header, 'click', () => this.toggleGroup(header, index));
});
}
const columns = this.getColumns();
const rowLength = columns.length;
this.rows.forEach((row, rowIndex) => {
let columnIndex = 0;
columns.forEach((col) => {
this.attachComponents(
this.refs[this.datagridKey][(rowIndex * rowLength) + columnIndex],
[this.rows[rowIndex][col.key]],
this.component.components
);
columnIndex++;
});
});
return super.attach(element);
}
onReorder(element, _target, _source, sibling) {
if (!element.dragInfo || (sibling && !sibling.dragInfo)) {
console.warn('There is no Drag Info available for either dragged or sibling element');
return;
}
const oldPosition = element.dragInfo.index;
//should drop at next sibling position; no next sibling means drop to last position
const newPosition = sibling ? sibling.dragInfo.index : this.dataValue.length;
const movedBelow = newPosition > oldPosition;
const dataValue = fastCloneDeep(this.dataValue);
const draggedRowData = dataValue[oldPosition];
//insert element at new position
dataValue.splice(newPosition, 0, draggedRowData);
//remove element from old position (if was moved above, after insertion it's at +1 index)
dataValue.splice(movedBelow ? oldPosition : oldPosition + 1, 1);
//need to re-build rows to re-calculate indexes and other indexed fields for component instance (like rows for ex.)
this.setValue(dataValue, { isReordered: true });
this.rebuild();
}
addRow() {
const index = this.rows.length;
// Handle length mismatch between rows and dataValue
if (this.dataValue.length === index) {
this.dataValue.push({});
}
let row;
const dataValue = this.dataValue;
const defaultValue = this.defaultValue;
if (this.initEmpty && defaultValue[index]) {
row = defaultValue[index];
dataValue[index] = row;
}
else {
row = dataValue[index];
}
this.rows[index] = this.createRowComponents(row, index);
this.checkConditions();
this.triggerChange();
this.redraw();
}
removeRow(index) {
this.splice(index);
const [row] = this.rows.splice(index, 1);
this.removeRowComponents(row);
this.setValue(this.dataValue, { isReordered: true });
this.redraw();
}
removeRowComponents(row) {
_.each(row, (component) => this.removeComponent(component));
}
getRowValues() {
return this.dataValue;
}
setRowComponentsData(rowIndex, rowData) {
_.each(this.rows[rowIndex], (component) => {
component.data = rowData;
});
}
createRows(init, rebuild) {
let added = false;
const rowValues = this.getRowValues();
// Create any missing rows.
rowValues.forEach((row, index) => {
if (!rebuild && this.rows[index]) {
this.setRowComponentsData(index, row);
}
else {
this.rows[index] = this.createRowComponents(row, index);
added = true;
}
});
// Delete any extra rows.
const removedRows = this.rows.splice(rowValues.length);
const removed = !!removedRows.length;
// Delete components of extra rows (to make sure that this.components contain only components of exisiting rows)
if (removed) {
removedRows.forEach(row => this.removeRowComponents(row));
}
if (!init && (added || removed)) {
this.redraw();
}
return added;
}
createRowComponents(row, rowIndex) {
const components = {};
this.tabIndex = 0;
this.component.components.map((col, colIndex) => {
const options = _.clone(this.options);
options.name += `[${rowIndex}]`;
options.row = `${rowIndex}-${colIndex}`;
let columnComponent;
if (this.builderMode) {
col.id = col.id + rowIndex;
columnComponent = col;
}
else {
columnComponent = { ...col, id: (col.id + rowIndex) };
}
const component = this.createComponent(columnComponent, options, row);
component.parentDisabled = !!this.disabled;
component.rowIndex = rowIndex;
component.inDataGrid = true;
if (
columnComponent.tabindex &&
parseInt(columnComponent.tabindex) > this.tabIndex
) {
this.tabIndex = parseInt(columnComponent.tabindex);
}
components[col.key] = component;
});
return components;
}
/**
* Checks the validity of this datagrid.
*
* @param data
* @param dirty
* @return {*}
*/
checkValidity(data, dirty, row, silentCheck) {
data = data || this.rootValue;
row = row || this.data;
if (!this.checkCondition(row, data)) {
this.setCustomValidity('');
return true;
}
if (!this.checkComponentValidity(data, dirty, row, { silentCheck })) {
return false;
}
const isValid = this.checkRows('checkValidity', data, dirty, true, silentCheck);
this.checkModal(isValid, dirty);
return isValid;
}
checkColumns(data, flags = {}) {
data = data || this.rootValue;
let show = false;
if (!this.rows || !this.rows.length) {
return { rebuild: false, show: false };
}
if (this.builderMode) {
return { rebuild: false, show: true };
}
const visibility = {};
const dataValue = this.dataValue;
this.rows.forEach((row, rowIndex) => {
_.each(row, (col, key) => {
if (col && (typeof col.checkConditions === 'function')) {
visibility[key] = !!visibility[key] ||
(col.checkConditions(data, flags, dataValue[rowIndex]) && col.type !== 'hidden');
}
});
});
const rebuild = !_.isEqual(visibility, this.visibleColumns);
_.each(visibility, (col) => {
show |= col;
});
this.visibleColumns = visibility;
return { rebuild, show };
}
checkComponentConditions(data, flags, row) {
const isVisible = this.visible;
// If table isn't visible, don't bother calculating columns.
if (!super.checkComponentConditions(data, flags, row)) {
return false;
}
const { rebuild, show } = this.checkColumns(data, flags);
// Check if a rebuild is needed or the visibility changes.
if (rebuild || !isVisible) {
this.createRows(false, rebuild);
}
// Return if this table should show.
return show;
}
setValue(value, flags = {}) {
if (!value) {
this.dataValue = this.defaultValue;
this.createRows();
return false;
}
if (!Array.isArray(value)) {
if (typeof value === 'object') {
value = [value];
}
else {
this.createRows();
value = [{}];
}
}
// Make sure we always have at least one row.
// NOTE: Removing this will break "Public Configurations" in portal. ;)
if (value && !value.length && !this.initEmpty) {
value.push({});
}
const isSettingSubmission = flags.fromSubmission && !_.isEqual(value, this.emptyValue);
const changed = this.hasChanged(value, this.dataValue);
this.dataValue = value;
if (this.initRows || isSettingSubmission) {
this.createRows();
}
this.rows.forEach((row, rowIndex) => {
if (value.length <= rowIndex) {
return;
}
_.each(row, (col) => {
col.rowIndex = rowIndex;
this.setNestedValue(col, value[rowIndex], flags);
});
});
this.updateOnChange(flags, changed);
return changed;
}
restoreComponentsContext() {
this.rows.forEach((row, index) => _.forIn(row, (component) => component.data = this.dataValue[index]));
}
getComponent(path, fn) {
path = Array.isArray(path) ? path : [path];
const [key, ...remainingPath] = path;
let result = [];
if (_.isNumber(key) && remainingPath.length) {
const compKey = remainingPath.pop();
result = this.rows[key][compKey];
// If the component is inside a Layout Component, try to find it among all the row's components
if (!result) {
Object.entries(this.rows[key]).forEach(([, comp]) => {
if ('getComponent' in comp) {
const possibleResult = comp.getComponent([compKey], fn);
if (possibleResult) {
result = possibleResult;
}
}
});
}
if (result && _.isFunction(fn)) {
fn(result, this.getComponents());
}
if (remainingPath.length && 'getComponent' in result) {
return result.getComponent(remainingPath, fn);
}
return result;
}
if (!_.isString(key)) {
return result;
}
this.everyComponent((component, components) => {
if (component.component.key === key) {
let comp = component;
if (remainingPath.length > 0 && 'getComponent' in component) {
comp = component.getComponent(remainingPath, fn);
}
else if (fn) {
fn(component, components);
}
result = result.concat(comp);
}
});
return result.length > 0 ? result : null;
}
toggleGroup(element, index) {
element.classList.toggle('collapsed');
_.each(this.refs.chunks[index], row => {
row.classList.toggle('hidden');
});
}
}