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