comindware.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.
461 lines (400 loc) • 17.6 kB
JavaScript
/**
* Developer: Stepan Burguchev
* Date: 7/18/2014
* Copyright: 2009-2016 Comindware®
* All Rights Reserved
* Published under the MIT license
*/
'use strict';
import 'lib';
import SelectableBehavior from '../models/behaviors/SelectableBehavior';
import { helpers } from 'utils';
const selectableBehavior = {
none: null,
single: SelectableBehavior.SingleSelect,
multi: SelectableBehavior.MultiSelect
};
const getNormalizedGroupingIterator = function getNormalizedGroupingIterator(groupingOptions) {
const it = groupingOptions.iterator;
return _.isString(it) ? function(model) {
return model.get(it) || model[it];
} : it;
};
const getNormalizedGroupingComparator = function getNormalizedGroupingComparator(groupingOptions) {
const cmp = groupingOptions.comparator;
return cmp !== undefined ?
(_.isString(cmp) ? function(model) {
return model.get(cmp) || model[cmp];
} : cmp) :
groupingOptions.iterator;
};
const getNormalizedGroupingModelFactory = function getNormalizedGroupingModelFactory(groupingOptions) {
const modelFactory = groupingOptions.modelFactory;
return modelFactory !== undefined ?
(_.isString(modelFactory) ?
function(model) {
return new Backbone.Model({
displayText: model.get(modelFactory),
groupingModel: true
});
} : modelFactory) :
function(model) {
return new Backbone.Model({
displayText: groupingOptions.iterator(model),
groupingModel: true
});
};
};
const fixGroupingOptions = function fixGroupingOptions(groupingOptions) {
if (groupingOptions.__normalized) {
return;
}
if (!groupingOptions.affectedAttributes) {
groupingOptions.affectedAttributes = [];
}
if (_.isString(groupingOptions.iterator)) {
groupingOptions.affectedAttributes.push(groupingOptions.iterator);
}
if (_.isString(groupingOptions.comparator)) {
groupingOptions.affectedAttributes.push(groupingOptions.comparator);
}
if (_.isString(groupingOptions.modelFactory)) {
groupingOptions.affectedAttributes.push(groupingOptions.modelFactory);
}
groupingOptions.affectedAttributes = _.uniq(groupingOptions.affectedAttributes);
groupingOptions.iterator = getNormalizedGroupingIterator(groupingOptions);
groupingOptions.comparator = getNormalizedGroupingComparator(groupingOptions);
groupingOptions.modelFactory = getNormalizedGroupingModelFactory(groupingOptions);
groupingOptions.__normalized = true;
};
/**
* @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(/** @lends module:core.collections.VirtualCollection.prototype */ {
constructor(collection, options) //noinspection JSHint
{
options = options || {};
this.options = options;
this.syncRoot = _.uniqueId('virtual-collection-');
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 = options.filter;
}
//noinspection JSUnresolvedVariable,JSHint
options.close_with && this.__bindLifecycle(options.close_with, 'close');
//noinspection JSUnresolvedVariable,JSHint
options.destroy_with && this.__bindLifecycle(options.destroy_with, 'destroy');
if (options.model) {
this.model = options.model;
} else if (collection.model) {
this.model = collection.model;
}
this.__rebuildIndex();
this.listenTo(collection, 'add', this.__onAdd);
this.listenTo(collection, 'remove', this.__onRemove);
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.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));
}
},
__rebuildIndex() {
let tStart;
if (window.flag_debug) {
//noinspection JSUnresolvedVariable
tStart = window.performance.now && window.performance.now();
}
const parentModels = this.filterFn ?
_.filter(this.parentCollection.models, this.filterFn) :
this.parentCollection.models;
this.index = this.__createIndexTree(parentModels, 0);
this.__rebuildModels();
if (window.flag_debug) {
//noinspection JSUnresolvedVariable
const tEnd = window.performance.now && window.performance.now();
//noinspection JSHint
console.log(`Call to __rebuildIndex took ${tEnd - tStart} milliseconds.`);
}
},
__rebuildModels() {
this._reset();
this.__buildModelsInternal(this.index);
},
__buildModelsInternal(list) {
for (let i = 0, len = list.length; i < len; i++) {
const model = list.at(i);
this.models.push(model);
this._addReference(model);
model.collection = this;
//noinspection JSHint
!model.collapsed && model.children && this.__buildModelsInternal(model.children);
}
this.length = this.models.length;
},
__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());
}
// Applying comparator to the ultimate items
if (!this.comparator) {
return new Backbone.Collection(models);
}
// Run sort based on type of `comparator`.
if (_.isString(this.comparator) || this.comparator.length === 1) {
models = _.sortBy(models, this.comparator, this);
} else {
models.sort(_.bind(this.comparator, this));
}
_.each(models, function(model) {
if (model.children && !model.children.comparator) {
model.children.comparator = this.comparator;
model.children.sort();
}
}, this);
return new Backbone.Collection(models);
},
filter(filterFn) {
if (filterFn !== undefined) {
this.filterFn = filterFn;
}
this.__rebuildIndex();
this.trigger('reset', this, {});
},
group(grouping) {
if (grouping !== undefined) {
this.grouping = grouping;
}
this.__rebuildIndex();
this.trigger('reset', this, {});
},
__bindLifecycle(view, methodName) {
view.on(methodName, _.bind(this.stopListening, this));
},
grouping: [
],
__onSort(collection, options) {
if (this.comparator !== undefined) {
return;
}
this.__rebuildIndex();
this.trigger('reset', this, options);
},
__onSync(collection, resp, options) {
this.trigger('sync', collection, resp, options);
},
__onAddDelayed: _.debounce(function(options) {
this.__rebuildIndex();
this.trigger('reset', this, options);
}, 10),
__onAdd(model, collection, options) {
if (options.at !== undefined) {
// Updating index
var 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();
this.trigger('reset', this, options);
return;
}
if (options.delayed !== false && this.options.delayedAdd) {
this.__onAddDelayed(options);
} else {
this.__rebuildIndex();
this.trigger('reset', this, options);
}
},
__onRemove(model, collection, options) {
let i;
let len;
options || (options = {}); // jshint ignore:line
// collecting items in index
function createIteratorValueChecker(iteratorValue) {
return function(m) {
return m.iteratorValue == iteratorValue; // jshint ignore:line
};
}
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, _.extend(options, { silent: true }));
}
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, _.extend(options, { silent: true }));
}
}
this.__rebuildModels();
this.trigger('reset', this, 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--;
if (!options.silent) {
options.index = index;
model.trigger('remove', model, this, options);
}
this._removeReference(model, options);
},
__onChange(model, options) {
const changed = _.keys(model.changedAttributes());
const attrsAffectedByGrouping = [];
_.each(this.grouping, o => {
if (o.affectedAttributes) {
for (let i = 0, len = o.affectedAttributes.length; i < len; i++) {
attrsAffectedByGrouping.push(o.affectedAttributes[i]);
}
}
});
let rebuildRequired = _.any(changed, 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) {
this.__rebuildIndex();
this.trigger('reset', this, options);
}
},
__onReset(collection, options) {
this.__rebuildIndex();
this.trigger('reset', this, options);
},
sort(options) {
this.__rebuildIndex();
this.trigger('reset', this, options);
},
collapse(model) {
model.collapse(true);
this.__rebuildModels();
this.trigger('reset', this);
},
expand(model) {
model.expand(true);
this.__rebuildModels();
this.trigger('reset', this);
}
});
// methods that alter data should proxy to the parent collection
_.each(['add', 'remove', 'set', 'reset', 'push', 'pop', 'unshift', 'shift', 'slice', 'sync', 'fetch'], methodName => {
VirtualCollection.prototype[methodName] = function() {
return this.parentCollection[methodName].apply(this.parentCollection, _.toArray(arguments));
};
});
_.extend(VirtualCollection.prototype, Backbone.Events);
export default VirtualCollection;