UNPKG

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
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;