slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
1,379 lines (1,212 loc) • 65.3 kB
text/typescript
import type {
Aggregator,
AnyFunction,
CssStyleHash,
CustomDataView,
DataViewHints,
Grouping,
GroupingFormatterItem,
ItemMetadata,
ItemMetadataProvider,
OnGroupCollapsedEventArgs,
OnGroupExpandedEventArgs,
OnRowCountChangedEventArgs,
OnRowsChangedEventArgs,
OnRowsOrCountChangedEventArgs,
OnSelectedRowIdsChangedEventArgs,
OnSetItemsCalledEventArgs,
PagingInfo,
SlickGridModel,
} from './models/index.js';
import {
type BasePubSub,
SlickEvent as SlickEvent_,
SlickEventData as SlickEventData_,
SlickGroup as SlickGroup_,
SlickGroupTotals as SlickGroupTotals_,
Utils as Utils_,
type SlickNonDataItem,
} from './slick.core.js';
import { SlickGroupItemMetadataProvider as SlickGroupItemMetadataProvider_ } from './slick.groupitemmetadataprovider.js';
// for (iife) load Slick methods from global Slick object, or use imports for (esm)
const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_;
const SlickEventData = IIFE_ONLY ? Slick.EventData : SlickEventData_;
const SlickGroup = IIFE_ONLY ? Slick.Group : SlickGroup_;
const SlickGroupTotals = IIFE_ONLY ? Slick.GroupTotals : SlickGroupTotals_;
const Utils = IIFE_ONLY ? Slick.Utils : Utils_;
const SlickGroupItemMetadataProvider = IIFE_ONLY ? Slick.Data?.GroupItemMetadataProvider ?? {} : SlickGroupItemMetadataProvider_;
export interface DataViewOption {
/** global override for all rows */
globalItemMetadataProvider: ItemMetadataProvider | null;
/** Optionally provide a GroupItemMetadataProvider in order to use Grouping/DraggableGrouping features */
groupItemMetadataProvider: SlickGroupItemMetadataProvider_ | null;
/** defaults to false, are we using inline filters? */
inlineFilters: boolean;
/**
* defaults to false, option to use CSP Safe approach,
* Note: it is an opt-in option because it is slightly slower (perf impact) when compared to the non-CSP safe approach.
*/
useCSPSafeFilter: boolean;
}
export type FilterFn<T> = (item: T, args: any) => boolean;
export type FilterCspFn<T> = (item: T[], args: any) => T[];
export type FilterWithCspCachingFn<T> = (item: T[], args: any, filterCache: any[]) => T[];
export type DataIdType = number | string;
export type SlickDataItem = SlickNonDataItem | SlickGroup_ | SlickGroupTotals_ | any;
export type GroupGetterFn = (val: any) => string | number;
/**
* A simple Model implementation.
* Provides a filtered view of the underlying data.
* Relies on the data item having an "id" property uniquely identifying it.
*/
export class SlickDataView<TData extends SlickDataItem = any> implements CustomDataView {
protected defaults: DataViewOption = {
globalItemMetadataProvider: null,
groupItemMetadataProvider: null,
inlineFilters: false,
useCSPSafeFilter: false,
};
// private
protected idProperty = 'id'; // property holding a unique row id
protected items: TData[] = []; // data by index
protected rows: TData[] = []; // data by row
protected idxById = new Map<DataIdType, number>(); // indexes by id
protected rowsById: { [id: DataIdType]: number } | undefined = undefined; // rows by id; lazy-calculated
protected filter: FilterFn<TData> | null = null; // filter function
protected filterCSPSafe: FilterFn<TData> | null = null; // filter function
protected updated: ({ [id: DataIdType]: boolean }) | null = null; // updated item ids
protected suspend = false; // suspends the recalculation
protected isBulkSuspend = false; // delays protectedious operations like the
// index update and delete to efficient
// versions at endUpdate
protected bulkDeleteIds = new Map<DataIdType, boolean>();
protected sortAsc: boolean | undefined = true;
protected fastSortField?: string | null | (() => string);
protected sortComparer!: ((a: TData, b: TData) => number);
protected refreshHints: DataViewHints = {};
protected prevRefreshHints: DataViewHints = {};
protected filterArgs: any;
protected filteredItems: TData[] = [];
protected compiledFilter?: FilterFn<TData> | null;
protected compiledFilterCSPSafe?: FilterCspFn<TData> | null;
protected compiledFilterWithCaching?: FilterFn<TData> | null;
protected compiledFilterWithCachingCSPSafe?: FilterWithCspCachingFn<TData> | null;
protected filterCache: any[] = [];
protected _grid?: SlickGridModel; // grid object will be defined only after using "syncGridSelection()" method"
// grouping
protected groupingInfoDefaults: Grouping = {
getter: undefined,
formatter: undefined,
comparer: (a: { value: any; }, b: { value: any; }) => (a.value === b.value ? 0 : (a.value > b.value ? 1 : -1)),
predefinedValues: [],
aggregators: [],
aggregateEmpty: false,
aggregateCollapsed: false,
aggregateChildGroups: false,
collapsed: false,
displayTotalsRow: true,
lazyTotalsCalculation: false
};
protected groupingInfos: Array<Grouping & { aggregators: Aggregator[]; getterIsAFn?: boolean; compiledAccumulators: any[]; getter: GroupGetterFn | string }> = [];
protected groups: SlickGroup_[] = [];
protected toggledGroupsByLevel: any[] = [];
protected groupingDelimiter = ':|:';
protected selectedRowIds: DataIdType[] = [];
protected preSelectedRowIdsChangeFn?: (args?: any) => void;
protected pagesize = 0;
protected pagenum = 0;
protected totalRows = 0;
protected _options: DataViewOption;
protected _container?: HTMLElement;
// public events
onBeforePagingInfoChanged: SlickEvent_<PagingInfo>;
onGroupExpanded: SlickEvent_<OnGroupExpandedEventArgs>;
onGroupCollapsed: SlickEvent_<OnGroupCollapsedEventArgs>;
onPagingInfoChanged: SlickEvent_<PagingInfo>;
onRowCountChanged: SlickEvent_<OnRowCountChangedEventArgs>;
onRowsChanged: SlickEvent_<OnRowsChangedEventArgs>;
onRowsOrCountChanged: SlickEvent_<OnRowsOrCountChangedEventArgs>;
onSelectedRowIdsChanged: SlickEvent_<OnSelectedRowIdsChangedEventArgs>;
onSetItemsCalled: SlickEvent_<OnSetItemsCalledEventArgs>;
constructor(options?: Partial<DataViewOption>, protected externalPubSub?: BasePubSub) {
this.onBeforePagingInfoChanged = new SlickEvent<PagingInfo>('onBeforePagingInfoChanged', externalPubSub);
this.onGroupExpanded = new SlickEvent<OnGroupExpandedEventArgs>('onGroupExpanded', externalPubSub);
this.onGroupCollapsed = new SlickEvent<OnGroupCollapsedEventArgs>('onGroupCollapsed', externalPubSub);
this.onPagingInfoChanged = new SlickEvent<PagingInfo>('onPagingInfoChanged', externalPubSub);
this.onRowCountChanged = new SlickEvent<OnRowCountChangedEventArgs>('onRowCountChanged', externalPubSub);
this.onRowsChanged = new SlickEvent<OnRowsChangedEventArgs>('onRowsChanged', externalPubSub);
this.onRowsOrCountChanged = new SlickEvent<OnRowsOrCountChangedEventArgs>('onRowsOrCountChanged', externalPubSub);
this.onSelectedRowIdsChanged = new SlickEvent<OnSelectedRowIdsChangedEventArgs>('onSelectedRowIdsChanged', externalPubSub);
this.onSetItemsCalled = new SlickEvent<OnSetItemsCalledEventArgs>('onSetItemsCalled', externalPubSub);
this._options = Utils.extend(true, {}, this.defaults, options);
}
/**
* Begins a bached update of the items in the data view.
* including deletes and the related events are postponed to the endUpdate call.
* As certain operations are postponed during this update, some methods might not
* deliver fully consistent information.
* @param {Boolean} [bulkUpdate] - if set to true, most data view modifications
*/
beginUpdate(bulkUpdate?: boolean) {
this.suspend = true;
this.isBulkSuspend = bulkUpdate === true;
}
endUpdate() {
const wasBulkSuspend = this.isBulkSuspend;
this.isBulkSuspend = false;
this.suspend = false;
if (wasBulkSuspend) {
this.processBulkDelete();
this.ensureIdUniqueness();
}
this.refresh();
}
destroy() {
this.items = [];
this.idxById = null as any;
this.rowsById = null as any;
this.filter = null as any;
this.filterCSPSafe = null as any;
this.updated = null as any;
this.sortComparer = null as any;
this.filterCache = [];
this.filteredItems = [];
this.compiledFilter = null;
this.compiledFilterCSPSafe = null;
this.compiledFilterWithCaching = null;
this.compiledFilterWithCachingCSPSafe = null;
if (this._grid && this._grid.onSelectedRowsChanged && this._grid.onCellCssStylesChanged) {
this._grid.onSelectedRowsChanged.unsubscribe();
this._grid.onCellCssStylesChanged.unsubscribe();
}
if (this.onRowsOrCountChanged) {
this.onRowsOrCountChanged.unsubscribe();
}
}
/** provide some refresh hints as to what to rows needs refresh */
setRefreshHints(hints: DataViewHints) {
this.refreshHints = hints;
}
/** get extra filter arguments of the filter method */
getFilterArgs() {
return this.filterArgs;
}
/** add extra filter arguments to the filter method */
setFilterArgs(args: any) {
this.filterArgs = args;
}
/**
* Processes all delete requests placed during bulk update
* by recomputing the items and idxById members.
*/
protected processBulkDelete() {
if (!this.idxById) { return; }
// the bulk update is processed by
// recomputing the whole items array and the index lookup in one go.
// this is done by placing the not-deleted items
// from left to right into the array and shrink the array the the new
// size afterwards.
// see https://github.com/6pac/SlickGrid/issues/571 for further details.
let id: DataIdType, item, newIdx = 0;
for (let i = 0, l = this.items.length; i < l; i++) {
item = this.items[i];
id = item[this.idProperty as keyof TData] as DataIdType;
if (id === undefined) {
throw new Error(`[SlickGrid DataView] Each data element must implement a unique 'id' property`);
}
// if items have been marked as deleted we skip them for the new final items array
// and we remove them from the lookup table.
if (this.bulkDeleteIds.has(id)) {
this.idxById.delete(id);
} else {
// for items which are not deleted, we add them to the
// next free position in the array and register the index in the lookup.
this.items[newIdx] = item;
this.idxById.set(id, newIdx);
++newIdx;
}
}
// here we shrink down the full item array to the ones actually
// inserted in the cleanup loop above.
this.items.length = newIdx;
// and finally cleanup the deleted ids to start cleanly on the next update.
this.bulkDeleteIds = new Map();
}
protected updateIdxById(startingIndex?: number) {
if (this.isBulkSuspend || !this.idxById) { // during bulk update we do not reorganize
return;
}
startingIndex = startingIndex || 0;
let id: DataIdType;
for (let i = startingIndex, l = this.items.length; i < l; i++) {
id = this.items[i][this.idProperty as keyof TData] as DataIdType;
if (id === undefined) {
throw new Error(`[SlickGrid DataView] Each data element must implement a unique 'id' property`);
}
this.idxById.set(id, i);
}
}
protected ensureIdUniqueness() {
if (this.isBulkSuspend || !this.idxById) { // during bulk update we do not reorganize
return;
}
let id: DataIdType;
for (let i = 0, l = this.items.length; i < l; i++) {
id = this.items[i][this.idProperty as keyof TData] as DataIdType;
if (id === undefined || this.idxById.get(id) !== i) {
throw new Error(`[SlickGrid DataView] Each data element must implement a unique 'id' property`);
}
}
}
/** Get all DataView Items */
getItems() {
return this.items;
}
/** Get the DataView Id property name to use (defaults to "Id" but could be customized to something else when instantiating the DataView) */
getIdPropertyName() {
return this.idProperty;
}
/**
* Set the Items with a new Dataset and optionally pass a different Id property name
* @param {Array<*>} data - array of data
* @param {String} [objectIdProperty] - optional id property to use as primary id
*/
setItems(data: TData[], objectIdProperty?: string) {
if (objectIdProperty !== undefined) {
this.idProperty = objectIdProperty;
}
this.items = this.filteredItems = data;
this.onSetItemsCalled.notify({ idProperty: this.idProperty, itemCount: this.items.length }, null, this);
this.idxById = new Map();
this.updateIdxById();
this.ensureIdUniqueness();
this.refresh();
}
/** Set Paging Options */
setPagingOptions(args: Partial<PagingInfo>) {
if (this.onBeforePagingInfoChanged.notify(this.getPagingInfo(), null, this).getReturnValue() !== false) {
if (Utils.isDefined(args.pageSize)) {
this.pagesize = args.pageSize;
this.pagenum = this.pagesize ? Math.min(this.pagenum, Math.max(0, Math.ceil(this.totalRows / this.pagesize) - 1)) : 0;
}
if (Utils.isDefined(args.pageNum)) {
this.pagenum = Math.min(args.pageNum, Math.max(0, Math.ceil(this.totalRows / this.pagesize) - 1));
}
this.onPagingInfoChanged.notify(this.getPagingInfo(), null, this);
this.refresh();
}
}
/** Get Paging Options */
getPagingInfo(): PagingInfo {
const totalPages = this.pagesize ? Math.max(1, Math.ceil(this.totalRows / this.pagesize)) : 1;
return { pageSize: this.pagesize, pageNum: this.pagenum, totalRows: this.totalRows, totalPages, dataView: this as SlickDataView };
}
/** Sort Method to use by the DataView */
sort(comparer: (a: TData, b: TData) => number, ascending?: boolean) {
this.sortAsc = ascending;
this.sortComparer = comparer;
this.fastSortField = null;
if (ascending === false) {
this.items.reverse();
}
this.items.sort(comparer);
if (ascending === false) {
this.items.reverse();
}
this.idxById = new Map();
this.updateIdxById();
this.refresh();
}
/**
* @deprecated, to be more removed in next major since IE is no longer supported and this is no longer useful.
* Provides a workaround for the extremely slow sorting in IE.
* Does a [lexicographic] sort on a give column by temporarily overriding Object.prototype.toString
* to return the value of that field and then doing a native Array.sort().
*/
fastSort(field: string | (() => string), ascending?: boolean) {
this.sortAsc = ascending;
this.fastSortField = field;
this.sortComparer = null as any;
const oldToString = Object.prototype.toString;
Object.prototype.toString = (typeof field === 'function') ? field : function () {
// @ts-ignore
return this[field];
};
// an extra reversal for descending sort keeps the sort stable
// (assuming a stable native sort implementation, which isn't true in some cases)
if (ascending === false) {
this.items.reverse();
}
this.items.sort();
Object.prototype.toString = oldToString;
if (ascending === false) {
this.items.reverse();
}
this.idxById = new Map();
this.updateIdxById();
this.refresh();
}
/** Re-Sort the dataset */
reSort() {
if (this.sortComparer) {
this.sort(this.sortComparer, this.sortAsc);
} else if (this.fastSortField) {
this.fastSort(this.fastSortField, this.sortAsc);
}
}
/** Get only the DataView filtered items */
getFilteredItems<T extends TData>() {
return this.filteredItems as T[];
}
/** Get the array length (count) of only the DataView filtered items */
getFilteredItemCount() {
return this.filteredItems.length;
}
/** Get current Filter used by the DataView */
getFilter() {
return this._options.useCSPSafeFilter ? this.filterCSPSafe : this.filter;
}
/**
* Set a Filter that will be used by the DataView
* @param {Function} fn - filter callback function
*/
setFilter(filterFn: FilterFn<TData>) {
this.filterCSPSafe = filterFn;
this.filter = filterFn;
if (this._options.inlineFilters) {
this.compiledFilterCSPSafe = this.compileFilterCSPSafe;
this.compiledFilterWithCachingCSPSafe = this.compileFilterWithCachingCSPSafe;
this.compiledFilter = this.compileFilter(this._options.useCSPSafeFilter);
this.compiledFilterWithCaching = this.compileFilterWithCaching(this._options.useCSPSafeFilter);
}
this.refresh();
}
/** Get current Grouping info */
getGrouping(): Grouping[] {
return this.groupingInfos;
}
/** Set some Grouping */
setGrouping(groupingInfo: Grouping | Grouping[]) {
if (!this._options.groupItemMetadataProvider) {
this._options.groupItemMetadataProvider = new SlickGroupItemMetadataProvider();
}
this.groups = [];
this.toggledGroupsByLevel = [];
groupingInfo = groupingInfo || [];
this.groupingInfos = ((groupingInfo instanceof Array) ? groupingInfo : [groupingInfo]) as any;
for (let i = 0; i < this.groupingInfos.length; i++) {
const gi = this.groupingInfos[i] = Utils.extend(true, {}, this.groupingInfoDefaults, this.groupingInfos[i]);
gi.getterIsAFn = typeof gi.getter === 'function';
// pre-compile accumulator loops
gi.compiledAccumulators = [];
let idx = gi.aggregators.length;
while (idx--) {
gi.compiledAccumulators[idx] = this.compileAccumulatorLoopCSPSafe(gi.aggregators[idx]);
}
this.toggledGroupsByLevel[i] = {};
}
this.refresh();
}
/** Get an item in the DataView by its row index */
getItemByIdx<T extends TData>(i: number) {
return this.items[i] as T;
}
/** Get row index in the DataView by its Id */
getIdxById(id: DataIdType) {
return this.idxById?.get(id);
}
protected ensureRowsByIdCache() {
if (!this.rowsById) {
this.rowsById = {};
for (let i = 0, l = this.rows.length; i < l; i++) {
this.rowsById[this.rows[i][this.idProperty as keyof TData] as DataIdType] = i;
}
}
}
/** Get row number in the grid by its item object */
getRowByItem(item: TData) {
this.ensureRowsByIdCache();
return this.rowsById?.[item[this.idProperty as keyof TData] as DataIdType];
}
/** Get row number in the grid by its Id */
getRowById(id: DataIdType) {
this.ensureRowsByIdCache();
return this.rowsById?.[id];
}
/** Get an item in the DataView by its Id */
getItemById<T extends TData>(id: DataIdType) {
return this.items[(this.idxById.get(id) as number)] as T;
}
/** From the items array provided, return the mapped rows */
mapItemsToRows(itemArray: TData[]) {
const rows: number[] = [];
this.ensureRowsByIdCache();
for (let i = 0, l = itemArray.length; i < l; i++) {
const row = this.rowsById?.[itemArray[i][this.idProperty as keyof TData] as DataIdType];
if (Utils.isDefined(row)) {
rows[rows.length] = row as number;
}
}
return rows;
}
/** From the Ids array provided, return the mapped rows */
mapIdsToRows(idArray: DataIdType[]) {
const rows: number[] = [];
this.ensureRowsByIdCache();
for (let i = 0, l = idArray.length; i < l; i++) {
const row = this.rowsById?.[idArray[i]];
if (Utils.isDefined(row)) {
rows[rows.length] = row as number;
}
}
return rows;
}
/** From the rows array provided, return the mapped Ids */
mapRowsToIds(rowArray: number[]) {
const ids: DataIdType[] = [];
for (let i = 0, l = rowArray.length; i < l; i++) {
if (rowArray[i] < this.rows.length) {
const rowItem = this.rows[rowArray[i]];
ids[ids.length] = rowItem![this.idProperty as keyof TData] as DataIdType;
}
}
return ids;
}
/**
* Performs the update operations of a single item by id without
* triggering any events or refresh operations.
* @param id The new id of the item.
* @param item The item which should be the new value for the given id.
*/
updateSingleItem(id: DataIdType, item: TData) {
if (!this.idxById) { return; }
// see also https://github.com/mleibman/SlickGrid/issues/1082
if (!this.idxById.has(id)) {
throw new Error('[SlickGrid DataView] Invalid id');
}
// What if the specified item also has an updated idProperty?
// Then we'll have to update the index as well, and possibly the `updated` cache too.
if (id !== item[this.idProperty as keyof TData]) {
// make sure the new id is unique:
const newId = item[this.idProperty as keyof TData] as DataIdType;
if (!Utils.isDefined(newId)) {
throw new Error('[SlickGrid DataView] Cannot update item to associate with a null id');
}
if (this.idxById.has(newId)) {
throw new Error('[SlickGrid DataView] Cannot update item to associate with a non-unique id');
}
this.idxById.set(newId, this.idxById.get(id) as number);
this.idxById.delete(id);
// Also update the `updated` hashtable/markercache? Yes, `recalc()` inside `refresh()` needs that one!
if (this.updated?.[id]) {
delete this.updated[id];
}
// Also update the row indexes? no need since the `refresh()`, further down, blows away the `rowsById[]` cache!
id = newId;
}
this.items[this.idxById.get(id) as number] = item;
// Also update the rows? no need since the `refresh()`, further down, blows away the `rows[]` cache and recalculates it via `recalc()`!
if (!this.updated) {
this.updated = {};
}
this.updated[id] = true;
}
/**
* Updates a single item in the data view given the id and new value.
* @param id The new id of the item.
* @param item The item which should be the new value for the given id.
*/
updateItem<T extends TData>(id: DataIdType, item: T) {
this.updateSingleItem(id, item);
this.refresh();
}
/**
* Updates multiple items in the data view given the new ids and new values.
* @param id {Array} The array of new ids which is in the same order as the items.
* @param newItems {Array} The new items that should be set in the data view for the given ids.
*/
updateItems<T extends TData>(ids: DataIdType[], newItems: T[]) {
if (ids.length !== newItems.length) {
throw new Error('[SlickGrid DataView] Mismatch on the length of ids and items provided to update');
}
for (let i = 0, l = newItems.length; i < l; i++) {
this.updateSingleItem(ids[i], newItems[i]);
}
this.refresh();
}
/**
* Inserts a single item into the data view at the given position.
* @param insertBefore {Number} The 0-based index before which the item should be inserted.
* @param item The item to insert.
*/
insertItem(insertBefore: number, item: TData) {
this.items.splice(insertBefore, 0, item);
this.updateIdxById(insertBefore);
this.refresh();
}
/**
* Inserts multiple items into the data view at the given position.
* @param insertBefore {Number} The 0-based index before which the items should be inserted.
* @param newItems {Array} The items to insert.
*/
insertItems(insertBefore: number, newItems: TData[]) {
// @ts-ignore
Array.prototype.splice.apply(this.items, [insertBefore, 0].concat(newItems));
this.updateIdxById(insertBefore);
this.refresh();
}
/**
* Adds a single item at the end of the data view.
* @param item The item to add at the end.
*/
addItem(item: TData) {
this.items.push(item);
this.updateIdxById(this.items.length - 1);
this.refresh();
}
/**
* Adds multiple items at the end of the data view.
* @param {Array} newItems The items to add at the end.
*/
addItems(newItems: TData[]) {
this.items = this.items.concat(newItems);
this.updateIdxById(this.items.length - newItems.length);
this.refresh();
}
/**
* Deletes a single item identified by the given id from the data view.
* @param {String|Number} id The id identifying the object to delete.
*/
deleteItem(id: DataIdType) {
if (!this.idxById) { return; }
if (this.isBulkSuspend) {
this.bulkDeleteIds.set(id, true);
} else {
const idx = this.idxById.get(id);
if (idx === undefined) {
throw new Error('[SlickGrid DataView] Invalid id');
}
this.idxById.delete(id);
this.items.splice(idx, 1);
this.updateIdxById(idx);
this.refresh();
}
}
/**
* Deletes multiple item identified by the given ids from the data view.
* @param {Array} ids The ids of the items to delete.
*/
deleteItems(ids: DataIdType[]) {
if (ids.length === 0 || !this.idxById) {
return;
}
if (this.isBulkSuspend) {
for (let i = 0, l = ids.length; i < l; i++) {
const id = ids[i];
const idx = this.idxById.get(id);
if (idx === undefined) {
throw new Error('[SlickGrid DataView] Invalid id');
}
this.bulkDeleteIds.set(id, true);
}
} else {
// collect all indexes
const indexesToDelete: number[] = [];
for (let i = 0, l = ids.length; i < l; i++) {
const id = ids[i];
const idx = this.idxById.get(id);
if (idx === undefined) {
throw new Error('[SlickGrid DataView] Invalid id');
}
this.idxById.delete(id);
indexesToDelete.push(idx);
}
// Remove from back to front
indexesToDelete.sort();
for (let i = indexesToDelete.length - 1; i >= 0; --i) {
this.items.splice(indexesToDelete[i], 1);
}
// update lookup from front to back
this.updateIdxById(indexesToDelete[0]);
this.refresh();
}
}
/** Add an item in a sorted dataset (a Sort function must be defined) */
sortedAddItem(item: TData) {
if (!this.sortComparer) {
throw new Error('[SlickGrid DataView] sortedAddItem() requires a sort comparer, use sort()');
}
this.insertItem(this.sortedIndex(item), item);
}
/** Update an item in a sorted dataset (a Sort function must be defined) */
sortedUpdateItem(id: string | number, item: TData) {
if (!this.idxById) { return; }
if (!this.idxById.has(id) || id !== item[this.idProperty as keyof TData]) {
throw new Error('[SlickGrid DataView] Invalid or non-matching id ' + this.idxById.get(id));
}
if (!this.sortComparer) {
throw new Error('[SlickGrid DataView] sortedUpdateItem() requires a sort comparer, use sort()');
}
const oldItem = this.getItemById(id);
if (this.sortComparer(oldItem, item) !== 0) {
// item affects sorting -> must use sorted add
this.deleteItem(id);
this.sortedAddItem(item);
} else { // update does not affect sorting -> regular update works fine
this.updateItem(id, item);
}
}
protected sortedIndex(searchItem: TData) {
let low = 0;
let high = this.items.length;
while (low < high) {
const mid = low + high >>> 1;
if (this.sortComparer(this.items[mid], searchItem) === -1) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
/** Get item count, that is the full dataset lenght of the DataView */
getItemCount() {
return this.items.length;
}
/** Get row count (rows displayed in current page) */
getLength() {
return this.rows.length;
}
/** Retrieve an item from the DataView at specific index */
getItem<T extends TData>(i: number) {
const item = this.rows[i] as T;
// if this is a group row, make sure totals are calculated and update the title
if ((item as SlickGroup_)?.__group && (item as SlickGroup_).totals && !(item as SlickGroup_).totals?.initialized) {
const gi = this.groupingInfos[(item as SlickGroup_).level];
if (!gi.displayTotalsRow) {
this.calculateTotals((item as SlickGroup_).totals);
(item as SlickGroup_).title = gi.formatter ? gi.formatter((item as SlickGroup_)) : (item as SlickGroup_).value;
}
}
// if this is a totals row, make sure it's calculated
else if ((item as SlickGroupTotals_)?.__groupTotals && !(item as SlickGroupTotals_).initialized) {
this.calculateTotals(item as SlickGroupTotals_);
}
return item;
}
getItemMetadata(row: number): ItemMetadata | null {
const item = this.rows[row];
if (item === undefined) {
return null;
}
// global override for all regular rows
if (this._options.globalItemMetadataProvider?.getRowMetadata) {
return this._options.globalItemMetadataProvider.getRowMetadata(item, row);
}
// overrides for grouping rows
if ((item as SlickGroup_).__group && this._options.groupItemMetadataProvider?.getGroupRowMetadata) {
return this._options.groupItemMetadataProvider.getGroupRowMetadata(item as GroupingFormatterItem, row);
}
// overrides for totals rows
if ((item as SlickGroupTotals_).__groupTotals && this._options.groupItemMetadataProvider?.getTotalsRowMetadata) {
return this._options.groupItemMetadataProvider.getTotalsRowMetadata(item as { group: GroupingFormatterItem }, row);
}
return null;
}
protected expandCollapseAllGroups(level?: number, collapse?: boolean) {
if (!Utils.isDefined(level)) {
for (let i = 0; i < this.groupingInfos.length; i++) {
this.toggledGroupsByLevel[i] = {};
this.groupingInfos[i].collapsed = collapse;
if (collapse === true) {
this.onGroupCollapsed.notify({ level: i, groupingKey: null });
} else {
this.onGroupExpanded.notify({ level: i, groupingKey: null });
}
}
} else {
this.toggledGroupsByLevel[level] = {};
this.groupingInfos[level].collapsed = collapse;
if (collapse === true) {
this.onGroupCollapsed.notify({ level, groupingKey: null });
} else {
this.onGroupExpanded.notify({ level, groupingKey: null });
}
}
this.refresh();
}
/**
* @param {Number} [level] Optional level to collapse. If not specified, applies to all levels.
*/
collapseAllGroups(level?: number) {
this.expandCollapseAllGroups(level, true);
}
/**
* @param {Number} [level] Optional level to expand. If not specified, applies to all levels.
*/
expandAllGroups(level?: number) {
this.expandCollapseAllGroups(level, false);
}
expandCollapseGroup(level: number, groupingKey: string, collapse?: boolean) {
// @ts-ignore
this.toggledGroupsByLevel[level][groupingKey] = this.groupingInfos[level].collapsed ^ collapse;
this.refresh();
}
/**
* @param varArgs Either a Slick.Group's "groupingKey" property, or a
* variable argument list of grouping values denoting a unique path to the row. For
* example, calling collapseGroup('high', '10%') will collapse the '10%' subgroup of
* the 'high' group.
*/
collapseGroup(...args: any) {
const calledArgs = Array.prototype.slice.call(args);
const arg0 = calledArgs[0];
let groupingKey: string;
let level: number;
if (args.length === 1 && arg0.indexOf(this.groupingDelimiter) !== -1) {
groupingKey = arg0;
level = arg0.split(this.groupingDelimiter).length - 1;
} else {
groupingKey = args.join(this.groupingDelimiter);
level = args.length - 1;
}
this.expandCollapseGroup(level, groupingKey, true);
this.onGroupCollapsed.notify({ level, groupingKey });
}
/**
* @param varArgs Either a Slick.Group's "groupingKey" property, or a
* variable argument list of grouping values denoting a unique path to the row. For
* example, calling expandGroup('high', '10%') will expand the '10%' subgroup of
* the 'high' group.
*/
expandGroup(...args: any) {
const calledArgs = Array.prototype.slice.call(args);
const arg0 = calledArgs[0];
let groupingKey: string;
let level: number;
if (args.length === 1 && arg0.indexOf(this.groupingDelimiter) !== -1) {
level = arg0.split(this.groupingDelimiter).length - 1;
groupingKey = arg0;
} else {
level = args.length - 1;
groupingKey = args.join(this.groupingDelimiter);
}
this.expandCollapseGroup(level, groupingKey, false);
this.onGroupExpanded.notify({ level, groupingKey });
}
getGroups() {
return this.groups;
}
protected extractGroups(rows: any[], parentGroup?: SlickGroup_) {
let group: SlickGroup_;
let val: any;
const groups: SlickGroup_[] = [];
const groupsByVal: any = {};
let r;
const level = parentGroup ? parentGroup.level + 1 : 0;
const gi = this.groupingInfos[level];
for (let i = 0, l = gi.predefinedValues?.length ?? 0; i < l; i++) {
val = gi.predefinedValues?.[i];
group = groupsByVal[val];
if (!group) {
group = new SlickGroup();
group.value = val;
group.level = level;
group.groupingKey = (parentGroup ? parentGroup.groupingKey + this.groupingDelimiter : '') + val;
groups[groups.length] = group;
groupsByVal[val] = group;
}
}
for (let i = 0, l = rows.length; i < l; i++) {
r = rows[i];
val = gi.getterIsAFn ? (gi.getter as GroupGetterFn)(r) : r[gi.getter as keyof TData];
group = groupsByVal[val];
if (!group) {
group = new SlickGroup();
group.value = val;
group.level = level;
group.groupingKey = (parentGroup ? parentGroup.groupingKey + this.groupingDelimiter : '') + val;
groups[groups.length] = group;
groupsByVal[val] = group;
}
group.rows[group.count++] = r;
}
if (level < this.groupingInfos.length - 1) {
for (let i = 0; i < groups.length; i++) {
group = groups[i];
group.groups = this.extractGroups(group.rows, group);
}
}
if (groups.length) {
this.addTotals(groups, level);
}
groups.sort(this.groupingInfos[level].comparer);
return groups;
}
/** claculate Group Totals */
protected calculateTotals(totals: SlickGroupTotals_) {
const group = totals.group;
const gi = this.groupingInfos[group.level ?? 0];
const isLeafLevel = (group.level === this.groupingInfos.length);
let agg: Aggregator;
let idx = gi.aggregators.length;
if (!isLeafLevel && gi.aggregateChildGroups) {
// make sure all the subgroups are calculated
let i = group.groups?.length ?? 0;
while (i--) {
if (!group.groups[i].totals.initialized) {
this.calculateTotals(group.groups[i].totals);
}
}
}
while (idx--) {
agg = gi.aggregators[idx];
agg.init();
if (!isLeafLevel && gi.aggregateChildGroups) {
gi.compiledAccumulators[idx].call(agg, group.groups);
} else {
gi.compiledAccumulators[idx].call(agg, group.rows);
}
agg.storeResult(totals);
}
totals.initialized = true;
}
protected addGroupTotals(group: SlickGroup_) {
const gi = this.groupingInfos[group.level];
const totals = new SlickGroupTotals();
totals.group = group;
group.totals = totals;
if (!gi.lazyTotalsCalculation) {
this.calculateTotals(totals);
}
}
protected addTotals(groups: SlickGroup_[], level?: number) {
level = level || 0;
const gi = this.groupingInfos[level];
const groupCollapsed = gi.collapsed;
const toggledGroups = this.toggledGroupsByLevel[level];
let idx = groups.length, g;
while (idx--) {
g = groups[idx];
if (g.collapsed && !gi.aggregateCollapsed) {
continue;
}
// Do a depth-first aggregation so that parent group aggregators can access subgroup totals.
if (g.groups) {
this.addTotals(g.groups, level + 1);
}
if (gi.aggregators?.length && (
gi.aggregateEmpty || g.rows.length || g.groups?.length)) {
this.addGroupTotals(g);
}
g.collapsed = (groupCollapsed as any) ^ toggledGroups[g.groupingKey];
g.title = gi.formatter ? gi.formatter(g) : g.value;
}
}
protected flattenGroupedRows(groups: SlickGroup_[], level?: number) {
level = level || 0;
const gi = this.groupingInfos[level];
const groupedRows: any[] = [];
let rows: any[];
let gl = 0;
let g;
for (let i = 0, l = groups.length; i < l; i++) {
g = groups[i];
groupedRows[gl++] = g;
if (!g.collapsed) {
rows = g.groups ? this.flattenGroupedRows(g.groups, level + 1) : g.rows;
for (let j = 0, jj = rows.length; j < jj; j++) {
groupedRows[gl++] = rows[j];
}
}
if (g.totals && gi.displayTotalsRow && (!g.collapsed || gi.aggregateCollapsed)) {
groupedRows[gl++] = g.totals;
}
}
return groupedRows;
}
protected compileAccumulatorLoopCSPSafe(aggregator: Aggregator) {
if (aggregator.accumulate) {
return function (items: any[]) {
let result;
for (let i = 0; i < items.length; i++) {
const item = items[i];
result = aggregator.accumulate!.call(aggregator, item);
}
return result;
};
} else {
return function noAccumulator() { };
}
}
protected compileFilterCSPSafe(items: TData[], args: any): TData[] {
if (typeof this.filterCSPSafe !== 'function') {
return [];
}
const _retval: TData[] = [];
const _il = items.length;
for (let _i = 0; _i < _il; _i++) {
if (this.filterCSPSafe(items[_i], args)) {
_retval.push(items[_i]);
}
}
return _retval;
}
protected compileFilter(stopRunningIfCSPSafeIsActive = false): FilterFn<TData> | null {
if (stopRunningIfCSPSafeIsActive) {
return null as any;
}
const filterInfo = Utils.getFunctionDetails(this.filter as FilterFn<TData>);
const filterPath1 = '{ continue _coreloop; }$1';
const filterPath2 = '{ _retval[_idx++] = $item$; continue _coreloop; }$1';
// make some allowances for minification - there's only so far we can go with RegEx
const filterBody = filterInfo.body
.replace(/return false\s*([;}]|\}|$)/gi, filterPath1)
.replace(/return!1([;}]|\}|$)/gi, filterPath1)
.replace(/return true\s*([;}]|\}|$)/gi, filterPath2)
.replace(/return!0([;}]|\}|$)/gi, filterPath2)
.replace(/return ([^;}]+?)\s*([;}]|$)/gi,
'{ if ($1) { _retval[_idx++] = $item$; }; continue _coreloop; }$2');
// This preserves the function template code after JS compression,
// so that replace() commands still work as expected.
let tpl = [
// 'function(_items, _args) { ',
'var _retval = [], _idx = 0; ',
'var $item$, $args$ = _args; ',
'_coreloop: ',
'for (var _i = 0, _il = _items.length; _i < _il; _i++) { ',
'$item$ = _items[_i]; ',
'$filter$; ',
'} ',
'return _retval; '
// '}'
].join('');
tpl = tpl.replace(/\$filter\$/gi, filterBody);
tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]);
tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]);
const fn: any = new Function('_items,_args', tpl);
const fnName = 'compiledFilter';
fn.displayName = fnName;
fn.name = this.setFunctionName(fn, fnName);
return fn;
}
protected compileFilterWithCaching(stopRunningIfCSPSafeIsActive = false) {
if (stopRunningIfCSPSafeIsActive) {
return null as any;
}
const filterInfo = Utils.getFunctionDetails(this.filter as FilterFn<TData>);
const filterPath1 = '{ continue _coreloop; }$1';
const filterPath2 = '{ _cache[_i] = true;_retval[_idx++] = $item$; continue _coreloop; }$1';
// make some allowances for minification - there's only so far we can go with RegEx
const filterBody = filterInfo.body
.replace(/return false\s*([;}]|\}|$)/gi, filterPath1)
.replace(/return!1([;}]|\}|$)/gi, filterPath1)
.replace(/return true\s*([;}]|\}|$)/gi, filterPath2)
.replace(/return!0([;}]|\}|$)/gi, filterPath2)
.replace(/return ([^;}]+?)\s*([;}]|$)/gi,
'{ if ((_cache[_i] = $1)) { _retval[_idx++] = $item$; }; continue _coreloop; }$2');
// This preserves the function template code after JS compression,
// so that replace() commands still work as expected.
let tpl = [
// 'function(_items, _args, _cache) { ',
'var _retval = [], _idx = 0; ',
'var $item$, $args$ = _args; ',
'_coreloop: ',
'for (var _i = 0, _il = _items.length; _i < _il; _i++) { ',
'$item$ = _items[_i]; ',
'if (_cache[_i]) { ',
'_retval[_idx++] = $item$; ',
'continue _coreloop; ',
'} ',
'$filter$; ',
'} ',
'return _retval; '
// '}'
].join('');
tpl = tpl.replace(/\$filter\$/gi, filterBody);
tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]);
tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]);
const fn: any = new Function('_items,_args,_cache', tpl);
const fnName = 'compiledFilterWithCaching';
fn.displayName = fnName;
fn.name = this.setFunctionName(fn, fnName);
return fn;
}
protected compileFilterWithCachingCSPSafe(items: TData[], args: any, filterCache: any[]): TData[] {
if (typeof this.filterCSPSafe !== 'function') {
return [];
}
const retval: TData[] = [];
const il = items.length;
for (let _i = 0; _i < il; _i++) {
if (filterCache[_i] || this.filterCSPSafe(items[_i], args)) {
retval.push(items[_i]);
}
}
return retval;
}
/**
* In ES5 we could set the function name on the fly but in ES6 this is forbidden and we need to set it through differently
* We can use Object.defineProperty and set it the property to writable, see MDN for reference
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
* @param {*} fn
* @param {string} fnName
*/
protected setFunctionName(fn: any, fnName: string) {
try {
Object.defineProperty(fn, 'name', { writable: true, value: fnName });
} catch (err) {
fn.name = fnName;
}
}
protected uncompiledFilter(items: TData[], args: any) {
const retval: any[] = [];
let idx = 0;
for (let i = 0, ii = items.length; i < ii; i++) {
if (this.filter?.(items[i], args)) {
retval[idx++] = items[i];
}
}
return retval;
}
protected uncompiledFilterWithCaching(items: TData[], args: any, cache: any) {
const retval: any[] = [];
let idx = 0,
item: TData;
for (let i = 0, ii = items.length; i < ii; i++) {
item = items[i];
if (cache[i]) {
retval[idx++] = item;
} else if (this.filter?.(item, args)) {
retval[idx++] = item;
cache[i] = true;
}
}
return retval;
}
protected getFilteredAndPagedItems(items: TData[]) {
if (this._options.useCSPSafeFilter ? this.filterCSPSafe : this.filter) {
let batchFilter: AnyFunction;
let batchFilterWithCaching: AnyFunction;
if (this._options.useCSPSafeFilter) {
batchFilter = (this._options.inlineFilters ? this.compiledFilterCSPSafe : this.uncompiledFilter) as AnyFunction;
batchFilterWithCaching = (this._options.inlineFilters ? this.compiledFilterWithCachingCSPSafe : this.uncompiledFilterWithCaching) as AnyFunction;
} else {
batchFilter = (this._options.inlineFilters ? this.compiledFilter : this.uncompiledFilter) as AnyFunction;
batchFilterWithCaching = (this._options.inlineFilters ? this.compiledFilterWithCaching : this.uncompiledFilterWithCaching) as AnyFunction;
}
if (this.refreshHints.isFilterNarrowing) {
this.filteredItems = batchFilter.call(this, this.filteredItems, this.filterArgs);
} else if (this.refreshHints.isFilterExpanding) {
this.filteredItems = batchFilterWithCaching.call(this, items, this.filterArgs, this.filterCache);
} else if (!this.refreshHints.isFilterUnchanged) {
this.filteredItems = batchFilter.call(this, items, this.filterArgs);
}
} else {
// special case: if not filtering and not paging, the resulting
// rows collection needs to be a copy so that changes due to sort
// can be caught
this.filteredItems = this.pagesize ? items : items.concat();
}
// get the current page
let paged: TData[];
if (this.pagesize) {
if (this.filteredItems.length <= this.pagenum * this.pagesize) {
if (this.filteredItems.length === 0) {
this.pagenum = 0;
} else {
this.pagenum = Math.floor((this.filteredItems.length - 1) / this.pagesize);
}
}
paged = this.filteredItems.slice(this.pagesize * this.pagenum, this.pagesize * this.pagenum + this.pagesize);
} else {
paged = this.filteredItems;
}
return { totalRows: this.filteredItems.length, rows: paged };
}
protected getRowDiffs(rows: TData[], newRows: TData[]) {
let item: TData | SlickNonDataItem | SlickDataItem | SlickGroup_;
let r;
let eitherIsNonData;
const diff: number[] = [];
let from = 0;
let to = Math.max(newRows.length, rows.length);
if (this.refreshHints?.ignoreDiffsBefore) {
from = Math.max(0,
Math.min(newRows.length, this.refreshHints.ignoreDiffsBefore));
}
if (this.refreshHints?.ignoreDiffsAfter) {
to = Math.min(newRows.length,
Math.max(0, this.refreshHints.ignoreDiffsAfter));
}
for (let i = from, rl = rows.length; i < to; i++) {
if (i >= rl) {
diff[diff.length] = i;
} else {
item = newRows[i];
r = rows[i];
if (!item || (this.groupingInfos.length && (eitherIsNonData = ((item as SlickNonDataItem).__nonDataRow) || ((r as SlickNonDataItem).__nonDataRow)) &&
(item as SlickGroup_).__group !== (r as SlickGroup_).__group ||
(item as SlickGroup_).__group && !(item as SlickGroup_).equals(r as SlickGroup_))
|| (eitherIsNonData &&
// no good way to compare totals since they are arbitrary DTOs
// deep object comparison is pretty expensive
// always considering them 'dirty' seems easier for the time being
((item as SlickGroupTotals_).__groupTotals || (r as SlickGroupTotals_).__groupTotals))
|| item[this.idProperty as keyof TData] !== r[this.idProperty as keyof TData]
|| (this.updated?.[item[this.idProperty as keyof TData]])
) {
diff[diff.length] = i;
}
}
}
return diff;
}
protected recalc(_items: TData[]) {
this.rowsById = undefined;
if (this.refreshHints.isFilterNarrowing !== this.prevRefreshHints.isFilterNarrowing ||
this.refreshHints.isFilterExpanding !== this.prevRefreshHints.isFilterExpanding) {
this.filterCache = [];
}
const filteredItems = this.getFilteredAndPagedItems(_items);
this.totalRows = filteredItems.totalRows;
let newRows: TData[] = filteredItems.rows;
this.groups = [];
if (this.groupingInfos.length) {
this.groups = this.extractGroups(newRows);
if (this.groups.length) {
newRows = this.flattenGroupedRows(this.groups);
}
}
const diff = this.getRowDiffs(this.rows, newRows as TData[]);
this.rows = newRows as TData[];
return diff;
}
refresh() {
if (this.suspend) {
return;
}
const previousPagingInfo = Utils.extend(true, {}, this.getPagingInfo());
const countBefore = this.rows.length;
const totalRowsBefore = this.totalRows;
let diff = this.recalc(this.items); // pass as direct refs to avoid closure perf hit
// if the current page is no longer valid, go to last page and recalc
// we suffer a performance penalty here, but the main loop (recalc) remains highly optimized
if (this.pagesize && this.totalRows < this.pagenum * this.pagesize) {
this.pagenum = Math.max(0, Math.ceil(this.totalRows / this.pagesize) - 1);
diff = this.recalc(this.items);
}
this.updated = null;
this.prevRefreshHints = this.refreshHints;
this.refreshHints = {};
if (totalRowsBefore !== this.totalRows) {
// use the previously saved paging info
if (this.onBeforePagingInfoChanged.notify(previousPagingInfo, null, this).getReturnValue() !== false) {
this.onPagingInfoChanged.notify(this.getPagingInfo(), null, this);
}
}
if (countBefore !== this.rows.length) {
this.onRowCountChanged.notify({ previous: countBefore, current: this.rows.length, itemCount: this.items.length, dataView: this, callingOnRowsChanged: (diff.length > 0) }, null, this);
}
if (diff.length > 0) {
this.onRowsChanged.notify({ rows: diff, itemCount: this.items.