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