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