]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/grid/grid.ts
lp1993824: grids enhancement; support for saving filter sets
[Evergreen.git] / Open-ILS / src / eg2 / src / app / share / grid / grid.ts
1 /**
2  * Collection of grid related classses and interfaces.
3  */
4 import {TemplateRef, EventEmitter, QueryList} from '@angular/core';
5 import {Observable, Subscription} from 'rxjs';
6 import {IdlService, IdlObject} from '@eg/core/idl.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {ServerStoreService} from '@eg/core/server-store.service';
9 import {FormatService} from '@eg/core/format.service';
10 import {Pager} from '@eg/share/util/pager';
11 import {GridFilterControlComponent} from './grid-filter-control.component';
12
13 const MAX_ALL_ROW_COUNT = 10000;
14
15 export class GridColumn {
16     name: string;
17     path: string;
18     label: string;
19     headerLabel: string;
20     flex: number;
21     align: string;
22     hidden: boolean;
23     visible: boolean;
24     sort: number;
25     // IDL class of the object which contains this field.
26     // Not to be confused with the class of a linked object.
27     idlClass: string;
28     idlFieldDef: any;
29     datatype: string;
30     datePlusTime: boolean;
31     ternaryBool: boolean;
32     timezoneContextOrg: number;
33     cellTemplate: TemplateRef<any>;
34     dateOnlyIntervalField: string;
35
36     cellContext: any;
37     isIndex: boolean;
38     isDragTarget: boolean;
39     isSortable: boolean;
40     isFilterable: boolean;
41     isFiltered: boolean;
42     isMultiSortable: boolean;
43     disableTooltip: boolean;
44     asyncSupportsEmptyTermClick: boolean;
45     comparator: (valueA: any, valueB: any) => number;
46     required = false;
47
48     // True if the column was automatically generated.
49     isAuto: boolean;
50
51     // for filters
52     filterValue: string;
53     filterOperator: string;
54     filterInputDisabled: boolean;
55     filterIncludeOrgAncestors: boolean;
56     filterIncludeOrgDescendants: boolean;
57
58     flesher: (obj: any, col: GridColumn, item: any) => any;
59
60     getCellContext(row: any) {
61         return {
62           col: this,
63           row: row,
64           userContext: this.cellContext
65         };
66     }
67
68     constructor() {
69         this.removeFilter();
70     }
71
72     removeFilter() {
73         this.isFiltered = false;
74         this.filterValue = undefined;
75         this.filterOperator = '=';
76         this.filterInputDisabled = false;
77         this.filterIncludeOrgAncestors = false;
78         this.filterIncludeOrgDescendants = false;
79     }
80
81     loadFilter(f:any) {
82         this.isFiltered = f.isFiltered;
83         this.filterValue = f.filterValue;
84         this.filterOperator = f.filterOperator;
85         this.filterInputDisabled = f.filterInputDisabled;
86         this.filterIncludeOrgAncestors = f.filterIncludeOrgAncestors;
87         this.filterIncludeOrgDescendants = f.IncludeOrgDescendants;
88     }
89
90     getIdlId(value: any) {
91         const obj: IdlObject = (value as unknown) as IdlObject;
92         return obj.id();
93     }
94
95     getFilter() {
96         return {
97             'isFiltered': this.isFiltered,
98             'filterValue': typeof this.filterValue === 'object' ? this.getIdlId(this.filterValue) : this.filterValue,
99             'filterOperator': this.filterOperator,
100             'filterInputDisabled': this.filterInputDisabled,
101             'filterIncludeOrgAncestors': this.filterIncludeOrgAncestors,
102             'filterIncludeOrgDescendants': this.filterIncludeOrgDescendants
103         }
104     }
105
106     clone(): GridColumn {
107         const col = new GridColumn();
108
109         col.name = this.name;
110         col.path = this.path;
111         col.label = this.label;
112         col.flex = this.flex;
113         col.required = this.required;
114         col.hidden = this.hidden;
115         col.asyncSupportsEmptyTermClick = this.asyncSupportsEmptyTermClick;
116         col.isIndex = this.isIndex;
117         col.cellTemplate = this.cellTemplate;
118         col.cellContext = this.cellContext;
119         col.disableTooltip = this.disableTooltip;
120         col.isSortable = this.isSortable;
121         col.isFilterable = this.isFilterable;
122         col.isMultiSortable = this.isMultiSortable;
123         col.datatype = this.datatype;
124         col.datePlusTime = this.datePlusTime;
125         col.ternaryBool = this.ternaryBool;
126         col.timezoneContextOrg = this.timezoneContextOrg;
127         col.idlClass = this.idlClass;
128         col.isAuto = this.isAuto;
129
130         return col;
131     }
132
133 }
134
135 export class GridColumnSet {
136     columns: GridColumn[];
137     idlClass: string;
138     indexColumn: GridColumn;
139     isSortable: boolean;
140     isFilterable: boolean;
141     isMultiSortable: boolean;
142     stockVisible: string[];
143     idl: IdlService;
144     defaultHiddenFields: string[];
145     defaultVisibleFields: string[];
146
147     constructor(idl: IdlService, idlClass?: string) {
148         this.idl = idl;
149         this.columns = [];
150         this.stockVisible = [];
151         this.idlClass = idlClass;
152     }
153
154     add(col: GridColumn) {
155
156         if (col.path && col.path.match(/\*$/)) {
157             return this.generateWildcardColumns(col);
158         }
159
160         this.applyColumnDefaults(col);
161
162         if (!this.insertColumn(col)) {
163             // Column was rejected as a duplicate.
164             return;
165         }
166
167         if (col.isIndex) { this.indexColumn = col; }
168
169         // track which fields are visible on page load.
170         if (col.visible) {
171             this.stockVisible.push(col.name);
172         }
173
174         this.applyColumnSortability(col);
175         this.applyColumnFilterability(col);
176     }
177
178     generateWildcardColumns(col: GridColumn) {
179
180         const dotpath = col.path.replace(/\.?\*$/, '');
181         let classObj:IdlObject, idlField:any;
182
183         if (col.idlClass) {
184             classObj = this.idl.classes[col.idlClass];
185         } else {
186             classObj = this.idl.classes[this.idlClass];
187         }
188
189         if (!classObj) { return; }
190
191         const pathParts = dotpath.split(/\./);
192         //let oldField;
193
194         // find the IDL class definition for the last element in the
195         // path before the .*
196         // An empty pathParts means expand the root class
197         pathParts.forEach((part, pathIdx) => {
198             //oldField = idlField;
199             idlField = classObj.field_map[part];
200
201             // unless we're at the end of the list, this field should
202             // link to another class.
203             if (idlField && idlField['class'] && (
204                 idlField.datatype === 'link' || idlField.datatype === 'org_unit')) {
205                 classObj = this.idl.classes[idlField['class']];
206
207             } else {
208                 if (pathIdx < (pathParts.length - 1)) {
209                     // we ran out of classes to hop through before
210                     // we ran out of path components
211                     console.warn('Grid: invalid IDL path: ' + dotpath);
212                 }
213             }
214         });
215
216         if (!classObj) {
217             console.warn(
218                 'Grid: wildcard path does not resolve to an object:' + dotpath);
219             return;
220         }
221
222         classObj.fields.forEach((field:any) => {
223
224             // Only show wildcard fields where we have data to show
225             // Virtual and un-fleshed links will not have any data.
226             if (field.virtual ||
227                 field.datatype === 'link' || field.datatype === 'org_unit') {
228                 return;
229             }
230
231             const newCol = col.clone();
232             newCol.isAuto = true;
233             newCol.path = dotpath ? dotpath + '.' + field.name : field.name;
234             newCol.label = dotpath ? classObj.label + ': ' + field.label : field.label;
235             newCol.datatype = field.datatype;
236
237             // Avoid including the class label prefix in the main grid
238             // header display so it doesn't take up so much horizontal space.
239             newCol.headerLabel = field.label;
240
241             this.add(newCol);
242         });
243     }
244
245     // Returns true if the new column was inserted, false otherwise.
246     // Declared columns take precedence over auto-generated columns
247     // when collisions occur.
248     // Declared columns are inserted in front of auto columns.
249     insertColumn(col: GridColumn): boolean {
250
251         if (col.isAuto) {
252             if (this.getColByName(col.name) || this.getColByPath(col.path)) {
253                 // New auto-generated column conflicts with existing
254                 // column.  Skip it.
255                 return false;
256             } else {
257                 // No collisions.  Add to the end of the list
258                 this.columns.push(col);
259                 return true;
260             }
261         }
262
263         // Adding a declared column.
264
265         // Check for dupes.
266         for (let idx = 0; idx < this.columns.length; idx++) {
267             const testCol = this.columns[idx];
268             if (testCol.name === col.name) { // match found
269                 if (testCol.isAuto) {
270                     // new column takes precedence, remove the existing column.
271                     this.columns.splice(idx, 1);
272                     break;
273                 } else {
274                     // New column does not take precedence.  Avoid
275                     // inserting it.
276                     return false;
277                 }
278             }
279         }
280
281         // Delcared columns are inserted just before the first auto-column
282         for (let idx = 0; idx < this.columns.length; idx++) {
283             const testCol = this.columns[idx];
284             if (testCol.isAuto) {
285                 if (idx === 0) {
286                     this.columns.unshift(col);
287                 } else {
288                     this.columns.splice(idx, 0, col);
289                 }
290                 return true;
291             }
292         }
293
294         // No insertion point found.  Toss the new column on the end.
295         this.columns.push(col);
296         return true;
297     }
298
299     getColByName(name: string): GridColumn {
300         return this.columns.filter(c => c.name === name)[0];
301     }
302
303     getColByPath(path: string): GridColumn {
304         if (path) {
305             return this.columns.filter(c => c.path === path)[0];
306         }
307     }
308
309     idlInfoFromDotpath(dotpath: string): any {
310         if (!dotpath || !this.idlClass) { return null; }
311
312         let idlParent:any;
313         let idlField:any;
314         let idlClass:any;
315         let nextIdlClass = this.idl.classes[this.idlClass];
316
317         const pathParts = dotpath.split(/\./);
318
319         for (let i = 0; i < pathParts.length; i++) {
320
321             const part = pathParts[i];
322             idlParent = idlField;
323             idlClass = nextIdlClass;
324             idlField = idlClass.field_map[part];
325
326             if (!idlField) { return null; } // invalid IDL path
327
328             if (i === pathParts.length - 1) {
329                 // No more links to process.
330                 break;
331             }
332
333             if (idlField['class'] && (
334                 idlField.datatype === 'link' ||
335                 idlField.datatype === 'org_unit')) {
336                 // The link class on the current field refers to the
337                 // class of the link destination, not the current field.
338                 // Mark it for processing during the next iteration.
339                 nextIdlClass = this.idl.classes[idlField['class']];
340             }
341         }
342
343         return {
344             idlParent: idlParent,
345             idlField : idlField,
346             idlClass : idlClass
347         };
348     }
349
350
351     reset() {
352         this.columns.forEach(col => {
353             col.flex = 2;
354             col.sort = 0;
355             col.align = 'left';
356             col.visible = this.stockVisible.includes(col.name);
357         });
358     }
359
360     applyColumnDefaults(col: GridColumn) {
361
362         if (!col.idlFieldDef) {
363             const idlInfo = this.idlInfoFromDotpath(col.path || col.name);
364             if (idlInfo) {
365                 col.idlFieldDef = idlInfo.idlField;
366                 col.idlClass = idlInfo.idlClass.name;
367                 if (!col.datatype) {
368                     col.datatype = col.idlFieldDef.datatype;
369                 }
370                 if (!col.label) {
371                     col.label = col.idlFieldDef.label || col.idlFieldDef.name;
372                 }
373             }
374         }
375
376         if (!col.name) { col.name = col.path; }
377         if (!col.flex) { col.flex = 2; }
378         if (!col.align) { col.align = 'left'; }
379         if (!col.label) { col.label = col.name; }
380         if (!col.datatype) { col.datatype = 'text'; }
381         if (!col.isAuto) { col.headerLabel = col.label; }
382
383         col.visible = !col.hidden;
384     }
385
386     applyColumnSortability(col: GridColumn) {
387         // column sortability defaults to the sortability of the column set.
388         if (col.isSortable === undefined && this.isSortable) {
389             col.isSortable = true;
390         }
391
392         if (col.isMultiSortable === undefined && this.isMultiSortable) {
393             col.isMultiSortable = true;
394         }
395
396         if (col.isMultiSortable) {
397             col.isSortable = true;
398         }
399     }
400     applyColumnFilterability(col: GridColumn) {
401         // column filterability defaults to the afilterability of the column set.
402         if (col.isFilterable === undefined && this.isFilterable) {
403             col.isFilterable = true;
404         }
405     }
406
407     displayColumns(): GridColumn[] {
408         return this.columns.filter(c => c.visible);
409     }
410
411     // Sorted visible columns followed by sorted non-visible columns.
412     // Note we don't sort this.columns directly as it would impact
413     // grid column display ordering.
414     sortForColPicker(): GridColumn[] {
415         const visible = this.columns.filter(c => c.visible);
416         const invisible = this.columns.filter(c => !c.visible);
417
418         visible.sort((a, b) => a.label < b.label ? -1 : 1);
419         invisible.sort((a, b) => a.label < b.label ? -1 : 1);
420
421         return visible.concat(invisible);
422     }
423
424     requiredColumns(): GridColumn[] {
425         const visible = this.displayColumns();
426         return visible.concat(
427             this.columns.filter(c => c.required && !c.visible));
428     }
429
430     insertBefore(source: GridColumn, target: GridColumn) {
431         let targetIdx = -1;
432         let sourceIdx = -1;
433         this.columns.forEach((col, idx) => {
434             if (col.name === target.name) { targetIdx = idx; }});
435
436         this.columns.forEach((col, idx) => {
437             if (col.name === source.name) { sourceIdx = idx; }});
438
439         if (sourceIdx >= 0) {
440             this.columns.splice(sourceIdx, 1);
441         }
442
443         this.columns.splice(targetIdx, 0, source);
444     }
445
446     // Move visible columns to the front of the list.
447     moveVisibleToFront() {
448         const newCols = this.displayColumns();
449         this.columns.forEach(col => {
450             if (!col.visible) { newCols.push(col); }});
451         this.columns = newCols;
452     }
453
454     moveColumn(col: GridColumn, diff: number) {
455         let srcIdx:number, targetIdx:number;
456
457         this.columns.forEach((c, i) => {
458           if (c.name === col.name) { srcIdx = i; }
459         });
460
461         targetIdx = srcIdx + diff;
462         if (targetIdx < 0) {
463             targetIdx = 0;
464         } else if (targetIdx >= this.columns.length) {
465             // Target index follows the last visible column.
466             let lastVisible = 0;
467             this.columns.forEach((c, idx) => {
468                 if (c.visible) { lastVisible = idx; }
469             });
470
471             // When moving a column (down) causes one or more
472             // visible columns to shuffle forward, our column
473             // moves into the slot of the last visible column.
474             // Otherwise, put it into the slot directly following
475             // the last visible column.
476             targetIdx = srcIdx <= lastVisible ? lastVisible : lastVisible + 1;
477         }
478
479         // Splice column out of old position, insert at new position.
480         this.columns.splice(srcIdx, 1);
481         this.columns.splice(targetIdx, 0, col);
482     }
483
484     compileSaveObject(): GridColumnPersistConf[] {
485         // only store information about visible columns.
486         // scrunch the data down to just the needed info.
487         return this.displayColumns().map(col => {
488             const c: GridColumnPersistConf = {name : col.name};
489             if (col.align !== 'left') { c.align = col.align; }
490             if (col.flex !== 2) { c.flex = Number(col.flex); }
491             if (Number(col.sort)) { c.sort = Number(col.sort); }
492             return c;
493         });
494     }
495
496     applyColumnSettings(conf: GridColumnPersistConf[]) {
497
498         if (!conf || conf.length === 0) {
499             // No configuration is available, but we have a list of
500             // fields to show or hide by default
501
502             if (this.defaultVisibleFields) {
503                 this.columns.forEach(col => {
504                     if (this.defaultVisibleFields.includes(col.name)) {
505                         col.visible = true;
506                     } else {
507                         col.visible = false;
508                     }
509                 });
510
511             } else if (this.defaultHiddenFields) {
512                 this.defaultHiddenFields.forEach(name => {
513                     const col = this.getColByName(name);
514                     if (col) {
515                         col.visible = false;
516                     }
517                 });
518             }
519
520             return;
521         }
522
523         const newCols = [];
524
525         conf.forEach(colConf => {
526             const col = this.getColByName(colConf.name);
527             if (!col) { return; } // no such column in this grid.
528
529             col.visible = true;
530             if (colConf.align) { col.align = colConf.align; }
531             if (colConf.flex)  { col.flex = Number(colConf.flex); }
532             if (colConf.sort)  { col.sort = Number(colConf.sort); }
533
534             // Add to new columns array, avoid dupes.
535             if (newCols.filter(c => c.name === col.name).length === 0) {
536                 newCols.push(col);
537             }
538         });
539
540         // columns which are not expressed within the saved
541         // configuration are marked as non-visible and
542         // appended to the end of the new list of columns.
543         this.columns.forEach(c => {
544             if (conf.filter(cf => cf.name === c.name).length === 0) {
545                 c.visible = false;
546                 newCols.push(c);
547             }
548         });
549
550         this.columns = newCols;
551     }
552 }
553
554 // Maps colunm names to functions which return plain text values for
555 // each mapped column on a given row.  This is primarily useful for
556 // generating print-friendly content for grid cells rendered via
557 // cellTemplate.
558 //
559 // USAGE NOTE: Since a cellTemplate can be passed arbitrary context
560 //             but a GridCellTextGenerator only gets the row object,
561 //             if it's important to include content that's not available
562 //             by default in the row object, you may want to stick
563 //             it in the row object as an additional attribute.
564 //
565 export interface GridCellTextGenerator {
566     [columnName: string]: (row: any) => string;
567 }
568
569 export class GridRowSelector {
570     indexes: {[string: string]: boolean};
571
572     // Track these so we can emit the selectionChange event
573     // only when the selection actually changes.
574     previousSelection: string[] = [];
575
576     // Emits the selected indexes on selection change
577     selectionChange: EventEmitter<string[]> = new EventEmitter<string[]>();
578
579     constructor() {
580         this.clear();
581     }
582
583     // Returns true if all of the requested indexes exist in the selector.
584     contains(index: string | string[]): boolean {
585         const indexes = [].concat(index);
586         for (let i = 0; i < indexes.length; i++) { // early exit
587             if (!this.indexes[indexes[i]]) {
588                 return false;
589             }
590         }
591         return true;
592     }
593
594     emitChange() {
595         const keys = this.selected();
596
597         if (keys.length === this.previousSelection.length &&
598             this.contains(this.previousSelection)) {
599             return; // No change has occurred
600         }
601
602         this.previousSelection = keys;
603         this.selectionChange.emit(keys);
604     }
605
606     select(index: string | string[]) {
607         const indexes = [].concat(index);
608         indexes.forEach(i => this.indexes[i] = true);
609         this.emitChange();
610     }
611
612     deselect(index: string | string[]) {
613         const indexes = [].concat(index);
614         indexes.forEach(i => delete this.indexes[i]);
615         this.emitChange();
616     }
617
618     toggle(index: string) {
619         if (this.indexes[index]) {
620             this.deselect(index);
621         } else {
622             this.select(index);
623         }
624     }
625
626     selected(): string[] {
627         return Object.keys(this.indexes);
628     }
629
630     isEmpty(): boolean {
631         return this.selected().length === 0;
632     }
633
634     clear() {
635         this.indexes = {};
636         this.emitChange();
637     }
638 }
639
640 export interface GridRowFlairEntry {
641     icon: string;   // name of material icon
642     title?: string;  // tooltip string
643 }
644
645 export class GridColumnPersistConf {
646     name: string;
647     flex?: number;
648     sort?: number;
649     align?: string;
650 }
651
652 export class GridPersistConf {
653     version: number;
654     limit: number;
655     columns: GridColumnPersistConf[];
656     hideToolbarActions: string[];
657 }
658
659 export class GridContext {
660
661     pager: Pager;
662     idlClass: string;
663     isSortable: boolean;
664     isFilterable: boolean;
665     initialFilterValues: {[field: string]: string};
666     allowNamedFilterSets: boolean;
667     migrateLegacyFilterSets: string;
668     stickyGridHeader: boolean;
669     isMultiSortable: boolean;
670     useLocalSort: boolean;
671     persistKey: string;
672     disableMultiSelect: boolean;
673     disableSelect: boolean;
674     dataSource: GridDataSource;
675     columnSet: GridColumnSet;
676     autoGeneratedColumnOrder: string;
677     rowSelector: GridRowSelector;
678     toolbarLabel: string;
679     toolbarButtons: GridToolbarButton[];
680     toolbarCheckboxes: GridToolbarCheckbox[];
681     toolbarActions: GridToolbarAction[];
682     lastSelectedIndex: any;
683     pageChanges: Subscription;
684     rowFlairIsEnabled: boolean;
685     rowFlairCallback: (row: any) => GridRowFlairEntry;
686     rowClassCallback: (row: any) => string;
687     cellClassCallback: (row: any, col: GridColumn) => string;
688     defaultVisibleFields: string[];
689     defaultHiddenFields: string[];
690     ignoredFields: string[];
691     overflowCells: boolean;
692     disablePaging: boolean;
693     showDeclaredFieldsOnly: boolean;
694     cellTextGenerator: GridCellTextGenerator;
695     reloadOnColumnChange: boolean;
696
697     // Allow calling code to know when the select-all-rows-in-page
698     // action has occurred.
699     selectRowsInPageEmitter: EventEmitter<void>;
700
701     filterControls: QueryList<GridFilterControlComponent>;
702
703     // Services injected by our grid component
704     idl: IdlService;
705     org: OrgService;
706     store: ServerStoreService;
707     format: FormatService;
708
709     constructor(
710         idl: IdlService,
711         org: OrgService,
712         store: ServerStoreService,
713         format: FormatService) {
714
715         this.idl = idl;
716         this.org = org;
717         this.store = store;
718         this.format = format;
719         this.pager = new Pager();
720         this.rowSelector = new GridRowSelector();
721         this.toolbarButtons = [];
722         this.toolbarCheckboxes = [];
723         this.toolbarActions = [];
724     }
725
726     init() {
727         this.selectRowsInPageEmitter = new EventEmitter<void>();
728         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
729         this.columnSet.isSortable = this.isSortable === true;
730         this.columnSet.isFilterable = this.isFilterable === true;
731         this.columnSet.isMultiSortable = this.isMultiSortable === true;
732         this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
733         this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
734         if (!this.pager.limit) {
735             this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
736         }
737         this.generateColumns();
738     }
739
740     // Load initial settings and data.
741     initData() {
742         this.applyGridConfig()
743         .then(() => this.dataSource.requestPage(this.pager))
744         .then(() => this.listenToPager());
745     }
746
747     destroy() {
748         this.ignorePager();
749     }
750
751         async applyGridConfig(): Promise<void> {
752                 try {
753                         const conf = await this.getGridConfig(this.persistKey);
754                         let columns = [];
755                         if (conf) {
756                                 columns = conf.columns;
757                                 if (conf.limit && !this.disablePaging) {
758                                         this.pager.limit = conf.limit;
759                                 }
760                                 this.applyToolbarActionVisibility(conf.hideToolbarActions);
761                         }
762
763                         // This is called regardless of the presence of saved
764                         // settings so defaults can be applied.
765                         this.columnSet.applyColumnSettings(columns);
766                 } catch (error) {
767                         console.error('Error applying grid config:', error);
768                 }
769         }
770
771
772     applyToolbarActionVisibility(hidden: string[]) {
773         if (!hidden || hidden.length === 0) { return; }
774
775         const groups = [];
776         this.toolbarActions.forEach(action => {
777             if (action.isGroup) {
778                 groups.push(action);
779             } else if (!action.isSeparator) {
780                 action.hidden = hidden.includes(action.label);
781             }
782         });
783
784         // If all actions in a group are hidden, hide the group as well.
785         // Note the group may be marked as hidden in the configuration,
786         // but the addition of new entries within a group should cause
787         // it to be visible again.
788         groups.forEach(group => {
789             const visible = this.toolbarActions
790                 .filter(action => action.group === group.label && !action.hidden);
791             group.hidden = visible.length === 0;
792         });
793     }
794
795     reload() {
796         // Give the UI time to settle before reloading grid data.
797         // This can help when data retrieval depends on a value
798         // getting modified by an angular digest cycle.
799         setTimeout(() => {
800             this.pager.reset();
801             this.dataSource.reset();
802             this.dataSource.requestPage(this.pager);
803         });
804     }
805
806     reloadWithoutPagerReset() {
807         setTimeout(() => {
808             this.dataSource.reset();
809             this.dataSource.requestPage(this.pager);
810         });
811     }
812
813     // Sort the existing data source instead of requesting sorted
814     // data from the client.  Reset pager to page 1.  As with reload(),
815     // give the client a chance to setting before redisplaying.
816     sortLocal() {
817         setTimeout(() => {
818             this.pager.reset();
819             this.sortLocalData();
820             this.dataSource.requestPage(this.pager);
821         });
822     }
823
824     // Subscribe or unsubscribe to page-change events from the pager.
825     listenToPager() {
826         if (this.pageChanges) { return; }
827         this.pageChanges = this.pager.onChange$.subscribe(
828             () => this.dataSource.requestPage(this.pager));
829     }
830
831     ignorePager() {
832         if (!this.pageChanges) { return; }
833         this.pageChanges.unsubscribe();
834         this.pageChanges = null;
835     }
836
837     // Sort data in the data source array
838     sortLocalData() {
839
840         const sortDefs = this.dataSource.sort.map(sort => {
841             const column = this.columnSet.getColByName(sort.name);
842
843             const def = {
844                 name: sort.name,
845                 dir: sort.dir,
846                 col: column
847             };
848
849             if (!def.col.comparator) {
850                 switch (def.col.datatype) {
851                     case 'id':
852                     case 'money':
853                     case 'int':
854                         def.col.comparator = (a, b) => {
855                             a = Number(a);
856                             b = Number(b);
857                             if (a < b) { return -1; }
858                             if (a > b) { return 1; }
859                             return 0;
860                         };
861                         break;
862                     default:
863                         def.col.comparator = (a, b) => {
864                             if (a < b) { return -1; }
865                             if (a > b) { return 1; }
866                             return 0;
867                         };
868                 }
869             }
870
871             return def;
872         });
873
874         this.dataSource.data.sort((rowA, rowB) => {
875
876             for (let idx = 0; idx < sortDefs.length; idx++) {
877                 const sortDef = sortDefs[idx];
878
879                 const valueA = this.getRowColumnValue(rowA, sortDef.col);
880                 const valueB = this.getRowColumnValue(rowB, sortDef.col);
881
882                 if (valueA === '' && valueB === '') { continue; }
883                 if (valueA === '' && valueB !== '') { return 1; }
884                 if (valueA !== '' && valueB === '') { return -1; }
885
886                 const diff = sortDef.col.comparator(valueA, valueB);
887                 if (diff === 0) { continue; }
888
889                 return sortDef.dir === 'DESC' ? -diff : diff;
890             }
891
892             return 0; // No differences found.
893         });
894     }
895
896     getRowIndex(row: any): any {
897         const col = this.columnSet.indexColumn;
898         if (!col) {
899             throw new Error('grid index column required');
900         }
901         return this.getRowColumnValue(row, col);
902     }
903
904     // Returns position in the data source array of the row with
905     // the provided index.
906     getRowPosition(index: any): number {
907         // for-loop for early exit
908         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
909             const row = this.dataSource.data[idx];
910             if (row !== undefined && index === this.getRowIndex(row)) {
911                 return idx;
912             }
913         }
914     }
915
916     // Return the row with the provided index.
917     getRowByIndex(index: any): any {
918         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
919             const row = this.dataSource.data[idx];
920             if (row !== undefined && index === this.getRowIndex(row)) {
921                 return row;
922             }
923         }
924     }
925
926     // Returns all selected rows, regardless of whether they are
927     // currently visible in the grid display.
928     // De-selects previously selected rows which are no longer
929     // present in the grid.
930     getSelectedRows(): any[] {
931         const selected = [];
932         const deleted = [];
933
934         this.rowSelector.selected().forEach(index => {
935             const row = this.getRowByIndex(index);
936             if (row) {
937                 selected.push(row);
938             } else {
939                 deleted.push(index);
940             }
941         });
942
943         this.rowSelector.deselect(deleted);
944         return selected;
945     }
946
947     rowIsSelected(row: any): boolean {
948         const index = this.getRowIndex(row);
949         return this.rowSelector.selected().filter(
950             idx => idx === index
951         ).length > 0;
952     }
953
954     getRowColumnBareValue(row: any, col: GridColumn): any {
955         if (col.name in row) {
956             return this.getObjectFieldValue(row, col.name);
957         } else if (col.path) {
958             return this.nestedItemFieldValue(row, col);
959         }
960     }
961
962     getRowColumnValue(row: any, col: GridColumn): any {
963         const val = this.getRowColumnBareValue(row, col);
964
965         if (col.datatype === 'bool') {
966             // Avoid string-ifying bools so we can use an <eg-bool/>
967             // in the grid template.
968             return val;
969         }
970
971         let interval:any;
972         const intField = col.dateOnlyIntervalField;
973         if (intField) {
974             const intCol =
975                 this.columnSet.columns.filter(c => c.path === intField)[0];
976             if (intCol) {
977                 interval = this.getRowColumnBareValue(row, intCol);
978             }
979         }
980
981         return this.format.transform({
982             value: val,
983             idlClass: col.idlClass,
984             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
985             datatype: col.datatype,
986             datePlusTime: Boolean(col.datePlusTime),
987             timezoneContextOrg: Number(col.timezoneContextOrg),
988             dateOnlyInterval: interval
989         });
990     }
991
992     getObjectFieldValue(obj: any, name: string): any {
993         if (typeof obj[name] === 'function') {
994             return obj[name]();
995         } else {
996             return obj[name];
997         }
998     }
999
1000     nestedItemFieldValue(obj: any, col: GridColumn): string {
1001
1002         let idlField:any;
1003         let idlClassDef:any;
1004         const original = obj;
1005         const steps = col.path.split('.');
1006
1007         for (let i = 0; i < steps.length; i++) {
1008             const step = steps[i];
1009
1010             if (obj === null || obj === undefined || typeof obj !== 'object') {
1011                 // We have run out of data to step through before
1012                 // reaching the end of the path.  Conclude fleshing via
1013                 // callback if provided then exit.
1014                 if (col.flesher && obj !== undefined) {
1015                     return col.flesher(obj, col, original);
1016                 }
1017                 return obj;
1018             }
1019
1020             const class_ = obj.classname;
1021             if (class_ && (idlClassDef = this.idl.classes[class_])) {
1022                 idlField = idlClassDef.field_map[step];
1023             }
1024
1025             obj = this.getObjectFieldValue(obj, step);
1026         }
1027
1028         // We found a nested IDL object which may or may not have
1029         // been configured as a top-level column.  Flesh the column
1030         // metadata with our newly found IDL info.
1031         if (idlField) {
1032             if (!col.datatype) {
1033                 col.datatype = idlField.datatype;
1034             }
1035             if (!col.idlFieldDef) {
1036                 idlField = col.idlFieldDef;
1037             }
1038             if (!col.idlClass) {
1039                 col.idlClass = idlClassDef.name;
1040             }
1041             if (!col.label) {
1042                 col.label = idlField.label || idlField.name;
1043             }
1044         }
1045
1046         return obj;
1047     }
1048
1049
1050     getColumnTextContent(row: any, col: GridColumn): string {
1051         if (this.columnHasTextGenerator(col)) {
1052             const str = this.cellTextGenerator[col.name](row);
1053             return (str === null || str === undefined)  ? '' : str;
1054         } else {
1055             if (col.cellTemplate) {
1056                 return ''; // avoid 'undefined' values
1057             } else {
1058                 return this.getRowColumnValue(row, col);
1059             }
1060         }
1061     }
1062
1063     selectOneRow(index: any) {
1064         this.rowSelector.clear();
1065         this.rowSelector.select(index);
1066         this.lastSelectedIndex = index;
1067     }
1068
1069     selectMultipleRows(indexes: any[]) {
1070         this.rowSelector.clear();
1071         this.rowSelector.select(indexes);
1072         this.lastSelectedIndex = indexes[indexes.length - 1];
1073     }
1074
1075     // selects or deselects an item, without affecting the others.
1076     // returns true if the item is selected; false if de-selected.
1077     toggleSelectOneRow(index: any) {
1078         if (this.rowSelector.contains(index)) {
1079             this.rowSelector.deselect(index);
1080             return false;
1081         }
1082
1083         this.rowSelector.select(index);
1084         this.lastSelectedIndex = index;
1085         return true;
1086     }
1087
1088     selectRowByPos(pos: number) {
1089         const row = this.dataSource.data[pos];
1090         if (row) {
1091             this.selectOneRow(this.getRowIndex(row));
1092         }
1093     }
1094
1095     selectPreviousRow() {
1096         if (!this.lastSelectedIndex) { return; }
1097         const pos = this.getRowPosition(this.lastSelectedIndex);
1098         if (pos === this.pager.offset) {
1099             this.toPrevPage().then(() => this.selectLastRow(), err => { console.log('grid: in selectPreviousRow',err); });
1100         } else {
1101             this.selectRowByPos(pos - 1);
1102         }
1103     }
1104
1105     selectNextRow() {
1106         if (!this.lastSelectedIndex) { return; }
1107         const pos = this.getRowPosition(this.lastSelectedIndex);
1108         if (pos === (this.pager.offset + this.pager.limit - 1)) {
1109             this.toNextPage().then(() => this.selectFirstRow(), err => { console.log('grid: in selectNextRow',err); });
1110         } else {
1111             this.selectRowByPos(pos + 1);
1112         }
1113     }
1114
1115     // shift-up-arrow
1116     // Select the previous row in addition to any currently selected row.
1117     // However, if the previous row is already selected, assume the user
1118     // has reversed direction and now wants to de-select the last selected row.
1119     selectMultiRowsPrevious() {
1120         if (!this.lastSelectedIndex) { return; }
1121         const pos = this.getRowPosition(this.lastSelectedIndex);
1122         const selectedIndexes = this.rowSelector.selected();
1123
1124         const promise = // load the previous page of data if needed
1125             (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
1126
1127         promise.then(
1128             () => {
1129                 const row = this.dataSource.data[pos - 1];
1130                 const newIndex = this.getRowIndex(row);
1131                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1132                     // Prev row is already selected.  User is reversing direction.
1133                     this.rowSelector.deselect(this.lastSelectedIndex);
1134                     this.lastSelectedIndex = newIndex;
1135                 } else {
1136                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
1137                 }
1138             },
1139             err => { console.log('grid: inside selectMultiRowsPrevious',err); }
1140         );
1141     }
1142
1143     // Select all rows between the previously selected row and
1144     // the provided row, including the provided row.
1145     // This is additive only -- rows are never de-selected.
1146     selectRowRange(index: any) {
1147
1148         if (!this.lastSelectedIndex) {
1149             this.selectOneRow(index);
1150             return;
1151         }
1152
1153         const next = this.getRowPosition(index);
1154         const prev = this.getRowPosition(this.lastSelectedIndex);
1155         const start = Math.min(prev, next);
1156         const end = Math.max(prev, next);
1157
1158         for (let idx = start; idx <= end; idx++) {
1159             const row = this.dataSource.data[idx];
1160             if (row) {
1161                 this.rowSelector.select(this.getRowIndex(row));
1162             }
1163         }
1164
1165         this.lastSelectedIndex = index;
1166     }
1167
1168     // shift-down-arrow
1169     // Select the next row in addition to any currently selected row.
1170     // However, if the next row is already selected, assume the user
1171     // has reversed direction and wants to de-select the last selected row.
1172     selectMultiRowsNext() {
1173         if (!this.lastSelectedIndex) { return; }
1174         const pos = this.getRowPosition(this.lastSelectedIndex);
1175         const selectedIndexes = this.rowSelector.selected();
1176
1177         const promise = // load the next page of data if needed
1178             (pos === (this.pager.offset + this.pager.limit - 1)) ?
1179             this.toNextPage() : Promise.resolve();
1180
1181         promise.then(
1182             () => {
1183                 const row = this.dataSource.data[pos + 1];
1184                 const newIndex = this.getRowIndex(row);
1185                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1186                     // Next row is already selected.  User is reversing direction.
1187                     this.rowSelector.deselect(this.lastSelectedIndex);
1188                     this.lastSelectedIndex = newIndex;
1189                 } else {
1190                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
1191                 }
1192             },
1193             err => { console.log('grid: inside selectMultiRowsNext',err); }
1194         );
1195     }
1196
1197     getFirstRowInPage(): any {
1198         return this.dataSource.data[this.pager.offset];
1199     }
1200
1201     getLastRowInPage(): any {
1202         return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
1203     }
1204
1205     selectFirstRow() {
1206         this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
1207     }
1208
1209     selectLastRow() {
1210         this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
1211     }
1212
1213     selectRowsInPage() {
1214         const rows = this.dataSource.getPageOfRows(this.pager);
1215         const indexes = rows.map(r => this.getRowIndex(r));
1216         this.rowSelector.select(indexes);
1217         this.selectRowsInPageEmitter.emit();
1218     }
1219
1220     toPrevPage(): Promise<any> {
1221         if (this.pager.isFirstPage()) {
1222             return Promise.reject('on first');
1223         }
1224         // temp ignore pager events since we're calling requestPage manually.
1225         this.ignorePager();
1226         this.pager.decrement();
1227         this.listenToPager();
1228         return this.dataSource.requestPage(this.pager);
1229     }
1230
1231     toNextPage(): Promise<any> {
1232         if (this.pager.isLastPage()) {
1233             return Promise.reject('on last');
1234         }
1235         // temp ignore pager events since we're calling requestPage manually.
1236         this.ignorePager();
1237         this.pager.increment();
1238         this.listenToPager();
1239         return this.dataSource.requestPage(this.pager);
1240     }
1241
1242     getAllRows(): Promise<any> {
1243         const pager = new Pager();
1244         pager.offset = 0;
1245         pager.limit = MAX_ALL_ROW_COUNT;
1246         return this.dataSource.requestPage(pager);
1247     }
1248
1249     // Returns a key/value pair object of visible column data as text.
1250     getRowAsFlatText(row: any): any {
1251         const flatRow = {};
1252         this.columnSet.displayColumns().forEach(col => {
1253             flatRow[col.name] =
1254                 this.getColumnTextContent(row, col);
1255         });
1256         return flatRow;
1257     }
1258
1259         getAllRowsAsText(): Observable<any> {
1260                 return new Observable((observer: any) => {
1261                         this.getAllRows().then(() => {
1262                                 this.dataSource.data.forEach(row => {
1263                                         observer.next(this.getRowAsFlatText(row));
1264                                 });
1265                                 observer.complete();
1266                         });
1267                 });
1268         }
1269
1270     removeFilters(): void {
1271         this.dataSource.filters = {};
1272         this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
1273         this.filterControls.forEach(ctl => ctl.reset());
1274         this.reload();
1275     }
1276     saveFilters(asName: string): void {
1277         const obj = {
1278             'filters' : this.dataSource.filters, // filters isn't 100% reversible to column filter values, so...
1279             'controls' : Object.fromEntries(new Map( this.columnSet.columns.map( c => [c.name, c.getFilter()] ) ))
1280         }
1281         this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1282             console.log('grid: saveFilters, setting = ', setting);
1283             setting ||= {};
1284             setting[asName] = obj;
1285             console.log('grid: saving ' + asName, JSON.stringify(obj));
1286             this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1287                 console.log('grid: save toast here',res);
1288             });
1289         });
1290     }
1291     deleteFilters(withName: string): void {
1292         this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1293             if (setting) {
1294                 if (setting[withName]) {
1295                     setting[withName] = undefined;
1296                     delete setting[withName]; /* not releasing right away */
1297                 } else {
1298                     console.warn('Could not find ' + withName + ' in eg.grid.filters.' + this.persistKey,setting);
1299                 }
1300                 this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1301                     console.log('grid: delete toast here',res);
1302                 });
1303             } else {
1304                 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1305             }
1306         });
1307     }
1308     loadFilters(fromName: string): void {
1309         console.log('grid: fromName',fromName);
1310         this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1311             if (setting) {
1312                 const obj = setting[fromName];
1313                 if (obj) {
1314                     this.dataSource.filters = obj.filters;
1315                     Object.keys(obj.controls).forEach( col_name => {
1316                         let col = this.columnSet.columns.find(c => c.name === col_name);
1317                         if (col) {
1318                             col.loadFilter( obj.controls[col_name] );
1319                         }
1320                     });
1321                     this.reload();
1322                 } else {
1323                     console.warn('Could not find ' + fromName + ' in eg.grid.filters.' + this.persistKey, obj);
1324                 }
1325             } else {
1326                 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1327             }
1328         });
1329     }
1330     filtersSet(): boolean {
1331         return Object.keys(this.dataSource.filters).length > 0;
1332     }
1333
1334     gridToCsv(): Promise<string> {
1335
1336         let csvStr = '';
1337         const columns = this.columnSet.displayColumns();
1338
1339         // CSV header
1340         columns.forEach(col => {
1341             csvStr += this.valueToCsv(col.label),
1342             csvStr += ',';
1343         });
1344
1345         csvStr = csvStr.replace(/,$/, '\n');
1346
1347         return new Promise(resolve => {
1348             this.getAllRowsAsText().subscribe({
1349                 next: row => {
1350                     columns.forEach(col => {
1351                         csvStr += this.valueToCsv(row[col.name]);
1352                         csvStr += ',';
1353                     });
1354                     csvStr = csvStr.replace(/,$/, '\n');
1355                 },
1356                 error: err => { console.log('grid: in gridToCsv',err); },
1357                 complete: ()  => resolve(csvStr)
1358             });
1359         });
1360     }
1361
1362
1363     // prepares a string for inclusion within a CSV document
1364     // by escaping commas and quotes and removing newlines.
1365     valueToCsv(str: string): string {
1366         str = '' + str;
1367         if (!str) { return ''; }
1368         str = str.replace(/\n/g, '');
1369         if (str.match(/\,/) || str.match(/"/)) {
1370             str = str.replace(/"/g, '""');
1371             str = '"' + str + '"';
1372         }
1373         return str;
1374     }
1375
1376     generateColumns() {
1377         if (!this.columnSet.idlClass) { return; }
1378
1379         const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
1380         //const specifiedColumnOrder = this.autoGeneratedColumnOrder ?
1381         //    this.autoGeneratedColumnOrder.split(/,/) : [];
1382
1383         // generate columns for all non-virtual fields on the IDL class
1384         const fields = this.idl.classes[this.columnSet.idlClass].fields
1385             .filter((field:any) => !field.virtual);
1386
1387         const sortedFields = this.autoGeneratedColumnOrder ?
1388             this.idl.sortIdlFields(fields, this.autoGeneratedColumnOrder.split(/,/)) :
1389             fields;
1390
1391         sortedFields.forEach((field:any) => {
1392             if (!this.ignoredFields.filter(ignored => ignored === field.name).length) {
1393                 const col = new GridColumn();
1394                 col.name = field.name;
1395                 col.label = field.label || field.name;
1396                 col.idlFieldDef = field;
1397                 col.idlClass = this.columnSet.idlClass;
1398                 col.datatype = field.datatype;
1399                 col.isIndex = (field.name === pkeyField);
1400                 col.isAuto = true;
1401                 col.headerLabel = col.label;
1402
1403                 if (this.showDeclaredFieldsOnly) {
1404                     col.hidden = true;
1405                 }
1406
1407                 col.filterValue = this?.initialFilterValues?.[field.name];
1408
1409                 this.columnSet.add(col);
1410             }
1411         });
1412     }
1413
1414     saveGridConfig(): Promise<any> {
1415         if (!this.persistKey) {
1416             throw new Error('Grid persistKey required to save columns');
1417         }
1418         const conf = new GridPersistConf();
1419         conf.version = 2;
1420         conf.limit = this.pager.limit;
1421         conf.columns = this.columnSet.compileSaveObject();
1422
1423         // Avoid persisting group visibility since that may change
1424         // with the addition of new columns.  Always calculate that
1425         // in real time.
1426         conf.hideToolbarActions = this.toolbarActions
1427             .filter(action => !action.isGroup && action.hidden)
1428             .map(action => action.label);
1429
1430         return this.store.setItem('eg.grid.' + this.persistKey, conf);
1431     }
1432
1433     // TODO: saveGridConfigAsOrgSetting(...)
1434
1435     getGridConfig(persistKey: string): Promise<GridPersistConf> {
1436         if (!persistKey) { return Promise.resolve(null); }
1437         return this.store.getItem('eg.grid.' + persistKey);
1438     }
1439
1440     columnHasTextGenerator(col: GridColumn): boolean {
1441         return this.cellTextGenerator && col.name in this.cellTextGenerator;
1442     }
1443 }
1444
1445
1446 // Actions apply to specific rows
1447 export class GridToolbarAction {
1448     label: string;
1449     onClick: EventEmitter<any []>;
1450     action: (rows: any[]) => any; // DEPRECATED
1451     group: string;
1452     disabled: boolean;
1453     isGroup: boolean; // used for group placeholder entries
1454     isSeparator: boolean;
1455     disableOnRows: (rows: any[]) => boolean;
1456     hidden?: boolean;
1457 }
1458
1459 // Buttons are global actions
1460 export class GridToolbarButton {
1461     label: string;
1462     adjacentPreceedingLabel: string;
1463     adjacentSubsequentLabel: string;
1464     onClick: EventEmitter<any []>;
1465     action: () => any; // DEPRECATED
1466     disabled: boolean;
1467     routerLink: string;
1468 }
1469
1470 export class GridToolbarCheckbox {
1471     label: string;
1472     isChecked: boolean;
1473     onChange: EventEmitter<boolean>;
1474 }
1475
1476 export interface GridColumnSort {
1477     name: string;
1478     dir: string;
1479 }
1480
1481 export class GridDataSource {
1482
1483     data: any[];
1484     sort: GridColumnSort[];
1485     filters: Object;
1486     allRowsRetrieved: boolean;
1487     requestingData: boolean;
1488     retrievalError: boolean;
1489     getRows: (pager: Pager, sort: GridColumnSort[]) => Observable<any>;
1490
1491     constructor() {
1492         this.sort = [];
1493         this.filters = {};
1494         this.reset();
1495     }
1496
1497     reset() {
1498         this.data = [];
1499         this.allRowsRetrieved = false;
1500     }
1501
1502     // called from the template -- no data fetching
1503     getPageOfRows(pager: Pager): any[] {
1504         if (this.data) {
1505             return this.data.slice(
1506                 pager.offset, pager.limit + pager.offset
1507             ).filter(row => row !== undefined);
1508         }
1509         return [];
1510     }
1511
1512     // called on initial component load and user action (e.g. paging, sorting).
1513     requestPage(pager: Pager): Promise<any> {
1514
1515         if (
1516             this.getPageOfRows(pager).length === pager.limit
1517             // already have all data
1518             || this.allRowsRetrieved
1519             // have no way to get more data.
1520             || !this.getRows
1521         ) {
1522             return Promise.resolve();
1523         }
1524
1525         // If we have to call out for data, set inFetch
1526         this.requestingData = true;
1527         this.retrievalError = false;
1528
1529         return new Promise((resolve, reject) => {
1530             let idx = pager.offset;
1531             return this.getRows(pager, this.sort).subscribe({
1532                 next: row => {
1533                     this.data[idx++] = row;
1534                     // not updating this.requestingData, as having
1535                     // retrieved one row doesn't mean we're done
1536                     this.retrievalError = false;
1537                 },
1538                 error: err => {
1539                     console.error(`grid getRows() error ${err}`);
1540                     this.requestingData = false;
1541                     this.retrievalError = true;
1542                     reject(err);
1543                 },
1544                 complete: ()  => {
1545                     this.checkAllRetrieved(pager, idx);
1546                     this.requestingData = false;
1547                     this.retrievalError = false;
1548                     resolve(null);
1549                 }
1550             });
1551         });
1552     }
1553
1554     // See if the last getRows() call resulted in the final set of data.
1555     checkAllRetrieved(pager: Pager, idx: number) {
1556         if (this.allRowsRetrieved) { return; }
1557
1558         if (idx === 0 || idx < (pager.limit + pager.offset)) {
1559             // last query returned nothing or less than one page.
1560             // confirm we have all of the preceding pages.
1561             if (!this.data.includes(undefined)) {
1562                 this.allRowsRetrieved = true;
1563                 pager.resultCount = this.data.length;
1564             }
1565         }
1566     }
1567 }
1568