ae611875c313cfe4046e564b23ba3c0fab9d8a7d
[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} 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     selected() {
389         return Object.keys(this.indexes).filter(
390             ind => Boolean(this.indexes[ind]));
391     }
392
393     isEmpty(): boolean {
394         return this.selected().length === 0;
395     }
396
397     clear() {
398         this.indexes = {};
399     }
400 }
401
402 export interface GridRowFlairEntry {
403     icon: string;   // name of material icon
404     title?: string;  // tooltip string
405 }
406
407 export class GridColumnPersistConf {
408     name: string;
409     flex?: number;
410     sort?: number;
411     align?: string;
412 }
413
414 export class GridPersistConf {
415     version: number;
416     limit: number;
417     columns: GridColumnPersistConf[];
418 }
419
420 export class GridContext {
421
422     pager: Pager;
423     idlClass: string;
424     isSortable: boolean;
425     isMultiSortable: boolean;
426     useLocalSort: boolean;
427     persistKey: string;
428     disableMultiSelect: boolean;
429     disableSelect: boolean;
430     dataSource: GridDataSource;
431     columnSet: GridColumnSet;
432     rowSelector: GridRowSelector;
433     toolbarButtons: GridToolbarButton[];
434     toolbarCheckboxes: GridToolbarCheckbox[];
435     toolbarActions: GridToolbarAction[];
436     lastSelectedIndex: any;
437     pageChanges: Subscription;
438     rowFlairIsEnabled: boolean;
439     rowFlairCallback: (row: any) => GridRowFlairEntry;
440     rowClassCallback: (row: any) => string;
441     cellClassCallback: (row: any, col: GridColumn) => string;
442     defaultVisibleFields: string[];
443     defaultHiddenFields: string[];
444     overflowCells: boolean;
445     showLinkSelectors: boolean;
446     disablePaging: boolean;
447     showDeclaredFieldsOnly: boolean;
448
449     // Allow calling code to know when the select-all-rows-in-page
450     // action has occurred.
451     selectRowsInPageEmitter: EventEmitter<void>;
452
453     // Services injected by our grid component
454     idl: IdlService;
455     org: OrgService;
456     store: ServerStoreService;
457     format: FormatService;
458
459     constructor(
460         idl: IdlService,
461         org: OrgService,
462         store: ServerStoreService,
463         format: FormatService) {
464
465         this.idl = idl;
466         this.org = org;
467         this.store = store;
468         this.format = format;
469         this.pager = new Pager();
470         this.rowSelector = new GridRowSelector();
471         this.toolbarButtons = [];
472         this.toolbarCheckboxes = [];
473         this.toolbarActions = [];
474     }
475
476     init() {
477         this.selectRowsInPageEmitter = new EventEmitter<void>();
478         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
479         this.columnSet.isSortable = this.isSortable === true;
480         this.columnSet.isMultiSortable = this.isMultiSortable === true;
481         this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
482         this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
483         if (!this.pager.limit) {
484             this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
485         }
486         this.generateColumns();
487     }
488
489     // Load initial settings and data.
490     initData() {
491         this.applyGridConfig()
492         .then(ok => this.dataSource.requestPage(this.pager))
493         .then(ok => this.listenToPager());
494     }
495
496     destroy() {
497         this.ignorePager();
498     }
499
500     applyGridConfig(): Promise<void> {
501         return this.getGridConfig(this.persistKey)
502         .then(conf => {
503             let columns = [];
504             if (conf) {
505                 columns = conf.columns;
506                 if (conf.limit) {
507                     this.pager.limit = conf.limit;
508                 }
509             }
510
511             // This is called regardless of the presence of saved
512             // settings so defaults can be applied.
513             this.columnSet.applyColumnSettings(columns);
514         });
515     }
516
517     reload() {
518         // Give the UI time to settle before reloading grid data.
519         // This can help when data retrieval depends on a value
520         // getting modified by an angular digest cycle.
521         setTimeout(() => {
522             this.pager.reset();
523             this.dataSource.reset();
524             this.dataSource.requestPage(this.pager);
525         });
526     }
527
528     // Sort the existing data source instead of requesting sorted
529     // data from the client.  Reset pager to page 1.  As with reload(),
530     // give the client a chance to setting before redisplaying.
531     sortLocal() {
532         setTimeout(() => {
533             this.pager.reset();
534             this.sortLocalData();
535             this.dataSource.requestPage(this.pager);
536         });
537     }
538
539     // Subscribe or unsubscribe to page-change events from the pager.
540     listenToPager() {
541         if (this.pageChanges) { return; }
542         this.pageChanges = this.pager.onChange$.subscribe(
543             val => this.dataSource.requestPage(this.pager));
544     }
545
546     ignorePager() {
547         if (!this.pageChanges) { return; }
548         this.pageChanges.unsubscribe();
549         this.pageChanges = null;
550     }
551
552     // Sort data in the data source array
553     sortLocalData() {
554
555         const sortDefs = this.dataSource.sort.map(sort => {
556             const column = this.columnSet.getColByName(sort.name);
557
558             const def = {
559                 name: sort.name,
560                 dir: sort.dir,
561                 col: column
562             };
563
564             if (!def.col.comparator) {
565                 switch (def.col.datatype) {
566                     case 'id':
567                     case 'money':
568                     case 'int':
569                         def.col.comparator = (a, b) => {
570                             a = Number(a);
571                             b = Number(b);
572                             if (a < b) { return -1; }
573                             if (a > b) { return 1; }
574                             return 0;
575                         };
576                         break;
577                     default:
578                         def.col.comparator = (a, b) => {
579                             if (a < b) { return -1; }
580                             if (a > b) { return 1; }
581                             return 0;
582                         };
583                 }
584             }
585
586             return def;
587         });
588
589         this.dataSource.data.sort((rowA, rowB) => {
590
591             for (let idx = 0; idx < sortDefs.length; idx++) {
592                 const sortDef = sortDefs[idx];
593
594                 const valueA = this.getRowColumnValue(rowA, sortDef.col);
595                 const valueB = this.getRowColumnValue(rowB, sortDef.col);
596
597                 if (valueA === '' && valueB === '') { continue; }
598                 if (valueA === '' && valueB !== '') { return 1; }
599                 if (valueA !== '' && valueB === '') { return -1; }
600
601                 const diff = sortDef.col.comparator(valueA, valueB);
602                 if (diff === 0) { continue; }
603
604                 return sortDef.dir === 'DESC' ? -diff : diff;
605             }
606
607             return 0; // No differences found.
608         });
609     }
610
611     getRowIndex(row: any): any {
612         const col = this.columnSet.indexColumn;
613         if (!col) {
614             throw new Error('grid index column required');
615         }
616         return this.getRowColumnValue(row, col);
617     }
618
619     // Returns position in the data source array of the row with
620     // the provided index.
621     getRowPosition(index: any): number {
622         // for-loop for early exit
623         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
624             const row = this.dataSource.data[idx];
625             if (row !== undefined && index === this.getRowIndex(row)) {
626                 return idx;
627             }
628         }
629     }
630
631     // Return the row with the provided index.
632     getRowByIndex(index: any): any {
633         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
634             const row = this.dataSource.data[idx];
635             if (row !== undefined && index === this.getRowIndex(row)) {
636                 return row;
637             }
638         }
639     }
640
641     // Returns all selected rows, regardless of whether they are
642     // currently visible in the grid display.
643     getSelectedRows(): any[] {
644         const selected = [];
645         this.rowSelector.selected().forEach(index => {
646             const row = this.getRowByIndex(index);
647             if (row) {
648                 selected.push(row);
649             }
650         });
651         return selected;
652     }
653
654     getRowColumnValue(row: any, col: GridColumn): string {
655         let val;
656
657         if (col.path) {
658             val = this.nestedItemFieldValue(row, col);
659         } else if (col.name in row) {
660             val = this.getObjectFieldValue(row, col.name);
661         }
662
663         if (col.datatype === 'bool') {
664             // Avoid string-ifying bools so we can use an <eg-bool/>
665             // in the grid template.
666             return val;
667         }
668
669         return this.format.transform({
670             value: val,
671             idlClass: col.idlClass,
672             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
673             datatype: col.datatype,
674             datePlusTime: Boolean(col.datePlusTime)
675         });
676     }
677
678     getObjectFieldValue(obj: any, name: string): any {
679         if (typeof obj[name] === 'function') {
680             return obj[name]();
681         } else {
682             return obj[name];
683         }
684     }
685
686     nestedItemFieldValue(obj: any, col: GridColumn): string {
687
688         let idlField;
689         let idlClassDef;
690         const original = obj;
691         const steps = col.path.split('.');
692
693         for (let i = 0; i < steps.length; i++) {
694             const step = steps[i];
695
696             if (obj === null || obj === undefined || typeof obj !== 'object') {
697                 // We have run out of data to step through before
698                 // reaching the end of the path.  Conclude fleshing via
699                 // callback if provided then exit.
700                 if (col.flesher && obj !== undefined) {
701                     return col.flesher(obj, col, original);
702                 }
703                 return obj;
704             }
705
706             const class_ = obj.classname;
707             if (class_ && (idlClassDef = this.idl.classes[class_])) {
708                 idlField = idlClassDef.field_map[step];
709             }
710
711             obj = this.getObjectFieldValue(obj, step);
712         }
713
714         // We found a nested IDL object which may or may not have
715         // been configured as a top-level column.  Flesh the column
716         // metadata with our newly found IDL info.
717         if (idlField) {
718             if (!col.datatype) {
719                 col.datatype = idlField.datatype;
720             }
721             if (!col.idlFieldDef) {
722                 idlField = col.idlFieldDef;
723             }
724             if (!col.idlClass) {
725                 col.idlClass = idlClassDef.name;
726             }
727             if (!col.label) {
728                 col.label = idlField.label || idlField.name;
729             }
730         }
731
732         return obj;
733     }
734
735
736     getColumnTextContent(row: any, col: GridColumn): string {
737         if (col.cellTemplate) {
738             // TODO
739             // Extract the text content from the rendered template.
740         } else {
741             return this.getRowColumnValue(row, col);
742         }
743     }
744
745     selectOneRow(index: any) {
746         this.rowSelector.clear();
747         this.rowSelector.select(index);
748         this.lastSelectedIndex = index;
749     }
750
751     selectMultipleRows(indexes: any[]) {
752         this.rowSelector.clear();
753         this.rowSelector.select(indexes);
754         this.lastSelectedIndex = indexes[indexes.length - 1];
755     }
756
757     // selects or deselects an item, without affecting the others.
758     // returns true if the item is selected; false if de-selected.
759     toggleSelectOneRow(index: any) {
760         if (this.rowSelector.contains(index)) {
761             this.rowSelector.deselect(index);
762             return false;
763         }
764
765         this.rowSelector.select(index);
766         return true;
767     }
768
769     selectRowByPos(pos: number) {
770         const row = this.dataSource.data[pos];
771         if (row) {
772             this.selectOneRow(this.getRowIndex(row));
773         }
774     }
775
776     selectPreviousRow() {
777         if (!this.lastSelectedIndex) { return; }
778         const pos = this.getRowPosition(this.lastSelectedIndex);
779         if (pos === this.pager.offset) {
780             this.toPrevPage().then(ok => this.selectLastRow(), err => {});
781         } else {
782             this.selectRowByPos(pos - 1);
783         }
784     }
785
786     selectNextRow() {
787         if (!this.lastSelectedIndex) { return; }
788         const pos = this.getRowPosition(this.lastSelectedIndex);
789         if (pos === (this.pager.offset + this.pager.limit - 1)) {
790             this.toNextPage().then(ok => this.selectFirstRow(), err => {});
791         } else {
792             this.selectRowByPos(pos + 1);
793         }
794     }
795
796     // shift-up-arrow
797     // Select the previous row in addition to any currently selected row.
798     // However, if the previous row is already selected, assume the user
799     // has reversed direction and now wants to de-select the last selected row.
800     selectMultiRowsPrevious() {
801         if (!this.lastSelectedIndex) { return; }
802         const pos = this.getRowPosition(this.lastSelectedIndex);
803         const selectedIndexes = this.rowSelector.selected();
804
805         const promise = // load the previous page of data if needed
806             (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
807
808         promise.then(
809             ok => {
810                 const row = this.dataSource.data[pos - 1];
811                 const newIndex = this.getRowIndex(row);
812                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
813                     // Prev row is already selected.  User is reversing direction.
814                     this.rowSelector.deselect(this.lastSelectedIndex);
815                     this.lastSelectedIndex = newIndex;
816                 } else {
817                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
818                 }
819             },
820             err => {}
821         );
822     }
823
824     // shift-down-arrow
825     // Select the next row in addition to any currently selected row.
826     // However, if the next row is already selected, assume the user
827     // has reversed direction and wants to de-select the last selected row.
828     selectMultiRowsNext() {
829         if (!this.lastSelectedIndex) { return; }
830         const pos = this.getRowPosition(this.lastSelectedIndex);
831         const selectedIndexes = this.rowSelector.selected();
832
833         const promise = // load the next page of data if needed
834             (pos === (this.pager.offset + this.pager.limit - 1)) ?
835             this.toNextPage() : Promise.resolve();
836
837         promise.then(
838             ok => {
839                 const row = this.dataSource.data[pos + 1];
840                 const newIndex = this.getRowIndex(row);
841                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
842                     // Next row is already selected.  User is reversing direction.
843                     this.rowSelector.deselect(this.lastSelectedIndex);
844                     this.lastSelectedIndex = newIndex;
845                 } else {
846                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
847                 }
848             },
849             err => {}
850         );
851     }
852
853     getFirstRowInPage(): any {
854         return this.dataSource.data[this.pager.offset];
855     }
856
857     getLastRowInPage(): any {
858         return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
859     }
860
861     selectFirstRow() {
862         this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
863     }
864
865     selectLastRow() {
866         this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
867     }
868
869     selectRowsInPage() {
870         const rows = this.dataSource.getPageOfRows(this.pager);
871         const indexes = rows.map(r => this.getRowIndex(r));
872         this.rowSelector.select(indexes);
873         this.selectRowsInPageEmitter.emit();
874     }
875
876     toPrevPage(): Promise<any> {
877         if (this.pager.isFirstPage()) {
878             return Promise.reject('on first');
879         }
880         // temp ignore pager events since we're calling requestPage manually.
881         this.ignorePager();
882         this.pager.decrement();
883         this.listenToPager();
884         return this.dataSource.requestPage(this.pager);
885     }
886
887     toNextPage(): Promise<any> {
888         if (this.pager.isLastPage()) {
889             return Promise.reject('on last');
890         }
891         // temp ignore pager events since we're calling requestPage manually.
892         this.ignorePager();
893         this.pager.increment();
894         this.listenToPager();
895         return this.dataSource.requestPage(this.pager);
896     }
897
898     getAllRows(): Promise<any> {
899         const pager = new Pager();
900         pager.offset = 0;
901         pager.limit = MAX_ALL_ROW_COUNT;
902         return this.dataSource.requestPage(pager);
903     }
904
905     // Returns a key/value pair object of visible column data as text.
906     getRowAsFlatText(row: any): any {
907         const flatRow = {};
908         this.columnSet.displayColumns().forEach(col => {
909             flatRow[col.name] =
910                 this.getColumnTextContent(row, col);
911         });
912         return flatRow;
913     }
914
915     getAllRowsAsText(): Observable<any> {
916         return Observable.create(observer => {
917             this.getAllRows().then(ok => {
918                 this.dataSource.data.forEach(row => {
919                     observer.next(this.getRowAsFlatText(row));
920                 });
921                 observer.complete();
922             });
923         });
924     }
925
926     gridToCsv(): Promise<string> {
927
928         let csvStr = '';
929         const columns = this.columnSet.displayColumns();
930
931         // CSV header
932         columns.forEach(col => {
933             csvStr += this.valueToCsv(col.label),
934             csvStr += ',';
935         });
936
937         csvStr = csvStr.replace(/,$/, '\n');
938
939         return new Promise(resolve => {
940             this.getAllRowsAsText().subscribe(
941                 row => {
942                     columns.forEach(col => {
943                         csvStr += this.valueToCsv(row[col.name]);
944                         csvStr += ',';
945                     });
946                     csvStr = csvStr.replace(/,$/, '\n');
947                 },
948                 err => {},
949                 ()  => resolve(csvStr)
950             );
951         });
952     }
953
954
955     // prepares a string for inclusion within a CSV document
956     // by escaping commas and quotes and removing newlines.
957     valueToCsv(str: string): string {
958         str = '' + str;
959         if (!str) { return ''; }
960         str = str.replace(/\n/g, '');
961         if (str.match(/\,/) || str.match(/"/)) {
962             str = str.replace(/"/g, '""');
963             str = '"' + str + '"';
964         }
965         return str;
966     }
967
968     generateColumns() {
969         if (!this.columnSet.idlClass) { return; }
970
971         const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
972
973         // generate columns for all non-virtual fields on the IDL class
974         this.idl.classes[this.columnSet.idlClass].fields
975         .filter(field => !field.virtual)
976         .forEach(field => {
977             const col = new GridColumn();
978             col.name = field.name;
979             col.label = field.label || field.name;
980             col.idlFieldDef = field;
981             col.idlClass = this.columnSet.idlClass;
982             col.datatype = field.datatype;
983             col.isIndex = (field.name === pkeyField);
984             col.isAuto = true;
985
986             if (this.showLinkSelectors) {
987                 const selector = this.idl.getLinkSelector(
988                     this.columnSet.idlClass, field.name);
989                 if (selector) {
990                     col.path = field.name + '.' + selector;
991                 }
992             }
993
994             if (this.showDeclaredFieldsOnly) {
995                 col.hidden = true;
996             }
997
998             this.columnSet.add(col);
999         });
1000     }
1001
1002     saveGridConfig(): Promise<any> {
1003         if (!this.persistKey) {
1004             throw new Error('Grid persistKey required to save columns');
1005         }
1006         const conf = new GridPersistConf();
1007         conf.version = 2;
1008         conf.limit = this.pager.limit;
1009         conf.columns = this.columnSet.compileSaveObject();
1010
1011         return this.store.setItem('eg.grid.' + this.persistKey, conf);
1012     }
1013
1014     // TODO: saveGridConfigAsOrgSetting(...)
1015
1016     getGridConfig(persistKey: string): Promise<GridPersistConf> {
1017         if (!persistKey) { return Promise.resolve(null); }
1018         return this.store.getItem('eg.grid.' + persistKey);
1019     }
1020 }
1021
1022
1023 // Actions apply to specific rows
1024 export class GridToolbarAction {
1025     label: string;
1026     onClick: EventEmitter<any []>;
1027     action: (rows: any[]) => any; // DEPRECATED
1028     group: string;
1029     disabled: boolean;
1030     isGroup: boolean; // used for group placeholder entries
1031     separator: boolean;
1032     disableOnRows: (rows: any[]) => boolean;
1033 }
1034
1035 // Buttons are global actions
1036 export class GridToolbarButton {
1037     label: string;
1038     onClick: EventEmitter<any []>;
1039     action: () => any; // DEPRECATED
1040     disabled: boolean;
1041 }
1042
1043 export class GridToolbarCheckbox {
1044     label: string;
1045     isChecked: boolean;
1046     onChange: EventEmitter<boolean>;
1047 }
1048
1049 export class GridDataSource {
1050
1051     data: any[];
1052     sort: any[];
1053     allRowsRetrieved: boolean;
1054     requestingData: boolean;
1055     getRows: (pager: Pager, sort: any[]) => Observable<any>;
1056
1057     constructor() {
1058         this.sort = [];
1059         this.reset();
1060     }
1061
1062     reset() {
1063         this.data = [];
1064         this.allRowsRetrieved = false;
1065     }
1066
1067     // called from the template -- no data fetching
1068     getPageOfRows(pager: Pager): any[] {
1069         if (this.data) {
1070             return this.data.slice(
1071                 pager.offset, pager.limit + pager.offset
1072             ).filter(row => row !== undefined);
1073         }
1074         return [];
1075     }
1076
1077     // called on initial component load and user action (e.g. paging, sorting).
1078     requestPage(pager: Pager): Promise<any> {
1079
1080         if (
1081             this.getPageOfRows(pager).length === pager.limit
1082             // already have all data
1083             || this.allRowsRetrieved
1084             // have no way to get more data.
1085             || !this.getRows
1086         ) {
1087             return Promise.resolve();
1088         }
1089
1090         // If we have to call out for data, set inFetch
1091         this.requestingData = true;
1092
1093         return new Promise((resolve, reject) => {
1094             let idx = pager.offset;
1095             return this.getRows(pager, this.sort).subscribe(
1096                 row => {
1097                     this.data[idx++] = row;
1098                     this.requestingData = false;
1099                 },
1100                 err => {
1101                     console.error(`grid getRows() error ${err}`);
1102                     reject(err);
1103                 },
1104                 ()  => {
1105                     this.checkAllRetrieved(pager, idx);
1106                     this.requestingData = false;
1107                     resolve();
1108                 }
1109             );
1110         });
1111     }
1112
1113     // See if the last getRows() call resulted in the final set of data.
1114     checkAllRetrieved(pager: Pager, idx: number) {
1115         if (this.allRowsRetrieved) { return; }
1116
1117         if (idx === 0 || idx < (pager.limit + pager.offset)) {
1118             // last query returned nothing or less than one page.
1119             // confirm we have all of the preceding pages.
1120             if (!this.data.includes(undefined)) {
1121                 this.allRowsRetrieved = true;
1122                 pager.resultCount = this.data.length;
1123             }
1124         }
1125     }
1126 }
1127
1128