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