]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/grid/grid.ts
LP2061136 - Stamping 1405 DB upgrade script
[working/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
437         this.columns.forEach((col, idx) => {
438             if (col.name === source.name) { sourceIdx = idx; }
439         });
440
441         if (sourceIdx >= 0) {
442             this.columns.splice(sourceIdx, 1);
443         }
444
445         this.columns.splice(targetIdx, 0, source);
446     }
447
448     // Move visible columns to the front of the list.
449     moveVisibleToFront() {
450         const newCols = this.displayColumns();
451         this.columns.forEach(col => {
452             if (!col.visible) { newCols.push(col); }
453         });
454         this.columns = newCols;
455     }
456
457     moveColumn(col: GridColumn, diff: number) {
458         let srcIdx:number, targetIdx:number;
459
460         this.columns.forEach((c, i) => {
461             if (c.name === col.name) { srcIdx = i; }
462         });
463
464         targetIdx = srcIdx + diff;
465         if (targetIdx < 0) {
466             targetIdx = 0;
467         } else if (targetIdx >= this.columns.length) {
468             // Target index follows the last visible column.
469             let lastVisible = 0;
470             this.columns.forEach((c, idx) => {
471                 if (c.visible) { lastVisible = idx; }
472             });
473
474             // When moving a column (down) causes one or more
475             // visible columns to shuffle forward, our column
476             // moves into the slot of the last visible column.
477             // Otherwise, put it into the slot directly following
478             // the last visible column.
479             targetIdx = srcIdx <= lastVisible ? lastVisible : lastVisible + 1;
480         }
481
482         // Splice column out of old position, insert at new position.
483         this.columns.splice(srcIdx, 1);
484         this.columns.splice(targetIdx, 0, col);
485     }
486
487     compileSaveObject(): GridColumnPersistConf[] {
488         // only store information about visible columns.
489         // scrunch the data down to just the needed info.
490         return this.displayColumns().map(col => {
491             const c: GridColumnPersistConf = {name : col.name};
492             if (col.align !== 'left') { c.align = col.align; }
493             if (col.flex !== 2) { c.flex = Number(col.flex); }
494             if (Number(col.sort)) { c.sort = Number(col.sort); }
495             return c;
496         });
497     }
498
499     applyColumnSettings(conf: GridColumnPersistConf[]) {
500
501         if (!conf || conf.length === 0) {
502             // No configuration is available, but we have a list of
503             // fields to show or hide by default
504
505             if (this.defaultVisibleFields) {
506                 this.columns.forEach(col => {
507                     if (this.defaultVisibleFields.includes(col.name)) {
508                         col.visible = true;
509                     } else {
510                         col.visible = false;
511                     }
512                 });
513
514             } else if (this.defaultHiddenFields) {
515                 this.defaultHiddenFields.forEach(name => {
516                     const col = this.getColByName(name);
517                     if (col) {
518                         col.visible = false;
519                     }
520                 });
521             }
522
523             return;
524         }
525
526         const newCols = [];
527
528         conf.forEach(colConf => {
529             const col = this.getColByName(colConf.name);
530             if (!col) { return; } // no such column in this grid.
531
532             col.visible = true;
533             if (colConf.align) { col.align = colConf.align; }
534             if (colConf.flex)  { col.flex = Number(colConf.flex); }
535             if (colConf.sort)  { col.sort = Number(colConf.sort); }
536
537             // Add to new columns array, avoid dupes.
538             if (newCols.filter(c => c.name === col.name).length === 0) {
539                 newCols.push(col);
540             }
541         });
542
543         // columns which are not expressed within the saved
544         // configuration are marked as non-visible and
545         // appended to the end of the new list of columns.
546         this.columns.forEach(c => {
547             if (conf.filter(cf => cf.name === c.name).length === 0) {
548                 c.visible = false;
549                 newCols.push(c);
550             }
551         });
552
553         this.columns = newCols;
554     }
555 }
556
557 // Maps colunm names to functions which return plain text values for
558 // each mapped column on a given row.  This is primarily useful for
559 // generating print-friendly content for grid cells rendered via
560 // cellTemplate.
561 //
562 // USAGE NOTE: Since a cellTemplate can be passed arbitrary context
563 //             but a GridCellTextGenerator only gets the row object,
564 //             if it's important to include content that's not available
565 //             by default in the row object, you may want to stick
566 //             it in the row object as an additional attribute.
567 //
568 export interface GridCellTextGenerator {
569     [columnName: string]: (row: any) => string;
570 }
571
572 export class GridRowSelector {
573     indexes: {[string: string]: boolean};
574
575     // Track these so we can emit the selectionChange event
576     // only when the selection actually changes.
577     previousSelection: string[] = [];
578
579     // Emits the selected indexes on selection change
580     selectionChange: EventEmitter<string[]> = new EventEmitter<string[]>();
581
582     constructor() {
583         this.clear();
584     }
585
586     // Returns true if all of the requested indexes exist in the selector.
587     contains(index: string | string[]): boolean {
588         const indexes = [].concat(index);
589         for (let i = 0; i < indexes.length; i++) { // early exit
590             if (!this.indexes[indexes[i]]) {
591                 return false;
592             }
593         }
594         return true;
595     }
596
597     emitChange() {
598         const keys = this.selected();
599
600         if (keys.length === this.previousSelection.length &&
601             this.contains(this.previousSelection)) {
602             return; // No change has occurred
603         }
604
605         this.previousSelection = keys;
606         this.selectionChange.emit(keys);
607     }
608
609     select(index: string | string[]) {
610         const indexes = [].concat(index);
611         indexes.forEach(i => this.indexes[i] = true);
612         this.emitChange();
613     }
614
615     deselect(index: string | string[]) {
616         const indexes = [].concat(index);
617         indexes.forEach(i => delete this.indexes[i]);
618         this.emitChange();
619     }
620
621     toggle(index: string) {
622         if (this.indexes[index]) {
623             this.deselect(index);
624         } else {
625             this.select(index);
626         }
627     }
628
629     selected(): string[] {
630         return Object.keys(this.indexes);
631     }
632
633     isEmpty(): boolean {
634         return this.selected().length === 0;
635     }
636
637     clear() {
638         this.indexes = {};
639         this.emitChange();
640     }
641 }
642
643 export interface GridRowFlairEntry {
644     icon: string;   // name of material icon
645     title?: string;  // tooltip string
646 }
647
648 export class GridColumnPersistConf {
649     name: string;
650     flex?: number;
651     sort?: number;
652     align?: string;
653 }
654
655 export class GridPersistConf {
656     version: number;
657     limit: number;
658     columns: GridColumnPersistConf[];
659     hideToolbarActions: string[];
660 }
661
662 export class GridContext {
663
664     pager: Pager;
665     idlClass: string;
666     isSortable: boolean;
667     isFilterable: boolean;
668     initialFilterValues: {[field: string]: string};
669     allowNamedFilterSets: boolean;
670     migrateLegacyFilterSets: string;
671     stickyGridHeader: boolean;
672     isMultiSortable: boolean;
673     useLocalSort: boolean;
674     persistKey: string;
675     disableMultiSelect: boolean;
676     disableSelect: boolean;
677     dataSource: GridDataSource;
678     columnSet: GridColumnSet;
679     autoGeneratedColumnOrder: string;
680     rowSelector: GridRowSelector;
681     toolbarLabel: string;
682     toolbarButtons: GridToolbarButton[];
683     toolbarCheckboxes: GridToolbarCheckbox[];
684     toolbarActions: GridToolbarAction[];
685     lastSelectedIndex: any;
686     pageChanges: Subscription;
687     rowFlairIsEnabled: boolean;
688     rowFlairCallback: (row: any) => GridRowFlairEntry;
689     rowClassCallback: (row: any) => string;
690     cellClassCallback: (row: any, col: GridColumn) => string;
691     defaultVisibleFields: string[];
692     defaultHiddenFields: string[];
693     ignoredFields: string[];
694     overflowCells: boolean;
695     disablePaging: boolean;
696     showDeclaredFieldsOnly: boolean;
697     cellTextGenerator: GridCellTextGenerator;
698     reloadOnColumnChange: boolean;
699
700     // Allow calling code to know when the select-all-rows-in-page
701     // action has occurred.
702     selectRowsInPageEmitter: EventEmitter<void>;
703
704     filterControls: QueryList<GridFilterControlComponent>;
705
706     // Services injected by our grid component
707     idl: IdlService;
708     org: OrgService;
709     store: ServerStoreService;
710     format: FormatService;
711
712     constructor(
713         idl: IdlService,
714         org: OrgService,
715         store: ServerStoreService,
716         format: FormatService) {
717
718         this.idl = idl;
719         this.org = org;
720         this.store = store;
721         this.format = format;
722         this.pager = new Pager();
723         this.rowSelector = new GridRowSelector();
724         this.toolbarButtons = [];
725         this.toolbarCheckboxes = [];
726         this.toolbarActions = [];
727     }
728
729     init() {
730         this.selectRowsInPageEmitter = new EventEmitter<void>();
731         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
732         this.columnSet.isSortable = this.isSortable === true;
733         this.columnSet.isFilterable = this.isFilterable === true;
734         this.columnSet.isMultiSortable = this.isMultiSortable === true;
735         this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
736         this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
737         if (!this.pager.limit) {
738             this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
739         }
740         this.generateColumns();
741     }
742
743     // Load initial settings and data.
744     initData() {
745         this.applyGridConfig()
746             .then(() => this.dataSource.requestPage(this.pager))
747             .then(() => this.listenToPager());
748     }
749
750     destroy() {
751         this.ignorePager();
752     }
753
754     async applyGridConfig(): Promise<void> {
755         try {
756             const conf = await this.getGridConfig(this.persistKey);
757             let columns = [];
758             if (conf) {
759                 columns = conf.columns;
760                 if (conf.limit && !this.disablePaging) {
761                     this.pager.limit = conf.limit;
762                 }
763                 this.applyToolbarActionVisibility(conf.hideToolbarActions);
764             }
765
766             // This is called regardless of the presence of saved
767             // settings so defaults can be applied.
768             this.columnSet.applyColumnSettings(columns);
769         } catch (error) {
770             console.error('Error applying grid config:', error);
771         }
772     }
773
774
775     applyToolbarActionVisibility(hidden: string[]) {
776         if (!hidden || hidden.length === 0) { return; }
777
778         const groups = [];
779         this.toolbarActions.forEach(action => {
780             if (action.isGroup) {
781                 groups.push(action);
782             } else if (!action.isSeparator) {
783                 action.hidden = hidden.includes(action.label);
784             }
785         });
786
787         // If all actions in a group are hidden, hide the group as well.
788         // Note the group may be marked as hidden in the configuration,
789         // but the addition of new entries within a group should cause
790         // it to be visible again.
791         groups.forEach(group => {
792             const visible = this.toolbarActions
793                 .filter(action => action.group === group.label && !action.hidden);
794             group.hidden = visible.length === 0;
795         });
796     }
797
798     reload() {
799         // Give the UI time to settle before reloading grid data.
800         // This can help when data retrieval depends on a value
801         // getting modified by an angular digest cycle.
802         setTimeout(() => {
803             this.pager.reset();
804             this.dataSource.reset();
805             this.dataSource.requestPage(this.pager);
806         });
807     }
808
809     reloadWithoutPagerReset() {
810         setTimeout(() => {
811             this.dataSource.reset();
812             this.dataSource.requestPage(this.pager);
813         });
814     }
815
816     // Sort the existing data source instead of requesting sorted
817     // data from the client.  Reset pager to page 1.  As with reload(),
818     // give the client a chance to setting before redisplaying.
819     sortLocal() {
820         setTimeout(() => {
821             this.pager.reset();
822             this.sortLocalData();
823             this.dataSource.requestPage(this.pager);
824         });
825     }
826
827     // Subscribe or unsubscribe to page-change events from the pager.
828     listenToPager() {
829         if (this.pageChanges) { return; }
830         this.pageChanges = this.pager.onChange$.subscribe(
831             () => this.dataSource.requestPage(this.pager));
832     }
833
834     ignorePager() {
835         if (!this.pageChanges) { return; }
836         this.pageChanges.unsubscribe();
837         this.pageChanges = null;
838     }
839
840     // Sort data in the data source array
841     sortLocalData() {
842
843         const sortDefs = this.dataSource.sort.map(sort => {
844             const column = this.columnSet.getColByName(sort.name);
845
846             const def = {
847                 name: sort.name,
848                 dir: sort.dir,
849                 col: column
850             };
851
852             if (!def.col.comparator) {
853                 switch (def.col.datatype) {
854                     case 'id':
855                     case 'money':
856                     case 'int':
857                         def.col.comparator = (a, b) => {
858                             a = Number(a);
859                             b = Number(b);
860                             if (a < b) { return -1; }
861                             if (a > b) { return 1; }
862                             return 0;
863                         };
864                         break;
865                     default:
866                         def.col.comparator = (a, b) => {
867                             if (a < b) { return -1; }
868                             if (a > b) { return 1; }
869                             return 0;
870                         };
871                 }
872             }
873
874             return def;
875         });
876
877         this.dataSource.data.sort((rowA, rowB) => {
878
879             for (let idx = 0; idx < sortDefs.length; idx++) {
880                 const sortDef = sortDefs[idx];
881
882                 const valueA = this.getRowColumnValue(rowA, sortDef.col);
883                 const valueB = this.getRowColumnValue(rowB, sortDef.col);
884
885                 if (valueA === '' && valueB === '') { continue; }
886                 if (valueA === '' && valueB !== '') { return 1; }
887                 if (valueA !== '' && valueB === '') { return -1; }
888
889                 const diff = sortDef.col.comparator(valueA, valueB);
890                 if (diff === 0) { continue; }
891
892                 return sortDef.dir === 'DESC' ? -diff : diff;
893             }
894
895             return 0; // No differences found.
896         });
897     }
898
899     getRowIndex(row: any): any {
900         const col = this.columnSet.indexColumn;
901         if (!col) {
902             throw new Error('grid index column required');
903         }
904         return this.getRowColumnValue(row, col);
905     }
906
907     // Returns position in the data source array of the row with
908     // the provided index.
909     getRowPosition(index: any): number {
910         // for-loop for early exit
911         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
912             const row = this.dataSource.data[idx];
913             if (row !== undefined && index === this.getRowIndex(row)) {
914                 return idx;
915             }
916         }
917     }
918
919     // Return the row with the provided index.
920     getRowByIndex(index: any): any {
921         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
922             const row = this.dataSource.data[idx];
923             if (row !== undefined && index === this.getRowIndex(row)) {
924                 return row;
925             }
926         }
927     }
928
929     // Returns all selected rows, regardless of whether they are
930     // currently visible in the grid display.
931     // De-selects previously selected rows which are no longer
932     // present in the grid.
933     getSelectedRows(): any[] {
934         const selected = [];
935         const deleted = [];
936
937         this.rowSelector.selected().forEach(index => {
938             const row = this.getRowByIndex(index);
939             if (row) {
940                 selected.push(row);
941             } else {
942                 deleted.push(index);
943             }
944         });
945
946         this.rowSelector.deselect(deleted);
947         return selected;
948     }
949
950     rowIsSelected(row: any): boolean {
951         const index = this.getRowIndex(row);
952         return this.rowSelector.selected().filter(
953             idx => idx === index
954         ).length > 0;
955     }
956
957     getRowColumnBareValue(row: any, col: GridColumn): any {
958         if (col.name in row) {
959             return this.getObjectFieldValue(row, col.name);
960         } else if (col.path) {
961             return this.nestedItemFieldValue(row, col);
962         }
963     }
964
965     getRowColumnValue(row: any, col: GridColumn): any {
966         const val = this.getRowColumnBareValue(row, col);
967
968         if (col.datatype === 'bool') {
969             // Avoid string-ifying bools so we can use an <eg-bool/>
970             // in the grid template.
971             return val;
972         }
973
974         let interval:any;
975         const intField = col.dateOnlyIntervalField;
976         if (intField) {
977             const intCol =
978                 this.columnSet.columns.filter(c => c.path === intField)[0];
979             if (intCol) {
980                 interval = this.getRowColumnBareValue(row, intCol);
981             }
982         }
983
984         return this.format.transform({
985             value: val,
986             idlClass: col.idlClass,
987             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
988             datatype: col.datatype,
989             datePlusTime: Boolean(col.datePlusTime),
990             timezoneContextOrg: Number(col.timezoneContextOrg),
991             dateOnlyInterval: interval
992         });
993     }
994
995     getObjectFieldValue(obj: any, name: string): any {
996         if (typeof obj[name] === 'function') {
997             return obj[name]();
998         } else {
999             return obj[name];
1000         }
1001     }
1002
1003     nestedItemFieldValue(obj: any, col: GridColumn): string {
1004
1005         let idlField:any;
1006         let idlClassDef:any;
1007         const original = obj;
1008         const steps = col.path.split('.');
1009
1010         for (let i = 0; i < steps.length; i++) {
1011             const step = steps[i];
1012
1013             if (obj === null || obj === undefined || typeof obj !== 'object') {
1014                 // We have run out of data to step through before
1015                 // reaching the end of the path.  Conclude fleshing via
1016                 // callback if provided then exit.
1017                 if (col.flesher && obj !== undefined) {
1018                     return col.flesher(obj, col, original);
1019                 }
1020                 return obj;
1021             }
1022
1023             const class_ = obj.classname;
1024             if (class_ && (idlClassDef = this.idl.classes[class_])) {
1025                 idlField = idlClassDef.field_map[step];
1026             }
1027
1028             obj = this.getObjectFieldValue(obj, step);
1029         }
1030
1031         // We found a nested IDL object which may or may not have
1032         // been configured as a top-level column.  Flesh the column
1033         // metadata with our newly found IDL info.
1034         if (idlField) {
1035             if (!col.datatype) {
1036                 col.datatype = idlField.datatype;
1037             }
1038             if (!col.idlFieldDef) {
1039                 idlField = col.idlFieldDef;
1040             }
1041             if (!col.idlClass) {
1042                 col.idlClass = idlClassDef.name;
1043             }
1044             if (!col.label) {
1045                 col.label = idlField.label || idlField.name;
1046             }
1047         }
1048
1049         return obj;
1050     }
1051
1052
1053     getColumnTextContent(row: any, col: GridColumn): string {
1054         if (this.columnHasTextGenerator(col)) {
1055             const str = this.cellTextGenerator[col.name](row);
1056             return (str === null || str === undefined)  ? '' : str;
1057         } else {
1058             if (col.cellTemplate) {
1059                 return ''; // avoid 'undefined' values
1060             } else {
1061                 return this.getRowColumnValue(row, col);
1062             }
1063         }
1064     }
1065
1066     selectOneRow(index: any) {
1067         this.rowSelector.clear();
1068         this.rowSelector.select(index);
1069         this.lastSelectedIndex = index;
1070     }
1071
1072     selectMultipleRows(indexes: any[]) {
1073         this.rowSelector.clear();
1074         this.rowSelector.select(indexes);
1075         this.lastSelectedIndex = indexes[indexes.length - 1];
1076     }
1077
1078     // selects or deselects an item, without affecting the others.
1079     // returns true if the item is selected; false if de-selected.
1080     toggleSelectOneRow(index: any) {
1081         if (this.rowSelector.contains(index)) {
1082             this.rowSelector.deselect(index);
1083             return false;
1084         }
1085
1086         this.rowSelector.select(index);
1087         this.lastSelectedIndex = index;
1088         return true;
1089     }
1090
1091     selectRowByPos(pos: number) {
1092         const row = this.dataSource.data[pos];
1093         if (row) {
1094             this.selectOneRow(this.getRowIndex(row));
1095         }
1096     }
1097
1098     selectPreviousRow() {
1099         if (!this.lastSelectedIndex) { return; }
1100         const pos = this.getRowPosition(this.lastSelectedIndex);
1101         if (pos === this.pager.offset) {
1102             this.toPrevPage().then(() => this.selectLastRow(), err => { console.log('grid: in selectPreviousRow',err); });
1103         } else {
1104             this.selectRowByPos(pos - 1);
1105         }
1106     }
1107
1108     selectNextRow() {
1109         if (!this.lastSelectedIndex) { return; }
1110         const pos = this.getRowPosition(this.lastSelectedIndex);
1111         if (pos === (this.pager.offset + this.pager.limit - 1)) {
1112             this.toNextPage().then(() => this.selectFirstRow(), err => { console.log('grid: in selectNextRow',err); });
1113         } else {
1114             this.selectRowByPos(pos + 1);
1115         }
1116     }
1117
1118     // shift-up-arrow
1119     // Select the previous row in addition to any currently selected row.
1120     // However, if the previous row is already selected, assume the user
1121     // has reversed direction and now wants to de-select the last selected row.
1122     selectMultiRowsPrevious() {
1123         if (!this.lastSelectedIndex) { return; }
1124         const pos = this.getRowPosition(this.lastSelectedIndex);
1125         const selectedIndexes = this.rowSelector.selected();
1126
1127         const promise = // load the previous page of data if needed
1128             (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
1129
1130         promise.then(
1131             () => {
1132                 const row = this.dataSource.data[pos - 1];
1133                 const newIndex = this.getRowIndex(row);
1134                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1135                     // Prev row is already selected.  User is reversing direction.
1136                     this.rowSelector.deselect(this.lastSelectedIndex);
1137                     this.lastSelectedIndex = newIndex;
1138                 } else {
1139                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
1140                 }
1141             },
1142             err => { console.log('grid: inside selectMultiRowsPrevious',err); }
1143         );
1144     }
1145
1146     // Select all rows between the previously selected row and
1147     // the provided row, including the provided row.
1148     // This is additive only -- rows are never de-selected.
1149     selectRowRange(index: any) {
1150
1151         if (!this.lastSelectedIndex) {
1152             this.selectOneRow(index);
1153             return;
1154         }
1155
1156         const next = this.getRowPosition(index);
1157         const prev = this.getRowPosition(this.lastSelectedIndex);
1158         const start = Math.min(prev, next);
1159         const end = Math.max(prev, next);
1160
1161         for (let idx = start; idx <= end; idx++) {
1162             const row = this.dataSource.data[idx];
1163             if (row) {
1164                 this.rowSelector.select(this.getRowIndex(row));
1165             }
1166         }
1167
1168         this.lastSelectedIndex = index;
1169     }
1170
1171     // shift-down-arrow
1172     // Select the next row in addition to any currently selected row.
1173     // However, if the next row is already selected, assume the user
1174     // has reversed direction and wants to de-select the last selected row.
1175     selectMultiRowsNext() {
1176         if (!this.lastSelectedIndex) { return; }
1177         const pos = this.getRowPosition(this.lastSelectedIndex);
1178         const selectedIndexes = this.rowSelector.selected();
1179
1180         const promise = // load the next page of data if needed
1181             (pos === (this.pager.offset + this.pager.limit - 1)) ?
1182                 this.toNextPage() : Promise.resolve();
1183
1184         promise.then(
1185             () => {
1186                 const row = this.dataSource.data[pos + 1];
1187                 const newIndex = this.getRowIndex(row);
1188                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1189                     // Next row is already selected.  User is reversing direction.
1190                     this.rowSelector.deselect(this.lastSelectedIndex);
1191                     this.lastSelectedIndex = newIndex;
1192                 } else {
1193                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
1194                 }
1195             },
1196             err => { console.log('grid: inside selectMultiRowsNext',err); }
1197         );
1198     }
1199
1200     getFirstRowInPage(): any {
1201         return this.dataSource.data[this.pager.offset];
1202     }
1203
1204     getLastRowInPage(): any {
1205         return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
1206     }
1207
1208     selectFirstRow() {
1209         this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
1210     }
1211
1212     selectLastRow() {
1213         this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
1214     }
1215
1216     selectRowsInPage() {
1217         const rows = this.dataSource.getPageOfRows(this.pager);
1218         const indexes = rows.map(r => this.getRowIndex(r));
1219         this.rowSelector.select(indexes);
1220         this.selectRowsInPageEmitter.emit();
1221     }
1222
1223     toPrevPage(): Promise<any> {
1224         if (this.pager.isFirstPage()) {
1225             return Promise.reject('on first');
1226         }
1227         // temp ignore pager events since we're calling requestPage manually.
1228         this.ignorePager();
1229         this.pager.decrement();
1230         this.listenToPager();
1231         return this.dataSource.requestPage(this.pager);
1232     }
1233
1234     toNextPage(): Promise<any> {
1235         if (this.pager.isLastPage()) {
1236             return Promise.reject('on last');
1237         }
1238         // temp ignore pager events since we're calling requestPage manually.
1239         this.ignorePager();
1240         this.pager.increment();
1241         this.listenToPager();
1242         return this.dataSource.requestPage(this.pager);
1243     }
1244
1245     getAllRows(): Promise<any> {
1246         const pager = new Pager();
1247         pager.offset = 0;
1248         pager.limit = MAX_ALL_ROW_COUNT;
1249         return this.dataSource.requestPage(pager);
1250     }
1251
1252     // Returns a key/value pair object of visible column data as text.
1253     getRowAsFlatText(row: any): any {
1254         const flatRow = {};
1255         this.columnSet.displayColumns().forEach(col => {
1256             flatRow[col.name] =
1257                 this.getColumnTextContent(row, col);
1258         });
1259         return flatRow;
1260     }
1261
1262     getAllRowsAsText(): Observable<any> {
1263         return new Observable((observer: any) => {
1264             this.getAllRows().then(() => {
1265                 this.dataSource.data.forEach(row => {
1266                     observer.next(this.getRowAsFlatText(row));
1267                 });
1268                 observer.complete();
1269             });
1270         });
1271     }
1272
1273     removeFilters(): void {
1274         this.dataSource.filters = {};
1275         this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
1276         this.filterControls.forEach(ctl => ctl.reset());
1277         this.reload();
1278     }
1279     saveFilters(asName: string): void {
1280         const obj = {
1281             'filters' : this.dataSource.filters, // filters isn't 100% reversible to column filter values, so...
1282             'controls' : Object.fromEntries(new Map( this.columnSet.columns.map( c => [c.name, c.getFilter()] ) ))
1283         };
1284         this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1285             console.log('grid: saveFilters, setting = ', setting);
1286             setting ||= {};
1287             setting[asName] = obj;
1288             console.log('grid: saving ' + asName, JSON.stringify(obj));
1289             this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1290                 console.log('grid: save toast here',res);
1291             });
1292         });
1293     }
1294     deleteFilters(withName: string): void {
1295         this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1296             if (setting) {
1297                 if (setting[withName]) {
1298                     setting[withName] = undefined;
1299                     delete setting[withName]; /* not releasing right away */
1300                 } else {
1301                     console.warn('Could not find ' + withName + ' in eg.grid.filters.' + this.persistKey,setting);
1302                 }
1303                 this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1304                     console.log('grid: delete toast here',res);
1305                 });
1306             } else {
1307                 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1308             }
1309         });
1310     }
1311     loadFilters(fromName: string): void {
1312         console.log('grid: fromName',fromName);
1313         this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1314             if (setting) {
1315                 const obj = setting[fromName];
1316                 if (obj) {
1317                     this.dataSource.filters = obj.filters;
1318                     Object.keys(obj.controls).forEach( col_name => {
1319                         const col = this.columnSet.columns.find(c => c.name === col_name);
1320                         if (col) {
1321                             col.loadFilter( obj.controls[col_name] );
1322                         }
1323                     });
1324                     this.reload();
1325                 } else {
1326                     console.warn('Could not find ' + fromName + ' in eg.grid.filters.' + this.persistKey, obj);
1327                 }
1328             } else {
1329                 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1330             }
1331         });
1332     }
1333     filtersSet(): boolean {
1334         return Object.keys(this.dataSource.filters).length > 0;
1335     }
1336
1337     gridToCsv(): Promise<string> {
1338
1339         let csvStr = '';
1340         const columns = this.columnSet.displayColumns();
1341
1342         // CSV header
1343         columns.forEach(col => {
1344             // eslint-disable-next-line no-unused-expressions
1345             csvStr += this.valueToCsv(col.label),
1346             csvStr += ',';
1347         });
1348
1349         csvStr = csvStr.replace(/,$/, '\n');
1350
1351         return new Promise(resolve => {
1352             this.getAllRowsAsText().subscribe({
1353                 next: row => {
1354                     columns.forEach(col => {
1355                         csvStr += this.valueToCsv(row[col.name]);
1356                         csvStr += ',';
1357                     });
1358                     csvStr = csvStr.replace(/,$/, '\n');
1359                 },
1360                 error: (err: unknown) => { console.log('grid: in gridToCsv',err); },
1361                 complete: ()  => resolve(csvStr)
1362             });
1363         });
1364     }
1365
1366
1367     // prepares a string for inclusion within a CSV document
1368     // by escaping commas and quotes and removing newlines.
1369     valueToCsv(str: string): string {
1370         str = '' + str;
1371         if (!str) { return ''; }
1372         str = str.replace(/\n/g, '');
1373         if (str.match(/,/) || str.match(/"/)) {
1374             str = str.replace(/"/g, '""');
1375             str = '"' + str + '"';
1376         }
1377         return str;
1378     }
1379
1380     generateColumns() {
1381         if (!this.columnSet.idlClass) { return; }
1382
1383         const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
1384         // const specifiedColumnOrder = this.autoGeneratedColumnOrder ?
1385         //    this.autoGeneratedColumnOrder.split(/,/) : [];
1386
1387         // generate columns for all non-virtual fields on the IDL class
1388         const fields = this.idl.classes[this.columnSet.idlClass].fields
1389             .filter((field:any) => !field.virtual);
1390
1391         const sortedFields = this.autoGeneratedColumnOrder ?
1392             this.idl.sortIdlFields(fields, this.autoGeneratedColumnOrder.split(/,/)) :
1393             fields;
1394
1395         sortedFields.forEach((field:any) => {
1396             if (!this.ignoredFields.filter(ignored => ignored === field.name).length) {
1397                 const col = new GridColumn();
1398                 col.name = field.name;
1399                 col.label = field.label || field.name;
1400                 col.idlFieldDef = field;
1401                 col.idlClass = this.columnSet.idlClass;
1402                 col.datatype = field.datatype;
1403                 col.isIndex = (field.name === pkeyField);
1404                 col.isAuto = true;
1405                 col.headerLabel = col.label;
1406
1407                 if (this.showDeclaredFieldsOnly) {
1408                     col.hidden = true;
1409                 }
1410
1411                 col.filterValue = this?.initialFilterValues?.[field.name];
1412
1413                 this.columnSet.add(col);
1414             }
1415         });
1416     }
1417
1418     saveGridConfig(): Promise<any> {
1419         if (!this.persistKey) {
1420             throw new Error('Grid persistKey required to save columns');
1421         }
1422         const conf = new GridPersistConf();
1423         conf.version = 2;
1424         conf.limit = this.pager.limit;
1425         conf.columns = this.columnSet.compileSaveObject();
1426
1427         // Avoid persisting group visibility since that may change
1428         // with the addition of new columns.  Always calculate that
1429         // in real time.
1430         conf.hideToolbarActions = this.toolbarActions
1431             .filter(action => !action.isGroup && action.hidden)
1432             .map(action => action.label);
1433
1434         return this.store.setItem('eg.grid.' + this.persistKey, conf);
1435     }
1436
1437     // TODO: saveGridConfigAsOrgSetting(...)
1438
1439     getGridConfig(persistKey: string): Promise<GridPersistConf> {
1440         if (!persistKey) { return Promise.resolve(null); }
1441         return this.store.getItem('eg.grid.' + persistKey);
1442     }
1443
1444     columnHasTextGenerator(col: GridColumn): boolean {
1445         return this.cellTextGenerator && col.name in this.cellTextGenerator;
1446     }
1447 }
1448
1449
1450 // Actions apply to specific rows
1451 export class GridToolbarAction {
1452     label: string;
1453     onClick: EventEmitter<any []>;
1454     action: (rows: any[]) => any; // DEPRECATED
1455     group: string;
1456     disabled: boolean;
1457     isGroup: boolean; // used for group placeholder entries
1458     isSeparator: boolean;
1459     disableOnRows: (rows: any[]) => boolean;
1460     hidden?: boolean;
1461 }
1462
1463 // Buttons are global actions
1464 export class GridToolbarButton {
1465     label: string;
1466     adjacentPreceedingLabel: string;
1467     adjacentSubsequentLabel: string;
1468     onClick: EventEmitter<any []>;
1469     action: () => any; // DEPRECATED
1470     disabled: boolean;
1471     routerLink: string;
1472 }
1473
1474 export class GridToolbarCheckbox {
1475     label: string;
1476     isChecked: boolean;
1477     onChange: EventEmitter<boolean>;
1478 }
1479
1480 export interface GridColumnSort {
1481     name: string;
1482     dir: string;
1483 }
1484
1485 export class GridDataSource {
1486
1487     data: any[];
1488     sort: GridColumnSort[];
1489     filters: Object;
1490     allRowsRetrieved: boolean;
1491     requestingData: boolean;
1492     retrievalError: boolean;
1493     getRows: (pager: Pager, sort: GridColumnSort[]) => Observable<any>;
1494
1495     constructor() {
1496         this.sort = [];
1497         this.filters = {};
1498         this.reset();
1499     }
1500
1501     reset() {
1502         this.data = [];
1503         this.allRowsRetrieved = false;
1504     }
1505
1506     // called from the template -- no data fetching
1507     getPageOfRows(pager: Pager): any[] {
1508         if (this.data) {
1509             return this.data.slice(
1510                 pager.offset, pager.limit + pager.offset
1511             ).filter(row => row !== undefined);
1512         }
1513         return [];
1514     }
1515
1516     // called on initial component load and user action (e.g. paging, sorting).
1517     requestPage(pager: Pager): Promise<any> {
1518
1519         if (
1520             this.getPageOfRows(pager).length === pager.limit
1521             // already have all data
1522             || this.allRowsRetrieved
1523             // have no way to get more data.
1524             || !this.getRows
1525         ) {
1526             return Promise.resolve();
1527         }
1528
1529         // If we have to call out for data, set inFetch
1530         this.requestingData = true;
1531         this.retrievalError = false;
1532
1533         return new Promise((resolve, reject) => {
1534             let idx = pager.offset;
1535             return this.getRows(pager, this.sort).subscribe({
1536                 next: row => {
1537                     this.data[idx++] = row;
1538                     // not updating this.requestingData, as having
1539                     // retrieved one row doesn't mean we're done
1540                     this.retrievalError = false;
1541                 },
1542                 error: (err: unknown) => {
1543                     console.error(`grid getRows() error ${err}`);
1544                     this.requestingData = false;
1545                     this.retrievalError = true;
1546                     reject(err);
1547                 },
1548                 complete: ()  => {
1549                     this.checkAllRetrieved(pager, idx);
1550                     this.requestingData = false;
1551                     this.retrievalError = false;
1552                     resolve(null);
1553                 }
1554             });
1555         });
1556     }
1557
1558     // See if the last getRows() call resulted in the final set of data.
1559     checkAllRetrieved(pager: Pager, idx: number) {
1560         if (this.allRowsRetrieved) { return; }
1561
1562         if (idx === 0 || idx < (pager.limit + pager.offset)) {
1563             // last query returned nothing or less than one page.
1564             // confirm we have all of the preceding pages.
1565             if (!this.data.includes(undefined)) {
1566                 this.allRowsRetrieved = true;
1567                 pager.resultCount = this.data.length;
1568             }
1569         }
1570     }
1571 }
1572