comindware.core.ui
Version:
Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.
553 lines (482 loc) • 20.6 kB
text/typescript
import Backbone from 'backbone';
import _ from 'underscore';
import SelectableBehavior from '../models/behaviors/SelectableBehavior';
import CheckableBehavior from '../models/behaviors/CheckableBehavior';
import GridItemBehavior from '../list/behaviors/GridItemBehavior';
import FixGroupingOptions from './GroupingService';
import { virtualCollectionFilterActions } from 'Meta';
const selectableBehavior = {
none: null,
single: SelectableBehavior.SingleSelect,
multi: SelectableBehavior.MultiSelect
};
/**
* @name VirtualCollection
* @memberof module:core.collections
* @class Коллекция-обертка, раширяющая родительскую Backbone-коллекцию функциями
* фильтрация, группировка (включая вложенную группировку и сворачивание групп), древовидное представление.<br/><br/>
* Используется в качестве модели данных для контролов виртуального списка и таблицы (<code>core.list</code>).<br/><br/>
* Оптимизировано для корректной работы с коллекцией до 100000 элементов.
* @constructor
* @extends Backbone.Collection
* @param {Backbone.Collection} collection Родительская Backbone-коллекция.
* @param {Object} options Объект опций.
* @param {Boolean} [options.delayedAdd=true] Добавление новой модели в коллекцию требует пересчета внутреннего индекса.
* Из этого следует, что добавление множества моделей приводит к резкому снижению производительности.
* Данная опция позволяет отложить пересчет индекса до окончания активного события.
* @param {Function} options.comparator Функция-компаратор.
* @param {Object} options.grouping .
* @param {Object} options.filter .
* @param {Backbone.Model} options.model Если указано, будет использована как Backbone.Model при добавление новых объектов в формате JSON.
* По умолчанию используется модель родительской коллекции.
* @param {String} [options.selectableBehavior='single'] Позволяет расширить коллекцию объектом SelectableBehavior.
* Используемая модель также должна поддерживать SelectableBehavior.<br/>
* Возможные варианты:<ul>
* <li><code>'none'</code> - не использовать selectable behavior.</li>
* <li><code>'single'</code> - использовать SelectableBehavior.SingleSelect.</li>
* <li><code>'multi'</code> - использовать SelectableBehavior.MultiSelect.</li>
* </ul>.
* */
const VirtualCollection = Backbone.Collection.extend({
constructor(collection, options = {}) {
this.options = options;
this.isTree = this.options.isTree;
if (options.delayedAdd === undefined) {
options.delayedAdd = true;
}
if (!collection) {
collection = new Backbone.Collection();
if (this.model) {
collection.model = this.model;
}
}
if (this.url && !collection.url) {
collection.url = this.url;
}
if (this.parse !== Backbone.Collection.prototype.parse) {
collection.parse = this.parse;
}
this.parentCollection = collection;
if (options.comparator !== undefined) {
this.comparator = options.comparator;
}
if (options.grouping !== undefined) {
this.grouping = options.grouping;
}
if (options.filter !== undefined) {
this.filterFn = typeof options.filter === 'function' ? [options.filter] : options.filter || [];
}
this._reset();
this.state = {
position: options.position || 0,
windowSize: options.windowSize || 1
};
this.visibleModels = [];
this.isSliding = options.isSliding;
this.__debounceRebuild = _.debounce((...args) => this.__rebuildModels(...args), 10);
if (options.model) {
this.model = options.model;
} else if (collection.model) {
this.model = collection.model;
}
this.__rebuildIndex({}, true);
this.listenTo(collection, 'add', this.__onAdd);
this.listenTo(collection, 'change', this.__onChange);
this.listenTo(collection, 'reset', this.__onReset);
this.listenTo(collection, 'sort', this.__onSort);
this.listenTo(collection, 'sync', this.__onSync);
this.listenTo(collection, 'update', this.__onUpdate);
this.initialize.apply(this, arguments);
let SelectableBehaviorClass;
const selectableBehaviorOption = this.options.selectableBehavior;
if (selectableBehaviorOption && selectableBehavior[selectableBehaviorOption] !== undefined) {
SelectableBehaviorClass = selectableBehavior[selectableBehaviorOption];
} else {
SelectableBehaviorClass = selectableBehavior.single;
}
if (SelectableBehaviorClass) {
_.extend(this, new SelectableBehaviorClass(this));
}
_.extend(this, new CheckableBehavior.CheckableCollection(this));
},
rebuild() {
this.__rebuildIndex({}, true);
},
highlight(text) {
this.parentCollection.each(record => {
if (record.highlight) {
record.highlight(text);
}
});
},
unhighlight() {
this.parentCollection.each(record => {
if (record.unhighlight) {
record.unhighlight();
}
});
},
updateWindowSize(newWindowSize) {
if (this.state.windowSize !== newWindowSize) {
this.internalUpdate = true;
this.isSliding = true;
this.state.windowSize = newWindowSize;
const oldModels = this.models.concat();
const oldVisibleModels = this.visibleModels.concat();
this.visibleModels = this.models.slice(this.state.position, this.state.position + this.state.windowSize);
this.visibleLength = this.visibleModels.length;
this.__processDiffs(oldVisibleModels, oldModels);
this.internalUpdate = false;
}
},
__rebuildIndex(options = {}, immediate) {
let parentModels = this.parentCollection.models;
if (this.filterFn) {
parentModels = this.__filterModels(this.parentCollection.models);
}
this.index = this.__createIndexTree(parentModels, 0);
if (immediate) {
this.__rebuildModels(options);
} else {
this.__debounceRebuild(options);
}
},
__rebuildModels(options) {
const oldVisibleModels = this.visibleModels.concat();
const oldModels = this.models.concat();
this._reset();
this.visibleModels = [];
this.visibleLength = 0;
this.__buildModelsInternal(this.index);
if (!this.models.length) {
this.trigger('reset', this);
return;
}
if (this.isSliding) {
this.visibleModels = this.models.slice(this.state.position, this.state.position + this.state.windowSize);
} else {
this.visibleModels = this.models;
}
this.visibleLength = this.visibleModels.length;
this.__processDiffs(oldVisibleModels, oldModels, options);
},
__addModel(model, options) {
const index = this.visibleModels.indexOf(model);
this.trigger('add', model, this, Object.assign({}, options, { at: index, index })); // both add and index to correct inserting in dom
},
__removeModels(removed, options) {
this.trigger(
'update',
this,
Object.assign({}, options, {
changes: {
removed,
added: options.added || [],
merged: []
}
})
);
},
__buildModelsInternal(list, level = 0) {
// Run sort based on type of `comparator`.
if (this.comparator) {
if (typeof this.comparator === 'string' || this.comparator.length === 1) {
list.models = _.sortBy(list.models, this.comparator, this);
} else {
list.models.sort(_.bind(this.comparator, this));
}
}
list.forEach(model => {
this.length++;
this.models.push(model);
this._addReference(model);
model.collection = this;
model.level = level;
Object.assign(model, GridItemBehavior(this));
if (!model.collapsed && model.children) {
//Skip building children models, if parent model is collapsed
if (this.isTree) {
this.stopListening(model.children, 'add remove reset');
this.listenToOnce(model.children, 'add remove reset', this.__debounceRebuild);
}
if (this.isTree && this.filterFn && model.filteredChildren) {
this.__buildModelsInternal(new Backbone.Collection(model.filteredChildren), level + 1);
} else {
this.__buildModelsInternal(model.children, level + 1);
}
}
});
},
__createIndexTree(models, i) {
const self = this;
if (i < this.grouping.length) {
const groupingOptions = this.grouping[i];
FixGroupingOptions(groupingOptions);
return new Backbone.Collection(
_.chain(models)
.groupBy(groupingOptions.iterator)
.map(v => {
const node = groupingOptions.modelFactory(v[0], v);
node.iteratorValue = groupingOptions.iterator(v[0]);
node.comparatorValue = groupingOptions.comparator(v[0], v);
node.children = self.__createIndexTree(v, i + 1);
return node;
})
.sortBy(n => n.comparatorValue)
.value()
);
}
return new Backbone.Collection(models);
},
/**
* Обновить позицию скользящего окна
* @param {Number} newPosition Новая позиция скользящего окна
* */
updatePosition(newPosition) {
newPosition = this.__normalizePosition(newPosition);
if (newPosition === this.state.position) {
return newPosition;
}
this.internalUpdate = true;
this.state.position = newPosition;
const oldVisibleModels = this.visibleModels.concat();
const oldModels = this.models.concat();
this.visibleModels = this.models.slice(this.state.position, this.state.position + this.state.windowSize);
this.visibleLength = this.visibleModels.length;
this.__processDiffs(oldVisibleModels, oldModels);
this.internalUpdate = false;
return newPosition;
},
__processDiffs(oldVisibleModels, oldModels, options = {}) {
const added: Array<Backbone.Model> = [];
const removed: Array<Backbone.Model> = [];
let isIndexChanged = false;
oldVisibleModels.forEach(model => {
if (!this.visibleModels.includes(model)) {
removed.push(model);
}
});
this.visibleModels.forEach(model => {
if (oldVisibleModels.includes(model)) {
this.trigger('update:child', model);
if (oldModels.indexOf(model) !== this.models.indexOf(model)) {
isIndexChanged = true;
}
} else {
added.push(model);
}
});
options.added = added;
this.__removeModels(removed, options);
added.forEach(model => this.__addModel(model, options));
if (isIndexChanged) {
this.trigger('reorder');
}
},
__normalizePosition(position) {
const maxPos = Math.max(0, this.length - this.state.windowSize);
return Math.max(0, Math.min(maxPos, position));
},
filter(filterFn, { action } = {}) {
if (!this.filterFn) {
this.filterFn = [];
}
switch (action) {
case virtualCollectionFilterActions.PUSH: //adds to the array filter function (or an array of them) with the specified name
if (Array.isArray(filterFn)) {
this.filterFn.concat(filterFn);
} else {
this.filterFn.push(filterFn);
}
this.filterFn = [...new Set(this.filterFn)];
break;
case virtualCollectionFilterActions.REMOVE: //removes from the array the filter function with the specified name
const index = this.filterFn.findIndex(fn => fn === filterFn);
if (index > -1) {
this.filterFn.splice(index, 1);
}
break;
default:
//'reset' - classic behavior. Replaces all filterFns with new one.
this.filterFn = filterFn ? (Array.isArray(filterFn) ? filterFn : [filterFn]) : [];
break;
}
this.__rebuildIndex({}, true);
this.trigger('filter');
},
__filterModels(models) {
const result = [];
models.forEach(model => {
if (model.children && model.children.length) {
model.filteredChildren = this.__filterModels(model.children.models);
} else {
delete model.filteredChildren;
}
if (this.filterFn.every(fn => fn.call(models, model, fn.parameters)) || (model.filteredChildren && model.filteredChildren.length)) {
result.push(model);
}
});
return result;
},
group(grouping) {
if (grouping !== undefined) {
this.grouping = grouping;
}
this.__rebuildIndex();
},
grouping: [],
__onSort(collection, options) {
if (this.comparator !== undefined) {
return;
}
this.__rebuildIndex(options, true);
},
__onSync(collection, resp, options) {
this.trigger('sync', collection, resp, options);
},
__onAdd(model, collection, options) {
// TODO: maybe this is unnecessary
if (options.at !== undefined) {
// Updating index
const addToIndex = function(ctx, list) {
for (let i = 0, len = list.length; i < len; i++) {
if (ctx.position === ctx.targetPosition) {
list.add(ctx.model, { at: i });
return true;
}
ctx.position++;
const indexModel = list.at(i);
if (indexModel.children && addToIndex(ctx, indexModel.children)) {
return true;
}
}
};
const added = addToIndex({ position: 0, targetPosition: options.at, model }, this.index);
if (!added) {
// border case when at === this.length
let targetCollection = this.index;
while (targetCollection.length > 0 && targetCollection.at(targetCollection.length - 1).children) {
targetCollection = targetCollection.at(targetCollection.length - 1).children;
}
targetCollection.add(model, { at: targetCollection.length });
}
// Updating models
this.__rebuildModels();
return;
}
this.__rebuildIndex();
},
__onRemove(model, options = {}) {
let i;
let len;
// collecting items in index
function createIteratorValueChecker(iteratorValue) {
return function(m) {
return m.iteratorValue === iteratorValue;
};
}
let index = this.index;
const groupItems = [];
if (this.grouping) {
for (i = 0, len = this.grouping.length; i < len; i++) {
const groupingOptions = this.grouping[i];
const groupItem = index.filter(createIteratorValueChecker(groupingOptions.iterator(model)))[0];
if (!groupItem) {
return;
}
groupItems.push(groupItem);
index = groupItem.children;
}
}
let item = index.get(model);
if (item) {
index.remove(item);
// this.__removeFromModels(item, options);
this._removeReference(model);
}
for (i = groupItems.length - 1; i >= 0; i--) {
item = groupItems[i];
index = groupItems[i - 1] || this.index;
if (item.children.length === 0) {
index.remove(item);
// this.__removeFromModels(item, options);
this._removeReference(model);
}
}
this.__rebuildIndex(options);
},
__onUpdate(collection, updateConfiguration, options) {
const changes = updateConfiguration.changes;
if (changes.merged && changes.merged.length) {
changes.merged.forEach(model => this.__onChange(model, options, true));
}
if (changes.removed && changes.removed.length) {
changes.removed.forEach(model => this.__onRemove(model, options));
}
},
__removeFromModels(model, options) {
if (!this.get(model)) {
return;
}
delete this._byId[model.id];
delete this._byId[model.cid];
const index = this.indexOf(model);
this.models.splice(index, 1);
this.length--;
this._removeReference(model, options);
},
__onChange(model, options, isPartialUpdate) {
if (!this.options.rebuildOnChange) {
return;
}
const changed = Object.keys(model.changedAttributes());
const attrsAffectedByGrouping = [];
this.grouping.forEach(o => {
if (o.affectedAttributes) {
for (let i = 0, len = o.affectedAttributes.length; i < len; i++) {
attrsAffectedByGrouping.push(o.affectedAttributes[i]);
}
}
});
let rebuildRequired = changed.some(key => attrsAffectedByGrouping.indexOf(key) !== -1);
if (!rebuildRequired && this.comparator) {
const previousModel = new model.constructor(model.previousAttributes(), model.options);
if (this.comparator.length === 1) {
const cmpVal1 = this.comparator(previousModel);
const cmpVal2 = this.comparator(model);
rebuildRequired = cmpVal1 !== cmpVal2;
} else if (this.comparator.length === 2) {
rebuildRequired = this.comparator(previousModel, model) !== 0;
}
}
if (rebuildRequired || isPartialUpdate) {
this.__rebuildIndex(options);
}
},
__onReset(collection, options) {
this.__rebuildIndex(options, true);
},
sort(options) {
this.__rebuildIndex(options, true);
},
collapse(model) {
model.collapse();
this.__rebuildIndex();
this.parentCollection.trigger('collapse', model);
},
expand(model) {
model.expand();
this.__rebuildIndex();
this.parentCollection.trigger('expand', model);
},
getState() {
return this.state;
}
});
// methods that alter data should proxy to the parent collection
['add', 'remove', 'set', 'reset', 'push', 'pop', 'unshift', 'shift', 'slice', 'sync', 'fetch', 'update', 'where', 'findWhere'].forEach(methodName => {
VirtualCollection.prototype[methodName] = function() {
return this.parentCollection[methodName].apply(this.parentCollection, Array.from(arguments));
};
});
export default VirtualCollection;