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