/**
* Private record store class which takes the place of the view's data store to provide a grouped
* view of the data when the Grouping feature is used.
*
* Relays granular mutation events from the underlying store as refresh events to the view.
*
* On mutation events from the underlying store, updates the summary rows by firing update events
* on the corresponding summary records.
* @private
*/
Ext.define('Ext.grid.feature.GroupStore', {
extend: 'Ext.util.Observable',
isStore: true,
// Number of records to load into a buffered grid before it has been bound to a view
// of known size
defaultViewSize: 100,
// Use this property moving forward for all feature stores. It will be used to ensure
// that the correct object is used to call various APIs. See EXTJSIV-10022.
isFeatureStore: true,
badGrouperKey: '[object Object]',
constructor: function(groupingFeature, store) {
var me = this;
me.callParent();
me.groupingFeature = groupingFeature;
me.bindStore(store);
// We don't want to listen to store events in a locking assembly.
if (!groupingFeature.grid.isLocked) {
me.bindViewStoreListeners();
}
},
bindStore: function(store) {
var me = this;
if (!store || me.store !== store) {
Ext.destroy(me.storeListeners);
me.store = null;
}
if (store) {
me.storeListeners = store.on({
datachanged: me.onDataChanged,
groupchange: me.onGroupChange,
idchanged: me.onIdChanged,
update: me.onUpdate,
scope: me,
destroyable: true
});
me.store = store;
me.processStore(store);
}
},
bindViewStoreListeners: function() {
var view = this.groupingFeature.view,
listeners = view.getStoreListeners(this);
listeners.scope = view;
this.on(listeners);
},
each: function(fn, scope, includeOptions) {
this.store.each(fn, scope, includeOptions);
},
getGroupedRecords: function(store, collapseAll) {
var me = this,
feature = me.groupingFeature,
groups = store.getGroups(),
groupCount = groups ? groups.length : 0,
groupField = store.getGroupField(),
group, grouper, i, key, metaGroup, records;
if (!groupCount) {
return store.getRange();
}
for (i = 0, records = []; i < groupCount; i++) {
group = groups.getAt(i);
// Cache group information by group name.
key = group.getGroupKey();
// If there is no store grouper and the groupField looks up a complex data type,
// the store will stringify it and the group name will be '[object Object]'.
// To fix this, groupers can be defined in the feature config, so we'll
// simply do a lookup here and re-group the store.
//
// Note that if a grouper wasn't defined on the feature that we'll just default
// to the old behavior and still try to group.
// eslint-disable-next-line max-len
if (me.badGrouperKey === key && (grouper = feature.getGrouper(groupField))) {
// We must reset the value because store.group() will call
// into processStore again!
store.getGroups().remove(group);
feature.startCollapsed = collapseAll;
store.group(grouper);
return null; // signal processStore to return as well
}
metaGroup = feature.getMetaGroup(group);
// This is only set at initialization time to handle startCollapsed
if (collapseAll) {
metaGroup.isCollapsed = collapseAll;
}
// Collapsed group - add the group's placeholder.
if (metaGroup.isCollapsed) {
records.push(metaGroup.placeholder);
}
// Expanded group - add the group's child records.
else {
records.push.apply(records, group.items);
}
}
return records;
},
processStore: function(store) {
var me = this,
feature = me.groupingFeature,
data = me.data,
refreshed = false,
collapseAll, i, records;
if (!data) {
me.data = data = new Ext.util.Collection({
rootProperty: 'data',
extraKeys: {
byInternalId: {
property: 'internalId',
rootProperty: ''
}
}
});
}
if (store.getCount()) {
// When loading or paging, our two counts (store.loadCount and feature.storeLoadCount)
// will not match so we apply the startCollapsed setting and use to what was set in the
// initial config. After loading/paging our two counts will match so when we change data
// on the page (and process the store again) we do not expand or collapse any of the
// groups and things look/work as expected.
if (store.getId() !== feature.previousStoreId ||
feature.storeLoadCount !== store.loadCount) {
feature.storeLoadCount = store.loadCount;
feature.previousStoreId = store.getId();
collapseAll = feature.startCollapsed;
}
records = me.getGroupedRecords(store, collapseAll);
// If we have the same number of records that we had before, see if they are
// exactly the same records.
if (records) {
for (i = data.length, refreshed = i !== records.length; !refreshed && i-- > 0;) {
refreshed = data.items[i] !== records[i];
}
if (refreshed) {
if (data.length) {
data.clear();
}
if (records.length) {
data.add(records);
}
}
}
}
else if (data.length) {
data.clear();
refreshed = true;
}
return refreshed;
},
isCollapsed: function(name) {
return this.groupingFeature.getCache()[name].isCollapsed;
},
isLoading: function() {
return false;
},
getData: function() {
return this.data;
},
getCount: function() {
return this.data.getCount();
},
getTotalCount: function() {
return this.data.getCount();
},
first: function() {
var data = this.data,
item = null;
if (data) {
item = data.first();
if (item && item.isCollapsedPlaceholder) {
item = this.store.first();
}
}
return item;
},
last: function() {
var data = this.data,
item = null;
if (data) {
item = data.last();
if (item && item.isCollapsedPlaceholder) {
item = this.store.last();
}
}
return item;
},
// This class is only created for fully loaded, non-buffered stores
rangeCached: function(start, end) {
return end < this.getCount();
},
getRange: function(start, end, options) {
// Collection's getRange is exclusive.
// Do NOT mutate the value: it is passed to the callback.
var result = this.data.getRange(start, Ext.isNumber(end) ? end + 1 : end);
if (options && options.callback) {
options.callback.call(options.scope || this, result, start, end, options);
}
return result;
},
getAt: function(index) {
return this.data.getAt(index);
},
/**
* Get the Record with the specified id.
*
* This method is not affected by filtering, lookup will be performed from all records
* inside the store, filtered or not.
*
* @param {Mixed} id The id of the Record to find.
* @return {Ext.data.Model} The Record with the passed id. Returns null if not found.
*/
getById: function(id) {
return this.store.getById(id);
},
/**
* @private
* Get the Record with the specified internalId.
*
* This method is not effected by filtering, lookup will be performed from all records
* inside the store, filtered or not.
*
* @param {Mixed} internalId The id of the Record to find.
* @return {Ext.data.Model} The Record with the passed internalId. Returns null if not found.
*/
getByInternalId: function(internalId) {
// Find the record in the base store.
// If it was a placeholder, then it won't be there, it will be in our data Collection.
return this.store.getByInternalId(internalId) || this.data.byInternalId.get(internalId);
},
expandGroup: function(group) {
var me = this,
groupingFeature = me.groupingFeature,
lockingPartner = groupingFeature.lockingPartner,
metaGroup, placeholder, startIdx, items;
if (typeof group === 'string') {
group = groupingFeature.getGroup(group);
}
if (group) {
items = group.items;
metaGroup = groupingFeature.getMetaGroup(group);
placeholder = metaGroup.placeholder;
}
if (items.length && (startIdx = me.data.indexOf(placeholder)) !== -1) {
// Any event handlers must see the new state
metaGroup.isCollapsed = false;
if (lockingPartner) {
lockingPartner.getMetaGroup(group).isCollapsed = false;
}
me.isExpandingOrCollapsing = 1;
// Remove the collapsed group placeholder record
me.data.removeAt(startIdx);
// Insert the child records in its place
me.data.insert(startIdx, group.items);
// Update views
me.fireEvent('replace', me, startIdx, [placeholder], group.items);
me.fireEvent('groupexpand', me, group);
me.isExpandingOrCollapsing = 0;
}
},
collapseGroup: function(group) {
var me = this,
groupingFeature = me.groupingFeature,
lockingPartner = groupingFeature.lockingPartner,
startIdx,
placeholder,
len, items;
if (typeof group === 'string') {
group = groupingFeature.getGroup(group);
}
if (group) {
items = group.items;
}
if (items && (len = items.length) && (startIdx = me.data.indexOf(items[0])) !== -1) {
// Any event handlers must see the new state
groupingFeature.getMetaGroup(group).isCollapsed = true;
if (lockingPartner) {
lockingPartner.getMetaGroup(group).isCollapsed = true;
}
me.isExpandingOrCollapsing = 2;
// Remove the group child records
me.data.removeAt(startIdx, len);
// Insert a placeholder record in their place
me.data.insert(startIdx, placeholder = me.getGroupPlaceholder(group));
// Update views
me.fireEvent('replace', me, startIdx, items, [placeholder]);
me.fireEvent('groupcollapse', me, group);
me.isExpandingOrCollapsing = 0;
}
},
getGroupPlaceholder: function(group) {
var metaGroup = this.groupingFeature.getMetaGroup(group);
if (!metaGroup.placeholder) {
// eslint-disable-next-line vars-on-top, one-var
var store = this.store,
Model = store.getModel(),
modelData = {},
key = group.getGroupKey(),
groupPlaceholder;
modelData[store.getGroupField()] = key;
groupPlaceholder = metaGroup.placeholder = new Model(modelData, store.session, true);
groupPlaceholder.isNonData = groupPlaceholder.isCollapsedPlaceholder = true;
// Adding the groupKey instead of storing a reference to the group
// itself. The latter can cause problems if the store is reloaded and the referenced
// group is lost. See EXTJS-18655
groupPlaceholder.groupKey = key;
}
return metaGroup.placeholder;
},
// Find index of record in group store.
// If it's in a collapsed group, then it's -1, not present
indexOf: function(record) {
var ret = -1;
if (record && !record.isCollapsedPlaceholder) {
ret = this.data.indexOf(record);
}
return ret;
},
contains: function(record) {
return this.indexOf(record) > -1;
},
indexOfPlaceholder: function(record) {
return this.data.indexOf(record);
},
/**
* Get the index within the store of the Record with the passed id.
*
* Like #indexOf, this method is effected by filtering.
*
* @param {String} id The id of the Record to find.
* @return {Number} The index of the Record. Returns -1 if not found.
*/
indexOfId: function(id) {
return this.data.indexOfKey(id);
},
/**
* Get the index within the entire dataset. From 0 to the totalCount.
*
* Like #indexOf, this method is effected by filtering.
*
* @param {Ext.data.Model} record The Ext.data.Model object to find.
* @return {Number} The index of the passed Record. Returns -1 if not found.
*/
indexOfTotal: function(record) {
return this.store.indexOf(record);
},
onIdChanged: function(store, rec, oldId, newId) {
this.data.updateKey(rec, oldId);
},
onUpdate: function(store, record, operation, modifiedFieldNames) {
var me = this,
groupingFeature = me.groupingFeature,
group, metaGroup, firstRec, lastRec, items;
// The grouping field value has been modified.
// This could either move a record from one group to another, or introduce a new group.
// Either way, we have to refresh the grid
if (store.isGrouped()) {
// Updating a single record, attach the group to the record for Grouping.setupRowData
// to use.
group = record.group = groupingFeature.getGroup(record);
// Make sure that still we have a group and that the last member of it
// wasn't just filtered. See EXTJS-18083.
if (group) {
metaGroup = groupingFeature.getMetaGroup(record);
if (modifiedFieldNames &&
Ext.Array.contains(modifiedFieldNames, groupingFeature.getGroupField())) {
me.onDataChanged();
delete record.group;
return;
}
// Fire an update event on the collapsed metaGroup placeholder record
if (metaGroup.isCollapsed) {
me.fireEvent('update', me, metaGroup.placeholder);
}
// Not in a collapsed group, fire update event on the modified record
// and, if in a grouped store, on the first and last records in the group.
else {
Ext.suspendLayouts();
// Propagate the record's update event
me.fireEvent('update', me, record, operation, modifiedFieldNames);
// Fire update event on first and last record in group (only once
// if a single row group)
// So that custom header TPL is applied, and the summary row is updated
items = group.items;
firstRec = items[0];
lastRec = items[items.length - 1];
// Fire an update on the first and last row in the group (ensure we don't refire
// update on the modified record). This is to give interested Features
// the opportunity to update the first item (a wrapped group header + data row),
// and last item (a wrapped data row + group summary)
if (firstRec !== record) {
firstRec.group = group;
me.fireEvent('update', me, firstRec, 'edit', modifiedFieldNames);
delete firstRec.group;
}
if (lastRec !== record && lastRec !== firstRec &&
groupingFeature.showSummaryRow) {
lastRec.group = group;
me.fireEvent('update', me, lastRec, 'edit', modifiedFieldNames);
delete lastRec.group;
}
Ext.resumeLayouts(true);
}
}
delete record.group;
}
else {
// Propagate the record's update event
me.fireEvent('update', me, record, operation, modifiedFieldNames);
}
},
// Relay the groupchange event
onGroupChange: function(store, grouper) {
if (!grouper) {
this.processStore(store);
}
this.fireEvent('groupchange', store, grouper);
},
onDataChanged: function() {
var me = this;
if (me.processStore(me.store)) {
me.fireEvent('refresh', me);
}
},
destroy: function() {
var me = this;
me.bindStore(null);
Ext.destroy(me.data);
me.groupingFeature = null;
me.callParent();
}
});