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