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