]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/grid/grid.ts
LP1904036 Grid toolbarLabel option
[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     // Track these so we can emit the selectionChange event
434     // only when the selection actually changes.
435     previousSelection: string[] = [];
436
437     // Emits the selected indexes on selection change
438     selectionChange: EventEmitter<string[]> = new EventEmitter<string[]>();
439
440     constructor() {
441         this.clear();
442     }
443
444     // Returns true if all of the requested indexes exist in the selector.
445     contains(index: string | string[]): boolean {
446         const indexes = [].concat(index);
447         for (let i = 0; i < indexes.length; i++) { // early exit
448             if (!this.indexes[indexes[i]]) {
449                 return false;
450             }
451         }
452         return true;
453     }
454
455     emitChange() {
456         const keys = this.selected();
457
458         if (keys.length === this.previousSelection.length &&
459             this.contains(this.previousSelection)) {
460             return; // No change has occurred
461         }
462
463         this.previousSelection = keys;
464         this.selectionChange.emit(keys);
465     }
466
467     select(index: string | string[]) {
468         const indexes = [].concat(index);
469         indexes.forEach(i => this.indexes[i] = true);
470         this.emitChange();
471     }
472
473     deselect(index: string | string[]) {
474         const indexes = [].concat(index);
475         indexes.forEach(i => delete this.indexes[i]);
476         this.emitChange();
477     }
478
479     toggle(index: string) {
480         if (this.indexes[index]) {
481             this.deselect(index);
482         } else {
483             this.select(index);
484         }
485     }
486
487     selected(): string[] {
488         return Object.keys(this.indexes);
489     }
490
491     isEmpty(): boolean {
492         return this.selected().length === 0;
493     }
494
495     clear() {
496         this.indexes = {};
497         this.emitChange();
498     }
499 }
500
501 export interface GridRowFlairEntry {
502     icon: string;   // name of material icon
503     title?: string;  // tooltip string
504 }
505
506 export class GridColumnPersistConf {
507     name: string;
508     flex?: number;
509     sort?: number;
510     align?: string;
511 }
512
513 export class GridPersistConf {
514     version: number;
515     limit: number;
516     columns: GridColumnPersistConf[];
517     hideToolbarActions: string[];
518 }
519
520 export class GridContext {
521
522     pager: Pager;
523     idlClass: string;
524     isSortable: boolean;
525     isFilterable: boolean;
526     stickyGridHeader: boolean;
527     isMultiSortable: boolean;
528     useLocalSort: boolean;
529     persistKey: string;
530     disableMultiSelect: boolean;
531     disableSelect: boolean;
532     dataSource: GridDataSource;
533     columnSet: GridColumnSet;
534     autoGeneratedColumnOrder: string;
535     rowSelector: GridRowSelector;
536     toolbarLabel: string;
537     toolbarButtons: GridToolbarButton[];
538     toolbarCheckboxes: GridToolbarCheckbox[];
539     toolbarActions: GridToolbarAction[];
540     lastSelectedIndex: any;
541     pageChanges: Subscription;
542     rowFlairIsEnabled: boolean;
543     rowFlairCallback: (row: any) => GridRowFlairEntry;
544     rowClassCallback: (row: any) => string;
545     cellClassCallback: (row: any, col: GridColumn) => string;
546     defaultVisibleFields: string[];
547     defaultHiddenFields: string[];
548     ignoredFields: string[];
549     overflowCells: boolean;
550     disablePaging: boolean;
551     showDeclaredFieldsOnly: boolean;
552     cellTextGenerator: GridCellTextGenerator;
553
554     // Allow calling code to know when the select-all-rows-in-page
555     // action has occurred.
556     selectRowsInPageEmitter: EventEmitter<void>;
557
558     filterControls: QueryList<GridFilterControlComponent>;
559
560     // Services injected by our grid component
561     idl: IdlService;
562     org: OrgService;
563     store: ServerStoreService;
564     format: FormatService;
565
566     constructor(
567         idl: IdlService,
568         org: OrgService,
569         store: ServerStoreService,
570         format: FormatService) {
571
572         this.idl = idl;
573         this.org = org;
574         this.store = store;
575         this.format = format;
576         this.pager = new Pager();
577         this.rowSelector = new GridRowSelector();
578         this.toolbarButtons = [];
579         this.toolbarCheckboxes = [];
580         this.toolbarActions = [];
581     }
582
583     init() {
584         this.selectRowsInPageEmitter = new EventEmitter<void>();
585         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
586         this.columnSet.isSortable = this.isSortable === true;
587         this.columnSet.isFilterable = this.isFilterable === true;
588         this.columnSet.isMultiSortable = this.isMultiSortable === true;
589         this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
590         this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
591         if (!this.pager.limit) {
592             this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
593         }
594         this.generateColumns();
595     }
596
597     // Load initial settings and data.
598     initData() {
599         this.applyGridConfig()
600         .then(ok => this.dataSource.requestPage(this.pager))
601         .then(ok => this.listenToPager());
602     }
603
604     destroy() {
605         this.ignorePager();
606     }
607
608     applyGridConfig(): Promise<void> {
609         return this.getGridConfig(this.persistKey)
610         .then(conf => {
611             let columns = [];
612             if (conf) {
613                 columns = conf.columns;
614                 if (conf.limit && !this.disablePaging) {
615                     this.pager.limit = conf.limit;
616                 }
617                 this.applyToolbarActionVisibility(conf.hideToolbarActions);
618             }
619
620             // This is called regardless of the presence of saved
621             // settings so defaults can be applied.
622             this.columnSet.applyColumnSettings(columns);
623         });
624     }
625
626     applyToolbarActionVisibility(hidden: string[]) {
627         if (!hidden || hidden.length === 0) { return; }
628
629         const groups = [];
630         this.toolbarActions.forEach(action => {
631             if (action.isGroup) {
632                 groups.push(action);
633             } else if (!action.isSeparator) {
634                 action.hidden = hidden.includes(action.label);
635             }
636         });
637
638         // If all actions in a group are hidden, hide the group as well.
639         // Note the group may be marked as hidden in the configuration,
640         // but the addition of new entries within a group should cause
641         // it to be visible again.
642         groups.forEach(group => {
643             const visible = this.toolbarActions
644                 .filter(action => action.group === group.label && !action.hidden);
645             group.hidden = visible.length === 0;
646         });
647     }
648
649     reload() {
650         // Give the UI time to settle before reloading grid data.
651         // This can help when data retrieval depends on a value
652         // getting modified by an angular digest cycle.
653         setTimeout(() => {
654             this.pager.reset();
655             this.dataSource.reset();
656             this.dataSource.requestPage(this.pager);
657         });
658     }
659
660     reloadWithoutPagerReset() {
661         setTimeout(() => {
662             this.dataSource.reset();
663             this.dataSource.requestPage(this.pager);
664         });
665     }
666
667     // Sort the existing data source instead of requesting sorted
668     // data from the client.  Reset pager to page 1.  As with reload(),
669     // give the client a chance to setting before redisplaying.
670     sortLocal() {
671         setTimeout(() => {
672             this.pager.reset();
673             this.sortLocalData();
674             this.dataSource.requestPage(this.pager);
675         });
676     }
677
678     // Subscribe or unsubscribe to page-change events from the pager.
679     listenToPager() {
680         if (this.pageChanges) { return; }
681         this.pageChanges = this.pager.onChange$.subscribe(
682             val => this.dataSource.requestPage(this.pager));
683     }
684
685     ignorePager() {
686         if (!this.pageChanges) { return; }
687         this.pageChanges.unsubscribe();
688         this.pageChanges = null;
689     }
690
691     // Sort data in the data source array
692     sortLocalData() {
693
694         const sortDefs = this.dataSource.sort.map(sort => {
695             const column = this.columnSet.getColByName(sort.name);
696
697             const def = {
698                 name: sort.name,
699                 dir: sort.dir,
700                 col: column
701             };
702
703             if (!def.col.comparator) {
704                 switch (def.col.datatype) {
705                     case 'id':
706                     case 'money':
707                     case 'int':
708                         def.col.comparator = (a, b) => {
709                             a = Number(a);
710                             b = Number(b);
711                             if (a < b) { return -1; }
712                             if (a > b) { return 1; }
713                             return 0;
714                         };
715                         break;
716                     default:
717                         def.col.comparator = (a, b) => {
718                             if (a < b) { return -1; }
719                             if (a > b) { return 1; }
720                             return 0;
721                         };
722                 }
723             }
724
725             return def;
726         });
727
728         this.dataSource.data.sort((rowA, rowB) => {
729
730             for (let idx = 0; idx < sortDefs.length; idx++) {
731                 const sortDef = sortDefs[idx];
732
733                 const valueA = this.getRowColumnValue(rowA, sortDef.col);
734                 const valueB = this.getRowColumnValue(rowB, sortDef.col);
735
736                 if (valueA === '' && valueB === '') { continue; }
737                 if (valueA === '' && valueB !== '') { return 1; }
738                 if (valueA !== '' && valueB === '') { return -1; }
739
740                 const diff = sortDef.col.comparator(valueA, valueB);
741                 if (diff === 0) { continue; }
742
743                 return sortDef.dir === 'DESC' ? -diff : diff;
744             }
745
746             return 0; // No differences found.
747         });
748     }
749
750     getRowIndex(row: any): any {
751         const col = this.columnSet.indexColumn;
752         if (!col) {
753             throw new Error('grid index column required');
754         }
755         return this.getRowColumnValue(row, col);
756     }
757
758     // Returns position in the data source array of the row with
759     // the provided index.
760     getRowPosition(index: any): number {
761         // for-loop for early exit
762         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
763             const row = this.dataSource.data[idx];
764             if (row !== undefined && index === this.getRowIndex(row)) {
765                 return idx;
766             }
767         }
768     }
769
770     // Return the row with the provided index.
771     getRowByIndex(index: any): any {
772         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
773             const row = this.dataSource.data[idx];
774             if (row !== undefined && index === this.getRowIndex(row)) {
775                 return row;
776             }
777         }
778     }
779
780     // Returns all selected rows, regardless of whether they are
781     // currently visible in the grid display.
782     // De-selects previously selected rows which are no longer
783     // present in the grid.
784     getSelectedRows(): any[] {
785         const selected = [];
786         const deleted = [];
787
788         this.rowSelector.selected().forEach(index => {
789             const row = this.getRowByIndex(index);
790             if (row) {
791                 selected.push(row);
792             } else {
793                 deleted.push(index);
794             }
795         });
796
797         this.rowSelector.deselect(deleted);
798         return selected;
799     }
800
801     rowIsSelected(row: any): boolean {
802         const index = this.getRowIndex(row);
803         return this.rowSelector.selected().filter(
804             idx => idx === index
805         ).length > 0;
806     }
807
808     getRowColumnBareValue(row: any, col: GridColumn): any {
809         if (col.name in row) {
810             return this.getObjectFieldValue(row, col.name);
811         } else if (col.path) {
812             return this.nestedItemFieldValue(row, col);
813         }
814     }
815
816     getRowColumnValue(row: any, col: GridColumn): any {
817         const val = this.getRowColumnBareValue(row, col);
818
819         if (col.datatype === 'bool') {
820             // Avoid string-ifying bools so we can use an <eg-bool/>
821             // in the grid template.
822             return val;
823         }
824
825         let interval;
826         const intField = col.dateOnlyIntervalField;
827         if (intField) {
828             const intCol =
829                 this.columnSet.columns.filter(c => c.path === intField)[0];
830             if (intCol) {
831                 interval = this.getRowColumnBareValue(row, intCol);
832             }
833         }
834
835         return this.format.transform({
836             value: val,
837             idlClass: col.idlClass,
838             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
839             datatype: col.datatype,
840             datePlusTime: Boolean(col.datePlusTime),
841             timezoneContextOrg: Number(col.timezoneContextOrg),
842             dateOnlyInterval: interval
843         });
844     }
845
846     getObjectFieldValue(obj: any, name: string): any {
847         if (typeof obj[name] === 'function') {
848             return obj[name]();
849         } else {
850             return obj[name];
851         }
852     }
853
854     nestedItemFieldValue(obj: any, col: GridColumn): string {
855
856         let idlField;
857         let idlClassDef;
858         const original = obj;
859         const steps = col.path.split('.');
860
861         for (let i = 0; i < steps.length; i++) {
862             const step = steps[i];
863
864             if (obj === null || obj === undefined || typeof obj !== 'object') {
865                 // We have run out of data to step through before
866                 // reaching the end of the path.  Conclude fleshing via
867                 // callback if provided then exit.
868                 if (col.flesher && obj !== undefined) {
869                     return col.flesher(obj, col, original);
870                 }
871                 return obj;
872             }
873
874             const class_ = obj.classname;
875             if (class_ && (idlClassDef = this.idl.classes[class_])) {
876                 idlField = idlClassDef.field_map[step];
877             }
878
879             obj = this.getObjectFieldValue(obj, step);
880         }
881
882         // We found a nested IDL object which may or may not have
883         // been configured as a top-level column.  Flesh the column
884         // metadata with our newly found IDL info.
885         if (idlField) {
886             if (!col.datatype) {
887                 col.datatype = idlField.datatype;
888             }
889             if (!col.idlFieldDef) {
890                 idlField = col.idlFieldDef;
891             }
892             if (!col.idlClass) {
893                 col.idlClass = idlClassDef.name;
894             }
895             if (!col.label) {
896                 col.label = idlField.label || idlField.name;
897             }
898         }
899
900         return obj;
901     }
902
903
904     getColumnTextContent(row: any, col: GridColumn): string {
905         if (this.columnHasTextGenerator(col)) {
906             const str = this.cellTextGenerator[col.name](row);
907             return (str === null || str === undefined)  ? '' : str;
908         } else {
909             if (col.cellTemplate) {
910                 return ''; // avoid 'undefined' values
911             } else {
912                 return this.getRowColumnValue(row, col);
913             }
914         }
915     }
916
917     selectOneRow(index: any) {
918         this.rowSelector.clear();
919         this.rowSelector.select(index);
920         this.lastSelectedIndex = index;
921     }
922
923     selectMultipleRows(indexes: any[]) {
924         this.rowSelector.clear();
925         this.rowSelector.select(indexes);
926         this.lastSelectedIndex = indexes[indexes.length - 1];
927     }
928
929     // selects or deselects an item, without affecting the others.
930     // returns true if the item is selected; false if de-selected.
931     toggleSelectOneRow(index: any) {
932         if (this.rowSelector.contains(index)) {
933             this.rowSelector.deselect(index);
934             return false;
935         }
936
937         this.rowSelector.select(index);
938         this.lastSelectedIndex = index;
939         return true;
940     }
941
942     selectRowByPos(pos: number) {
943         const row = this.dataSource.data[pos];
944         if (row) {
945             this.selectOneRow(this.getRowIndex(row));
946         }
947     }
948
949     selectPreviousRow() {
950         if (!this.lastSelectedIndex) { return; }
951         const pos = this.getRowPosition(this.lastSelectedIndex);
952         if (pos === this.pager.offset) {
953             this.toPrevPage().then(ok => this.selectLastRow(), err => {});
954         } else {
955             this.selectRowByPos(pos - 1);
956         }
957     }
958
959     selectNextRow() {
960         if (!this.lastSelectedIndex) { return; }
961         const pos = this.getRowPosition(this.lastSelectedIndex);
962         if (pos === (this.pager.offset + this.pager.limit - 1)) {
963             this.toNextPage().then(ok => this.selectFirstRow(), err => {});
964         } else {
965             this.selectRowByPos(pos + 1);
966         }
967     }
968
969     // shift-up-arrow
970     // Select the previous row in addition to any currently selected row.
971     // However, if the previous row is already selected, assume the user
972     // has reversed direction and now wants to de-select the last selected row.
973     selectMultiRowsPrevious() {
974         if (!this.lastSelectedIndex) { return; }
975         const pos = this.getRowPosition(this.lastSelectedIndex);
976         const selectedIndexes = this.rowSelector.selected();
977
978         const promise = // load the previous page of data if needed
979             (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
980
981         promise.then(
982             ok => {
983                 const row = this.dataSource.data[pos - 1];
984                 const newIndex = this.getRowIndex(row);
985                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
986                     // Prev row is already selected.  User is reversing direction.
987                     this.rowSelector.deselect(this.lastSelectedIndex);
988                     this.lastSelectedIndex = newIndex;
989                 } else {
990                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
991                 }
992             },
993             err => {}
994         );
995     }
996
997     // Select all rows between the previously selected row and
998     // the provided row, including the provided row.
999     // This is additive only -- rows are never de-selected.
1000     selectRowRange(index: any) {
1001
1002         if (!this.lastSelectedIndex) {
1003             this.selectOneRow(index);
1004             return;
1005         }
1006
1007         const next = this.getRowPosition(index);
1008         const prev = this.getRowPosition(this.lastSelectedIndex);
1009         const start = Math.min(prev, next);
1010         const end = Math.max(prev, next);
1011
1012         for (let idx = start; idx <= end; idx++) {
1013             const row = this.dataSource.data[idx];
1014             if (row) {
1015                 this.rowSelector.select(this.getRowIndex(row));
1016             }
1017         }
1018
1019         this.lastSelectedIndex = index;
1020     }
1021
1022     // shift-down-arrow
1023     // Select the next row in addition to any currently selected row.
1024     // However, if the next row is already selected, assume the user
1025     // has reversed direction and wants to de-select the last selected row.
1026     selectMultiRowsNext() {
1027         if (!this.lastSelectedIndex) { return; }
1028         const pos = this.getRowPosition(this.lastSelectedIndex);
1029         const selectedIndexes = this.rowSelector.selected();
1030
1031         const promise = // load the next page of data if needed
1032             (pos === (this.pager.offset + this.pager.limit - 1)) ?
1033             this.toNextPage() : Promise.resolve();
1034
1035         promise.then(
1036             ok => {
1037                 const row = this.dataSource.data[pos + 1];
1038                 const newIndex = this.getRowIndex(row);
1039                 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1040                     // Next row is already selected.  User is reversing direction.
1041                     this.rowSelector.deselect(this.lastSelectedIndex);
1042                     this.lastSelectedIndex = newIndex;
1043                 } else {
1044                     this.selectMultipleRows(selectedIndexes.concat(newIndex));
1045                 }
1046             },
1047             err => {}
1048         );
1049     }
1050
1051     getFirstRowInPage(): any {
1052         return this.dataSource.data[this.pager.offset];
1053     }
1054
1055     getLastRowInPage(): any {
1056         return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
1057     }
1058
1059     selectFirstRow() {
1060         this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
1061     }
1062
1063     selectLastRow() {
1064         this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
1065     }
1066
1067     selectRowsInPage() {
1068         const rows = this.dataSource.getPageOfRows(this.pager);
1069         const indexes = rows.map(r => this.getRowIndex(r));
1070         this.rowSelector.select(indexes);
1071         this.selectRowsInPageEmitter.emit();
1072     }
1073
1074     toPrevPage(): Promise<any> {
1075         if (this.pager.isFirstPage()) {
1076             return Promise.reject('on first');
1077         }
1078         // temp ignore pager events since we're calling requestPage manually.
1079         this.ignorePager();
1080         this.pager.decrement();
1081         this.listenToPager();
1082         return this.dataSource.requestPage(this.pager);
1083     }
1084
1085     toNextPage(): Promise<any> {
1086         if (this.pager.isLastPage()) {
1087             return Promise.reject('on last');
1088         }
1089         // temp ignore pager events since we're calling requestPage manually.
1090         this.ignorePager();
1091         this.pager.increment();
1092         this.listenToPager();
1093         return this.dataSource.requestPage(this.pager);
1094     }
1095
1096     getAllRows(): Promise<any> {
1097         const pager = new Pager();
1098         pager.offset = 0;
1099         pager.limit = MAX_ALL_ROW_COUNT;
1100         return this.dataSource.requestPage(pager);
1101     }
1102
1103     // Returns a key/value pair object of visible column data as text.
1104     getRowAsFlatText(row: any): any {
1105         const flatRow = {};
1106         this.columnSet.displayColumns().forEach(col => {
1107             flatRow[col.name] =
1108                 this.getColumnTextContent(row, col);
1109         });
1110         return flatRow;
1111     }
1112
1113     getAllRowsAsText(): Observable<any> {
1114         return Observable.create(observer => {
1115             this.getAllRows().then(ok => {
1116                 this.dataSource.data.forEach(row => {
1117                     observer.next(this.getRowAsFlatText(row));
1118                 });
1119                 observer.complete();
1120             });
1121         });
1122     }
1123
1124     removeFilters(): void {
1125         this.dataSource.filters = {};
1126         this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
1127         this.filterControls.forEach(ctl => ctl.reset());
1128         this.reload();
1129     }
1130     filtersSet(): boolean {
1131         return Object.keys(this.dataSource.filters).length > 0;
1132     }
1133
1134     gridToCsv(): Promise<string> {
1135
1136         let csvStr = '';
1137         const columns = this.columnSet.displayColumns();
1138
1139         // CSV header
1140         columns.forEach(col => {
1141             csvStr += this.valueToCsv(col.label),
1142             csvStr += ',';
1143         });
1144
1145         csvStr = csvStr.replace(/,$/, '\n');
1146
1147         return new Promise(resolve => {
1148             this.getAllRowsAsText().subscribe(
1149                 row => {
1150                     columns.forEach(col => {
1151                         csvStr += this.valueToCsv(row[col.name]);
1152                         csvStr += ',';
1153                     });
1154                     csvStr = csvStr.replace(/,$/, '\n');
1155                 },
1156                 err => {},
1157                 ()  => resolve(csvStr)
1158             );
1159         });
1160     }
1161
1162
1163     // prepares a string for inclusion within a CSV document
1164     // by escaping commas and quotes and removing newlines.
1165     valueToCsv(str: string): string {
1166         str = '' + str;
1167         if (!str) { return ''; }
1168         str = str.replace(/\n/g, '');
1169         if (str.match(/\,/) || str.match(/"/)) {
1170             str = str.replace(/"/g, '""');
1171             str = '"' + str + '"';
1172         }
1173         return str;
1174     }
1175
1176     generateColumns() {
1177         if (!this.columnSet.idlClass) { return; }
1178
1179         const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
1180         const specifiedColumnOrder = this.autoGeneratedColumnOrder ?
1181             this.autoGeneratedColumnOrder.split(/,/) : [];
1182
1183         // generate columns for all non-virtual fields on the IDL class
1184         const fields = this.idl.classes[this.columnSet.idlClass].fields
1185             .filter(field => !field.virtual);
1186
1187         const sortedFields = this.autoGeneratedColumnOrder ?
1188             this.idl.sortIdlFields(fields, this.autoGeneratedColumnOrder.split(/,/)) :
1189             fields;
1190
1191         sortedFields.forEach(field => {
1192             if (!this.ignoredFields.filter(ignored => ignored === field.name).length) {
1193                 const col = new GridColumn();
1194                 col.name = field.name;
1195                 col.label = field.label || field.name;
1196                 col.idlFieldDef = field;
1197                 col.idlClass = this.columnSet.idlClass;
1198                 col.datatype = field.datatype;
1199                 col.isIndex = (field.name === pkeyField);
1200                 col.isAuto = true;
1201
1202                 if (this.showDeclaredFieldsOnly) {
1203                     col.hidden = true;
1204                 }
1205
1206                 this.columnSet.add(col);
1207             }
1208         });
1209     }
1210
1211     saveGridConfig(): Promise<any> {
1212         if (!this.persistKey) {
1213             throw new Error('Grid persistKey required to save columns');
1214         }
1215         const conf = new GridPersistConf();
1216         conf.version = 2;
1217         conf.limit = this.pager.limit;
1218         conf.columns = this.columnSet.compileSaveObject();
1219
1220         // Avoid persisting group visibility since that may change
1221         // with the addition of new columns.  Always calculate that
1222         // in real time.
1223         conf.hideToolbarActions = this.toolbarActions
1224             .filter(action => !action.isGroup && action.hidden)
1225             .map(action => action.label);
1226
1227         return this.store.setItem('eg.grid.' + this.persistKey, conf);
1228     }
1229
1230     // TODO: saveGridConfigAsOrgSetting(...)
1231
1232     getGridConfig(persistKey: string): Promise<GridPersistConf> {
1233         if (!persistKey) { return Promise.resolve(null); }
1234         return this.store.getItem('eg.grid.' + persistKey);
1235     }
1236
1237     columnHasTextGenerator(col: GridColumn): boolean {
1238         return this.cellTextGenerator && col.name in this.cellTextGenerator;
1239     }
1240 }
1241
1242
1243 // Actions apply to specific rows
1244 export class GridToolbarAction {
1245     label: string;
1246     onClick: EventEmitter<any []>;
1247     action: (rows: any[]) => any; // DEPRECATED
1248     group: string;
1249     disabled: boolean;
1250     isGroup: boolean; // used for group placeholder entries
1251     isSeparator: boolean;
1252     disableOnRows: (rows: any[]) => boolean;
1253     hidden?: boolean;
1254 }
1255
1256 // Buttons are global actions
1257 export class GridToolbarButton {
1258     label: string;
1259     onClick: EventEmitter<any []>;
1260     action: () => any; // DEPRECATED
1261     disabled: boolean;
1262 }
1263
1264 export class GridToolbarCheckbox {
1265     label: string;
1266     isChecked: boolean;
1267     onChange: EventEmitter<boolean>;
1268 }
1269
1270 export class GridDataSource {
1271
1272     data: any[];
1273     sort: any[];
1274     filters: Object;
1275     allRowsRetrieved: boolean;
1276     requestingData: boolean;
1277     retrievalError: boolean;
1278     getRows: (pager: Pager, sort: any[]) => Observable<any>;
1279
1280     constructor() {
1281         this.sort = [];
1282         this.filters = {};
1283         this.reset();
1284     }
1285
1286     reset() {
1287         this.data = [];
1288         this.allRowsRetrieved = false;
1289     }
1290
1291     // called from the template -- no data fetching
1292     getPageOfRows(pager: Pager): any[] {
1293         if (this.data) {
1294             return this.data.slice(
1295                 pager.offset, pager.limit + pager.offset
1296             ).filter(row => row !== undefined);
1297         }
1298         return [];
1299     }
1300
1301     // called on initial component load and user action (e.g. paging, sorting).
1302     requestPage(pager: Pager): Promise<any> {
1303
1304         if (
1305             this.getPageOfRows(pager).length === pager.limit
1306             // already have all data
1307             || this.allRowsRetrieved
1308             // have no way to get more data.
1309             || !this.getRows
1310         ) {
1311             return Promise.resolve();
1312         }
1313
1314         // If we have to call out for data, set inFetch
1315         this.requestingData = true;
1316         this.retrievalError = false;
1317
1318         return new Promise((resolve, reject) => {
1319             let idx = pager.offset;
1320             return this.getRows(pager, this.sort).subscribe(
1321                 row => {
1322                     this.data[idx++] = row;
1323                     // not updating this.requestingData, as having
1324                     // retrieved one row doesn't mean we're done
1325                     this.retrievalError = false;
1326                 },
1327                 err => {
1328                     console.error(`grid getRows() error ${err}`);
1329                     this.requestingData = false;
1330                     this.retrievalError = true;
1331                     reject(err);
1332                 },
1333                 ()  => {
1334                     this.checkAllRetrieved(pager, idx);
1335                     this.requestingData = false;
1336                     this.retrievalError = false;
1337                     resolve(null);
1338                 }
1339             );
1340         });
1341     }
1342
1343     // See if the last getRows() call resulted in the final set of data.
1344     checkAllRetrieved(pager: Pager, idx: number) {
1345         if (this.allRowsRetrieved) { return; }
1346
1347         if (idx === 0 || idx < (pager.limit + pager.offset)) {
1348             // last query returned nothing or less than one page.
1349             // confirm we have all of the preceding pages.
1350             if (!this.data.includes(undefined)) {
1351                 this.allRowsRetrieved = true;
1352                 pager.resultCount = this.data.length;
1353             }
1354         }
1355     }
1356 }
1357
1358