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