]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/grid/grid.ts
LP#1831788: add result filtering and other improvements to the Angular eg-grid
[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     flex: number;
20     align: string;
21     hidden: boolean;
22     visible: boolean;
23     sort: number;
24     // IDL class of the object which contains this field.
25     // Not to be confused with the class of a linked object.
26     idlClass: string;
27     idlFieldDef: any;
28     datatype: string;
29     datePlusTime: boolean;
30     ternaryBool: boolean;
31     cellTemplate: TemplateRef<any>;
32     cellContext: any;
33     isIndex: boolean;
34     isDragTarget: boolean;
35     isSortable: boolean;
36     isFilterable: boolean;
37     isFiltered: boolean;
38     isMultiSortable: boolean;
39     disableTooltip: boolean;
40     comparator: (valueA: any, valueB: any) => number;
41
42     // True if the column was automatically generated.
43     isAuto: boolean;
44
45     // for filters
46     filterValue: string;
47     filterOperator: string;
48     filterInputDisabled: boolean;
49     filterIncludeOrgAncestors: boolean;
50     filterIncludeOrgDescendants: boolean;
51
52     flesher: (obj: any, col: GridColumn, item: any) => any;
53
54     getCellContext(row: any) {
55         return {
56           col: this,
57           row: row,
58           userContext: this.cellContext
59         };
60     }
61
62     constructor() {
63         this.removeFilter();
64     }
65
66     removeFilter() {
67         this.isFiltered = false;
68         this.filterValue = undefined;
69         this.filterOperator = '=';
70         this.filterInputDisabled = false;
71         this.filterIncludeOrgAncestors = false;
72         this.filterIncludeOrgDescendants = false;
73     }
74 }
75
76 export class GridColumnSet {
77     columns: GridColumn[];
78     idlClass: string;
79     indexColumn: GridColumn;
80     isSortable: boolean;
81     isFilterable: boolean;
82     isMultiSortable: boolean;
83     stockVisible: string[];
84     idl: IdlService;
85     defaultHiddenFields: string[];
86     defaultVisibleFields: string[];
87
88     constructor(idl: IdlService, idlClass?: string) {
89         this.idl = idl;
90         this.columns = [];
91         this.stockVisible = [];
92         this.idlClass = idlClass;
93     }
94
95     add(col: GridColumn) {
96
97         this.applyColumnDefaults(col);
98
99         if (!this.insertColumn(col)) {
100             // Column was rejected as a duplicate.
101             return;
102         }
103
104         if (col.isIndex) { this.indexColumn = col; }
105
106         // track which fields are visible on page load.
107         if (col.visible) {
108             this.stockVisible.push(col.name);
109         }
110
111         this.applyColumnSortability(col);
112         this.applyColumnFilterability(col);
113     }
114
115     // Returns true if the new column was inserted, false otherwise.
116     // Declared columns take precedence over auto-generated columns
117     // when collisions occur.
118     // Declared columns are inserted in front of auto columns.
119     insertColumn(col: GridColumn): boolean {
120
121         if (col.isAuto) {
122             if (this.getColByName(col.name)) {
123                 // New auto-generated column conflicts with existing
124                 // column.  Skip it.
125                 return false;
126             } else {
127                 // No collisions.  Add to the end of the list
128                 this.columns.push(col);
129                 return true;
130             }
131         }
132
133         // Adding a declared column.
134
135         // Check for dupes.
136         for (let idx = 0; idx < this.columns.length; idx++) {
137             const testCol = this.columns[idx];
138             if (testCol.name === col.name) { // match found
139                 if (testCol.isAuto) {
140                     // new column takes precedence, remove the existing column.
141                     this.columns.splice(idx, 1);
142                     break;
143                 } else {
144                     // New column does not take precedence.  Avoid
145                     // inserting it.
146                     return false;
147                 }
148             }
149         }
150
151         // Delcared columns are inserted just before the first auto-column
152         for (let idx = 0; idx < this.columns.length; idx++) {
153             const testCol = this.columns[idx];
154             if (testCol.isAuto) {
155                 if (idx === 0) {
156                     this.columns.unshift(col);
157                 } else {
158                     this.columns.splice(idx, 0, col);
159                 }
160                 return true;
161             }
162         }
163
164         // No insertion point found.  Toss the new column on the end.
165         this.columns.push(col);
166         return true;
167     }
168
169     getColByName(name: string): GridColumn {
170         return this.columns.filter(c => c.name === name)[0];
171     }
172
173     idlInfoFromDotpath(dotpath: string): any {
174         if (!dotpath || !this.idlClass) { return null; }
175
176         let idlParent;
177         let idlField;
178         let idlClass = this.idl.classes[this.idlClass];
179
180         const pathParts = dotpath.split(/\./);
181
182         for (let i = 0; i < pathParts.length; i++) {
183             const part = pathParts[i];
184             idlParent = idlField;
185             idlField = idlClass.field_map[part];
186
187             if (idlField) {
188                 if (idlField['class'] && (
189                     idlField.datatype === 'link' ||
190                     idlField.datatype === 'org_unit')) {
191                     idlClass = this.idl.classes[idlField['class']];
192                 }
193             } else {
194                 return null;
195             }
196         }
197
198         return {
199             idlParent: idlParent,
200             idlField : idlField,
201             idlClass : idlClass
202         };
203     }
204
205
206     reset() {
207         this.columns.forEach(col => {
208             col.flex = 2;
209             col.sort = 0;
210             col.align = 'left';
211             col.visible = this.stockVisible.includes(col.name);
212         });
213     }
214
215     applyColumnDefaults(col: GridColumn) {
216
217         if (!col.idlFieldDef && col.path) {
218             const idlInfo = this.idlInfoFromDotpath(col.path);
219             if (idlInfo) {
220                 col.idlFieldDef = idlInfo.idlField;
221                 col.idlClass = idlInfo.idlClass.name;
222                 if (!col.label) {
223                     col.label = col.idlFieldDef.label || col.idlFieldDef.name;
224                     col.datatype = col.idlFieldDef.datatype;
225                 }
226             }
227         }
228
229         if (!col.name) { col.name = col.path; }
230         if (!col.flex) { col.flex = 2; }
231         if (!col.align) { col.align = 'left'; }
232         if (!col.label) { col.label = col.name; }
233         if (!col.datatype) { col.datatype = 'text'; }
234
235         col.visible = !col.hidden;
236     }
237
238     applyColumnSortability(col: GridColumn) {
239         // column sortability defaults to the sortability of the column set.
240         if (col.isSortable === undefined && this.isSortable) {
241             col.isSortable = true;
242         }
243
244         if (col.isMultiSortable === undefined && this.isMultiSortable) {
245             col.isMultiSortable = true;
246         }
247
248         if (col.isMultiSortable) {
249             col.isSortable = true;
250         }
251     }
252     applyColumnFilterability(col: GridColumn) {
253         // column filterability defaults to the afilterability of the column set.
254         if (col.isFilterable === undefined && this.isFilterable) {
255             col.isFilterable = true;
256         }
257     }
258
259     displayColumns(): GridColumn[] {
260         return this.columns.filter(c => c.visible);
261     }
262
263     insertBefore(source: GridColumn, target: GridColumn) {
264         let targetIdx = -1;
265         let sourceIdx = -1;
266         this.columns.forEach((col, idx) => {
267             if (col.name === target.name) { targetIdx = idx; }});
268
269         this.columns.forEach((col, idx) => {
270             if (col.name === source.name) { sourceIdx = idx; }});
271
272         if (sourceIdx >= 0) {
273             this.columns.splice(sourceIdx, 1);
274         }
275
276         this.columns.splice(targetIdx, 0, source);
277     }
278
279     // Move visible columns to the front of the list.
280     moveVisibleToFront() {
281         const newCols = this.displayColumns();
282         this.columns.forEach(col => {
283             if (!col.visible) { newCols.push(col); }});
284         this.columns = newCols;
285     }
286
287     moveColumn(col: GridColumn, diff: number) {
288         let srcIdx, targetIdx;
289
290         this.columns.forEach((c, i) => {
291           if (c.name === col.name) { srcIdx = i; }
292         });
293
294         targetIdx = srcIdx + diff;
295         if (targetIdx < 0) {
296             targetIdx = 0;
297         } else if (targetIdx >= this.columns.length) {
298             // Target index follows the last visible column.
299             let lastVisible = 0;
300             this.columns.forEach((c, idx) => {
301                 if (c.visible) { lastVisible = idx; }
302             });
303
304             // When moving a column (down) causes one or more
305             // visible columns to shuffle forward, our column
306             // moves into the slot of the last visible column.
307             // Otherwise, put it into the slot directly following
308             // the last visible column.
309             targetIdx = srcIdx <= lastVisible ? lastVisible : lastVisible + 1;
310         }
311
312         // Splice column out of old position, insert at new position.
313         this.columns.splice(srcIdx, 1);
314         this.columns.splice(targetIdx, 0, col);
315     }
316
317     compileSaveObject(): GridColumnPersistConf[] {
318         // only store information about visible columns.
319         // scrunch the data down to just the needed info.
320         return this.displayColumns().map(col => {
321             const c: GridColumnPersistConf = {name : col.name};
322             if (col.align !== 'left') { c.align = col.align; }
323             if (col.flex !== 2) { c.flex = Number(col.flex); }
324             if (Number(col.sort)) { c.sort = Number(c.sort); }
325             return c;
326         });
327     }
328
329     applyColumnSettings(conf: GridColumnPersistConf[]) {
330
331         if (!conf || conf.length === 0) {
332             // No configuration is available, but we have a list of
333             // fields to show or hide by default
334
335             if (this.defaultVisibleFields) {
336                 this.columns.forEach(col => {
337                     if (this.defaultVisibleFields.includes(col.name)) {
338                         col.visible = true;
339                     } else {
340                         col.visible = false;
341                     }
342                 });
343
344             } else if (this.defaultHiddenFields) {
345                 this.defaultHiddenFields.forEach(name => {
346                     const col = this.getColByName(name);
347                     if (col) {
348                         col.visible = false;
349                     }
350                 });
351             }
352
353             return;
354         }
355
356         const newCols = [];
357
358         conf.forEach(colConf => {
359             const col = this.getColByName(colConf.name);
360             if (!col) { return; } // no such column in this grid.
361
362             col.visible = true;
363             if (colConf.align) { col.align = colConf.align; }
364             if (colConf.flex)  { col.flex = Number(colConf.flex); }
365             if (colConf.sort)  { col.sort = Number(colConf.sort); }
366
367             // Add to new columns array, avoid dupes.
368             if (newCols.filter(c => c.name === col.name).length === 0) {
369                 newCols.push(col);
370             }
371         });
372
373         // columns which are not expressed within the saved
374         // configuration are marked as non-visible and
375         // appended to the end of the new list of columns.
376         this.columns.forEach(c => {
377             if (conf.filter(cf => cf.name === c.name).length === 0) {
378                 c.visible = false;
379                 newCols.push(c);
380             }
381         });
382
383         this.columns = newCols;
384     }
385 }
386
387
388 export class GridRowSelector {
389     indexes: {[string: string]: boolean};
390
391     constructor() {
392         this.clear();
393     }
394
395     // Returns true if all of the requested indexes exist in the selector.
396     contains(index: string | string[]): boolean {
397         const indexes = [].concat(index);
398         for (let i = 0; i < indexes.length; i++) { // early exit
399             if (!this.indexes[indexes[i]]) {
400                 return false;
401             }
402         }
403         return true;
404     }
405
406     select(index: string | string[]) {
407         const indexes = [].concat(index);
408         indexes.forEach(i => this.indexes[i] = true);
409     }
410
411     deselect(index: string | string[]) {
412         const indexes = [].concat(index);
413         indexes.forEach(i => delete this.indexes[i]);
414     }
415
416     // Returns the list of selected index values.
417     // in some contexts (template checkboxes) the value for an index is
418     // set to false to deselect instead of having it removed (via deselect()).
419     selected() {
420         return Object.keys(this.indexes).filter(
421             ind => Boolean(this.indexes[ind]));
422     }
423
424     isEmpty(): boolean {
425         return this.selected().length === 0;
426     }
427
428     clear() {
429         this.indexes = {};
430     }
431 }
432
433 export interface GridRowFlairEntry {
434     icon: string;   // name of material icon
435     title?: string;  // tooltip string
436 }
437
438 export class GridColumnPersistConf {
439     name: string;
440     flex?: number;
441     sort?: number;
442     align?: string;
443 }
444
445 export class GridPersistConf {
446     version: number;
447     limit: number;
448     columns: GridColumnPersistConf[];
449 }
450
451 export class GridContext {
452
453     pager: Pager;
454     idlClass: string;
455     isSortable: boolean;
456     isFilterable: boolean;
457     stickyGridHeader: boolean;
458     isMultiSortable: boolean;
459     useLocalSort: boolean;
460     persistKey: string;
461     disableMultiSelect: boolean;
462     disableSelect: boolean;
463     dataSource: GridDataSource;
464     columnSet: GridColumnSet;
465     rowSelector: GridRowSelector;
466     toolbarButtons: GridToolbarButton[];
467     toolbarCheckboxes: GridToolbarCheckbox[];
468     toolbarActions: GridToolbarAction[];
469     lastSelectedIndex: any;
470     pageChanges: Subscription;
471     rowFlairIsEnabled: boolean;
472     rowFlairCallback: (row: any) => GridRowFlairEntry;
473     rowClassCallback: (row: any) => string;
474     cellClassCallback: (row: any, col: GridColumn) => string;
475     defaultVisibleFields: string[];
476     defaultHiddenFields: string[];
477     overflowCells: boolean;
478     showLinkSelectors: boolean;
479     disablePaging: boolean;
480     showDeclaredFieldsOnly: boolean;
481
482     // Allow calling code to know when the select-all-rows-in-page
483     // action has occurred.
484     selectRowsInPageEmitter: EventEmitter<void>;
485
486     filterControls: QueryList<GridFilterControlComponent>;
487
488     // Services injected by our grid component
489     idl: IdlService;
490     org: OrgService;
491     store: ServerStoreService;
492     format: FormatService;
493
494     constructor(
495         idl: IdlService,
496         org: OrgService,
497         store: ServerStoreService,
498         format: FormatService) {
499
500         this.idl = idl;
501         this.org = org;
502         this.store = store;
503         this.format = format;
504         this.pager = new Pager();
505         this.rowSelector = new GridRowSelector();
506         this.toolbarButtons = [];
507         this.toolbarCheckboxes = [];
508         this.toolbarActions = [];
509     }
510
511     init() {
512         this.selectRowsInPageEmitter = new EventEmitter<void>();
513         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
514         this.columnSet.isSortable = this.isSortable === true;
515         this.columnSet.isFilterable = this.isFilterable === true;
516         this.columnSet.isMultiSortable = this.isMultiSortable === true;
517         this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
518         this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
519         if (!this.pager.limit) {
520             this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
521         }
522         this.generateColumns();
523     }
524
525     // Load initial settings and data.
526     initData() {
527         this.applyGridConfig()
528         .then(ok => this.dataSource.requestPage(this.pager))
529         .then(ok => this.listenToPager());
530     }
531
532     destroy() {
533         this.ignorePager();
534     }
535
536     applyGridConfig(): Promise<void> {
537         return this.getGridConfig(this.persistKey)
538         .then(conf => {
539             let columns = [];
540             if (conf) {
541                 columns = conf.columns;
542                 if (conf.limit) {
543                     this.pager.limit = conf.limit;
544                 }
545             }
546
547             // This is called regardless of the presence of saved
548             // settings so defaults can be applied.
549             this.columnSet.applyColumnSettings(columns);
550         });
551     }
552
553     reload() {
554         // Give the UI time to settle before reloading grid data.
555         // This can help when data retrieval depends on a value
556         // getting modified by an angular digest cycle.
557         setTimeout(() => {
558             this.pager.reset();
559             this.dataSource.reset();
560             this.dataSource.requestPage(this.pager);
561         });
562     }
563
564     reloadSansPagerReset() {
565         setTimeout(() => {
566             this.dataSource.reset();
567             this.dataSource.requestPage(this.pager);
568         });
569     }
570
571     // Sort the existing data source instead of requesting sorted
572     // data from the client.  Reset pager to page 1.  As with reload(),
573     // give the client a chance to setting before redisplaying.
574     sortLocal() {
575         setTimeout(() => {
576             this.pager.reset();
577             this.sortLocalData();
578             this.dataSource.requestPage(this.pager);
579         });
580     }
581
582     // Subscribe or unsubscribe to page-change events from the pager.
583     listenToPager() {
584         if (this.pageChanges) { return; }
585         this.pageChanges = this.pager.onChange$.subscribe(
586             val => this.dataSource.requestPage(this.pager));
587     }
588
589     ignorePager() {
590         if (!this.pageChanges) { return; }
591         this.pageChanges.unsubscribe();
592         this.pageChanges = null;
593     }
594
595     // Sort data in the data source array
596     sortLocalData() {
597
598         const sortDefs = this.dataSource.sort.map(sort => {
599             const column = this.columnSet.getColByName(sort.name);
600
601             const def = {
602                 name: sort.name,
603                 dir: sort.dir,
604                 col: column
605             };
606
607             if (!def.col.comparator) {
608                 switch (def.col.datatype) {
609                     case 'id':
610                     case 'money':
611                     case 'int':
612                         def.col.comparator = (a, b) => {
613                             a = Number(a);
614                             b = Number(b);
615                             if (a < b) { return -1; }
616                             if (a > b) { return 1; }
617                             return 0;
618                         };
619                         break;
620                     default:
621                         def.col.comparator = (a, b) => {
622                             if (a < b) { return -1; }
623                             if (a > b) { return 1; }
624                             return 0;
625                         };
626                 }
627             }
628
629             return def;
630         });
631
632         this.dataSource.data.sort((rowA, rowB) => {
633
634             for (let idx = 0; idx < sortDefs.length; idx++) {
635                 const sortDef = sortDefs[idx];
636
637                 const valueA = this.getRowColumnValue(rowA, sortDef.col);
638                 const valueB = this.getRowColumnValue(rowB, sortDef.col);
639
640                 if (valueA === '' && valueB === '') { continue; }
641                 if (valueA === '' && valueB !== '') { return 1; }
642                 if (valueA !== '' && valueB === '') { return -1; }
643
644                 const diff = sortDef.col.comparator(valueA, valueB);
645                 if (diff === 0) { continue; }
646
647                 return sortDef.dir === 'DESC' ? -diff : diff;
648             }
649
650             return 0; // No differences found.
651         });
652     }
653
654     getRowIndex(row: any): any {
655         const col = this.columnSet.indexColumn;
656         if (!col) {
657             throw new Error('grid index column required');
658         }
659         return this.getRowColumnValue(row, col);
660     }
661
662     // Returns position in the data source array of the row with
663     // the provided index.
664     getRowPosition(index: any): number {
665         // for-loop for early exit
666         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
667             const row = this.dataSource.data[idx];
668             if (row !== undefined && index === this.getRowIndex(row)) {
669                 return idx;
670             }
671         }
672     }
673
674     // Return the row with the provided index.
675     getRowByIndex(index: any): any {
676         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
677             const row = this.dataSource.data[idx];
678             if (row !== undefined && index === this.getRowIndex(row)) {
679                 return row;
680             }
681         }
682     }
683
684     // Returns all selected rows, regardless of whether they are
685     // currently visible in the grid display.
686     getSelectedRows(): any[] {
687         const selected = [];
688         this.rowSelector.selected().forEach(index => {
689             const row = this.getRowByIndex(index);
690             if (row) {
691                 selected.push(row);
692             }
693         });
694         return selected;
695     }
696
697     getRowColumnValue(row: any, col: GridColumn): string {
698         let val;
699
700         if (col.path) {
701             val = this.nestedItemFieldValue(row, col);
702         } else if (col.name in row) {
703             val = this.getObjectFieldValue(row, col.name);
704         }
705
706         if (col.datatype === 'bool') {
707             // Avoid string-ifying bools so we can use an <eg-bool/>
708             // in the grid template.
709             return val;
710         }
711
712         return this.format.transform({
713             value: val,
714             idlClass: col.idlClass,
715             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
716             datatype: col.datatype,
717             datePlusTime: Boolean(col.datePlusTime)
718         });
719     }
720
721     getObjectFieldValue(obj: any, name: string): any {
722         if (typeof obj[name] === 'function') {
723             return obj[name]();
724         } else {
725             return obj[name];
726         }
727     }
728
729     nestedItemFieldValue(obj: any, col: GridColumn): string {
730
731         let idlField;
732         let idlClassDef;
733         const original = obj;
734         const steps = col.path.split('.');
735
736         for (let i = 0; i < steps.length; i++) {
737             const step = steps[i];
738
739             if (obj === null || obj === undefined || typeof obj !== 'object') {
740                 // We have run out of data to step through before
741                 // reaching the end of the path.  Conclude fleshing via
742                 // callback if provided then exit.
743                 if (col.flesher && obj !== undefined) {
744                     return col.flesher(obj, col, original);
745                 }
746                 return obj;
747             }
748
749             const class_ = obj.classname;
750             if (class_ && (idlClassDef = this.idl.classes[class_])) {
751                 idlField = idlClassDef.field_map[step];
752             }
753
754             obj = this.getObjectFieldValue(obj, step);
755         }
756
757         // We found a nested IDL object which may or may not have
758         // been configured as a top-level column.  Flesh the column
759         // metadata with our newly found IDL info.
760         if (idlField) {
761             if (!col.datatype) {
762                 col.datatype = idlField.datatype;
763             }
764             if (!col.idlFieldDef) {
765                 idlField = col.idlFieldDef;
766             }
767             if (!col.idlClass) {
768                 col.idlClass = idlClassDef.name;
769             }
770             if (!col.label) {
771                 col.label = idlField.label || idlField.name;
772             }
773         }
774
775         return obj;
776     }
777
778
779     getColumnTextContent(row: any, col: GridColumn): string {
780         if (col.cellTemplate) {
781             // TODO
782             // Extract the text content from the rendered template.
783         } else {
784             return this.getRowColumnValue(row, col);
785         }
786     }
787
788     selectOneRow(index: any) {
789         this.rowSelector.clear();
790         this.rowSelector.select(index);
791         this.lastSelectedIndex = index;
792     }
793
794     selectMultipleRows(indexes: any[]) {
795         this.rowSelector.clear();
796         this.rowSelector.select(indexes);
797         this.lastSelectedIndex = indexes[indexes.length - 1];
798     }
799
800     // selects or deselects an item, without affecting the others.
801     // returns true if the item is selected; false if de-selected.
802     toggleSelectOneRow(index: any) {
803         if (this.rowSelector.contains(index)) {
804             this.rowSelector.deselect(index);
805             return false;
806         }
807
808         this.rowSelector.select(index);
809         return true;
810     }
811
812     selectRowByPos(pos: number) {
813         const row = this.dataSource.data[pos];
814         if (row) {
815             this.selectOneRow(this.getRowIndex(row));
816         }
817     }
818
819     selectPreviousRow() {
820         if (!this.lastSelectedIndex) { return; }
821         const pos = this.getRowPosition(this.lastSelectedIndex);
822         if (pos === this.pager.offset) {
823             this.toPrevPage().then(ok => this.selectLastRow(), err => {});
824         } else {
825             this.selectRowByPos(pos - 1);
826         }
827     }
828
829     selectNextRow() {
830         if (!this.lastSelectedIndex) { return; }
831         const pos = this.getRowPosition(this.lastSelectedIndex);
832         if (pos === (this.pager.offset + this.pager.limit - 1)) {
833             this.toNextPage().then(ok => this.selectFirstRow(), err => {});
834         } else {
835             this.selectRowByPos(pos + 1);
836         }
837     }
838
839     // shift-up-arrow
840     // Select the previous row in addition to any currently selected row.
841     // However, if the previous row is already selected, assume the user
842     // has reversed direction and now wants to de-select the last selected row.
843     selectMultiRowsPrevious() {
844         if (!this.lastSelectedIndex) { return; }
845         const pos = this.getRowPosition(this.lastSelectedIndex);
846         const selectedIndexes = this.rowSelector.selected();
847
848         const promise = // load the previous page of data if needed
849             (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
850
851         promise.then(
852             ok => {
853                 const row = this.dataSource.data[pos - 1];
854                 const newIndex = this.getRowIndex(row);
855                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
856                     // Prev row is already selected.  User is reversing direction.
857                     this.rowSelector.deselect(this.lastSelectedIndex);
858                     this.lastSelectedIndex = newIndex;
859                 } else {
860                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
861                 }
862             },
863             err => {}
864         );
865     }
866
867     // shift-down-arrow
868     // Select the next row in addition to any currently selected row.
869     // However, if the next row is already selected, assume the user
870     // has reversed direction and wants to de-select the last selected row.
871     selectMultiRowsNext() {
872         if (!this.lastSelectedIndex) { return; }
873         const pos = this.getRowPosition(this.lastSelectedIndex);
874         const selectedIndexes = this.rowSelector.selected();
875
876         const promise = // load the next page of data if needed
877             (pos === (this.pager.offset + this.pager.limit - 1)) ?
878             this.toNextPage() : Promise.resolve();
879
880         promise.then(
881             ok => {
882                 const row = this.dataSource.data[pos + 1];
883                 const newIndex = this.getRowIndex(row);
884                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
885                     // Next row is already selected.  User is reversing direction.
886                     this.rowSelector.deselect(this.lastSelectedIndex);
887                     this.lastSelectedIndex = newIndex;
888                 } else {
889                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
890                 }
891             },
892             err => {}
893         );
894     }
895
896     getFirstRowInPage(): any {
897         return this.dataSource.data[this.pager.offset];
898     }
899
900     getLastRowInPage(): any {
901         return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
902     }
903
904     selectFirstRow() {
905         this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
906     }
907
908     selectLastRow() {
909         this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
910     }
911
912     selectRowsInPage() {
913         const rows = this.dataSource.getPageOfRows(this.pager);
914         const indexes = rows.map(r => this.getRowIndex(r));
915         this.rowSelector.select(indexes);
916         this.selectRowsInPageEmitter.emit();
917     }
918
919     toPrevPage(): Promise<any> {
920         if (this.pager.isFirstPage()) {
921             return Promise.reject('on first');
922         }
923         // temp ignore pager events since we're calling requestPage manually.
924         this.ignorePager();
925         this.pager.decrement();
926         this.listenToPager();
927         return this.dataSource.requestPage(this.pager);
928     }
929
930     toNextPage(): Promise<any> {
931         if (this.pager.isLastPage()) {
932             return Promise.reject('on last');
933         }
934         // temp ignore pager events since we're calling requestPage manually.
935         this.ignorePager();
936         this.pager.increment();
937         this.listenToPager();
938         return this.dataSource.requestPage(this.pager);
939     }
940
941     getAllRows(): Promise<any> {
942         const pager = new Pager();
943         pager.offset = 0;
944         pager.limit = MAX_ALL_ROW_COUNT;
945         return this.dataSource.requestPage(pager);
946     }
947
948     // Returns a key/value pair object of visible column data as text.
949     getRowAsFlatText(row: any): any {
950         const flatRow = {};
951         this.columnSet.displayColumns().forEach(col => {
952             flatRow[col.name] =
953                 this.getColumnTextContent(row, col);
954         });
955         return flatRow;
956     }
957
958     getAllRowsAsText(): Observable<any> {
959         return Observable.create(observer => {
960             this.getAllRows().then(ok => {
961                 this.dataSource.data.forEach(row => {
962                     observer.next(this.getRowAsFlatText(row));
963                 });
964                 observer.complete();
965             });
966         });
967     }
968
969     removeFilters(): void {
970         this.dataSource.filters = {};
971         this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
972         this.filterControls.forEach(ctl => ctl.reset());
973         this.reload();
974     }
975     filtersSet(): boolean {
976         return Object.keys(this.dataSource.filters).length > 0;
977     }
978
979     gridToCsv(): Promise<string> {
980
981         let csvStr = '';
982         const columns = this.columnSet.displayColumns();
983
984         // CSV header
985         columns.forEach(col => {
986             csvStr += this.valueToCsv(col.label),
987             csvStr += ',';
988         });
989
990         csvStr = csvStr.replace(/,$/, '\n');
991
992         return new Promise(resolve => {
993             this.getAllRowsAsText().subscribe(
994                 row => {
995                     columns.forEach(col => {
996                         csvStr += this.valueToCsv(row[col.name]);
997                         csvStr += ',';
998                     });
999                     csvStr = csvStr.replace(/,$/, '\n');
1000                 },
1001                 err => {},
1002                 ()  => resolve(csvStr)
1003             );
1004         });
1005     }
1006
1007
1008     // prepares a string for inclusion within a CSV document
1009     // by escaping commas and quotes and removing newlines.
1010     valueToCsv(str: string): string {
1011         str = '' + str;
1012         if (!str) { return ''; }
1013         str = str.replace(/\n/g, '');
1014         if (str.match(/\,/) || str.match(/"/)) {
1015             str = str.replace(/"/g, '""');
1016             str = '"' + str + '"';
1017         }
1018         return str;
1019     }
1020
1021     generateColumns() {
1022         if (!this.columnSet.idlClass) { return; }
1023
1024         const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
1025
1026         // generate columns for all non-virtual fields on the IDL class
1027         this.idl.classes[this.columnSet.idlClass].fields
1028         .filter(field => !field.virtual)
1029         .forEach(field => {
1030             const col = new GridColumn();
1031             col.name = field.name;
1032             col.label = field.label || field.name;
1033             col.idlFieldDef = field;
1034             col.idlClass = this.columnSet.idlClass;
1035             col.datatype = field.datatype;
1036             col.isIndex = (field.name === pkeyField);
1037             col.isAuto = true;
1038
1039             if (this.showLinkSelectors) {
1040                 const selector = this.idl.getLinkSelector(
1041                     this.columnSet.idlClass, field.name);
1042                 if (selector) {
1043                     col.path = field.name + '.' + selector;
1044                 }
1045             }
1046
1047             if (this.showDeclaredFieldsOnly) {
1048                 col.hidden = true;
1049             }
1050
1051             this.columnSet.add(col);
1052         });
1053     }
1054
1055     saveGridConfig(): Promise<any> {
1056         if (!this.persistKey) {
1057             throw new Error('Grid persistKey required to save columns');
1058         }
1059         const conf = new GridPersistConf();
1060         conf.version = 2;
1061         conf.limit = this.pager.limit;
1062         conf.columns = this.columnSet.compileSaveObject();
1063
1064         return this.store.setItem('eg.grid.' + this.persistKey, conf);
1065     }
1066
1067     // TODO: saveGridConfigAsOrgSetting(...)
1068
1069     getGridConfig(persistKey: string): Promise<GridPersistConf> {
1070         if (!persistKey) { return Promise.resolve(null); }
1071         return this.store.getItem('eg.grid.' + persistKey);
1072     }
1073 }
1074
1075
1076 // Actions apply to specific rows
1077 export class GridToolbarAction {
1078     label: string;
1079     onClick: EventEmitter<any []>;
1080     action: (rows: any[]) => any; // DEPRECATED
1081     group: string;
1082     disabled: boolean;
1083     isGroup: boolean; // used for group placeholder entries
1084     disableOnRows: (rows: any[]) => boolean;
1085 }
1086
1087 // Buttons are global actions
1088 export class GridToolbarButton {
1089     label: string;
1090     onClick: EventEmitter<any []>;
1091     action: () => any; // DEPRECATED
1092     disabled: boolean;
1093 }
1094
1095 export class GridToolbarCheckbox {
1096     label: string;
1097     isChecked: boolean;
1098     onChange: EventEmitter<boolean>;
1099 }
1100
1101 export class GridDataSource {
1102
1103     data: any[];
1104     sort: any[];
1105     filters: Object;
1106     allRowsRetrieved: boolean;
1107     requestingData: boolean;
1108     getRows: (pager: Pager, sort: any[]) => Observable<any>;
1109
1110     constructor() {
1111         this.sort = [];
1112         this.filters = {};
1113         this.reset();
1114     }
1115
1116     reset() {
1117         this.data = [];
1118         this.allRowsRetrieved = false;
1119     }
1120
1121     // called from the template -- no data fetching
1122     getPageOfRows(pager: Pager): any[] {
1123         if (this.data) {
1124             return this.data.slice(
1125                 pager.offset, pager.limit + pager.offset
1126             ).filter(row => row !== undefined);
1127         }
1128         return [];
1129     }
1130
1131     // called on initial component load and user action (e.g. paging, sorting).
1132     requestPage(pager: Pager): Promise<any> {
1133
1134         if (
1135             this.getPageOfRows(pager).length === pager.limit
1136             // already have all data
1137             || this.allRowsRetrieved
1138             // have no way to get more data.
1139             || !this.getRows
1140         ) {
1141             return Promise.resolve();
1142         }
1143
1144         // If we have to call out for data, set inFetch
1145         this.requestingData = true;
1146
1147         return new Promise((resolve, reject) => {
1148             let idx = pager.offset;
1149             return this.getRows(pager, this.sort).subscribe(
1150                 row => {
1151                     this.data[idx++] = row;
1152                     this.requestingData = false;
1153                 },
1154                 err => {
1155                     console.error(`grid getRows() error ${err}`);
1156                     reject(err);
1157                 },
1158                 ()  => {
1159                     this.checkAllRetrieved(pager, idx);
1160                     this.requestingData = false;
1161                     resolve();
1162                 }
1163             );
1164         });
1165     }
1166
1167     // See if the last getRows() call resulted in the final set of data.
1168     checkAllRetrieved(pager: Pager, idx: number) {
1169         if (this.allRowsRetrieved) { return; }
1170
1171         if (idx === 0 || idx < (pager.limit + pager.offset)) {
1172             // last query returned nothing or less than one page.
1173             // confirm we have all of the preceding pages.
1174             if (!this.data.includes(undefined)) {
1175                 this.allRowsRetrieved = true;
1176                 pager.resultCount = this.data.length;
1177             }
1178         }
1179     }
1180 }
1181
1182