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