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