LP#1819179: Angular value formatter gets link smarts
[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} 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 def = {
546                 name: sort.name,
547                 dir: sort.dir,
548                 col: this.columnSet.getColByName(sort.name)
549             };
550
551             if (!def.col.comparator) {
552                 def.col.comparator = (a, b) => {
553                     if (a < b) { return -1; }
554                     if (a > b) { return 1; }
555                     return 0;
556                 };
557             }
558
559             return def;
560         });
561
562         this.dataSource.data.sort((rowA, rowB) => {
563
564             for (let idx = 0; idx < sortDefs.length; idx++) {
565                 const sortDef = sortDefs[idx];
566
567                 const valueA = this.getRowColumnValue(rowA, sortDef.col);
568                 const valueB = this.getRowColumnValue(rowB, sortDef.col);
569
570                 if (valueA === '' && valueB === '') { continue; }
571                 if (valueA === '' && valueB !== '') { return 1; }
572                 if (valueA !== '' && valueB === '') { return -1; }
573
574                 const diff = sortDef.col.comparator(valueA, valueB);
575                 if (diff === 0) { continue; }
576
577                 console.log(valueA, valueB, diff);
578
579                 return sortDef.dir === 'DESC' ? -diff : diff;
580             }
581
582             return 0; // No differences found.
583         });
584     }
585
586     getRowIndex(row: any): any {
587         const col = this.columnSet.indexColumn;
588         if (!col) {
589             throw new Error('grid index column required');
590         }
591         return this.getRowColumnValue(row, col);
592     }
593
594     // Returns position in the data source array of the row with
595     // the provided index.
596     getRowPosition(index: any): number {
597         // for-loop for early exit
598         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
599             const row = this.dataSource.data[idx];
600             if (row !== undefined && index === this.getRowIndex(row)) {
601                 return idx;
602             }
603         }
604     }
605
606     // Return the row with the provided index.
607     getRowByIndex(index: any): any {
608         for (let idx = 0; idx < this.dataSource.data.length; idx++) {
609             const row = this.dataSource.data[idx];
610             if (row !== undefined && index === this.getRowIndex(row)) {
611                 return row;
612             }
613         }
614     }
615
616     // Returns all selected rows, regardless of whether they are
617     // currently visible in the grid display.
618     getSelectedRows(): any[] {
619         const selected = [];
620         this.rowSelector.selected().forEach(index => {
621             const row = this.getRowByIndex(index);
622             if (row) {
623                 selected.push(row);
624             }
625         });
626         return selected;
627     }
628
629     getRowColumnValue(row: any, col: GridColumn): string {
630         let val;
631
632         if (col.path) {
633             val = this.nestedItemFieldValue(row, col);
634         } else if (col.name in row) {
635             val = this.getObjectFieldValue(row, col.name);
636         }
637
638         return this.format.transform({
639             value: val,
640             idlClass: col.idlClass,
641             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
642             datatype: col.datatype,
643             datePlusTime: Boolean(col.datePlusTime)
644         });
645     }
646
647     getObjectFieldValue(obj: any, name: string): any {
648         if (typeof obj[name] === 'function') {
649             return obj[name]();
650         } else {
651             return obj[name];
652         }
653     }
654
655     nestedItemFieldValue(obj: any, col: GridColumn): string {
656
657         let idlField;
658         let idlClassDef;
659         const original = obj;
660         const steps = col.path.split('.');
661
662         for (let i = 0; i < steps.length; i++) {
663             const step = steps[i];
664
665             if (obj === null || obj === undefined || typeof obj !== 'object') {
666                 // We have run out of data to step through before
667                 // reaching the end of the path.  Conclude fleshing via
668                 // callback if provided then exit.
669                 if (col.flesher && obj !== undefined) {
670                     return col.flesher(obj, col, original);
671                 }
672                 return obj;
673             }
674
675             const class_ = obj.classname;
676             if (class_ && (idlClassDef = this.idl.classes[class_])) {
677                 idlField = idlClassDef.field_map[step];
678             }
679
680             obj = this.getObjectFieldValue(obj, step);
681         }
682
683         // We found a nested IDL object which may or may not have
684         // been configured as a top-level column.  Flesh the column
685         // metadata with our newly found IDL info.
686         if (idlField) {
687             if (!col.datatype) {
688                 col.datatype = idlField.datatype;
689             }
690             if (!col.idlFieldDef) {
691                 idlField = col.idlFieldDef;
692             }
693             if (!col.idlClass) {
694                 col.idlClass = idlClassDef.name;
695             }
696             if (!col.label) {
697                 col.label = idlField.label || idlField.name;
698             }
699         }
700
701         return obj;
702     }
703
704
705     getColumnTextContent(row: any, col: GridColumn): string {
706         if (col.cellTemplate) {
707             // TODO
708             // Extract the text content from the rendered template.
709         } else {
710             return this.getRowColumnValue(row, col);
711         }
712     }
713
714     selectOneRow(index: any) {
715         this.rowSelector.clear();
716         this.rowSelector.select(index);
717         this.lastSelectedIndex = index;
718     }
719
720     // selects or deselects an item, without affecting the others.
721     // returns true if the item is selected; false if de-selected.
722     toggleSelectOneRow(index: any) {
723         if (this.rowSelector.contains(index)) {
724             this.rowSelector.deselect(index);
725             return false;
726         }
727
728         this.rowSelector.select(index);
729         return true;
730     }
731
732     selectRowByPos(pos: number) {
733         const row = this.dataSource.data[pos];
734         if (row) {
735             this.selectOneRow(this.getRowIndex(row));
736         }
737     }
738
739     selectPreviousRow() {
740         if (!this.lastSelectedIndex) { return; }
741         const pos = this.getRowPosition(this.lastSelectedIndex);
742         if (pos === this.pager.offset) {
743             this.toPrevPage().then(ok => this.selectLastRow(), err => {});
744         } else {
745             this.selectRowByPos(pos - 1);
746         }
747     }
748
749     selectNextRow() {
750         if (!this.lastSelectedIndex) { return; }
751         const pos = this.getRowPosition(this.lastSelectedIndex);
752         if (pos === (this.pager.offset + this.pager.limit - 1)) {
753             this.toNextPage().then(ok => this.selectFirstRow(), err => {});
754         } else {
755             this.selectRowByPos(pos + 1);
756         }
757     }
758
759     selectFirstRow() {
760         this.selectRowByPos(this.pager.offset);
761     }
762
763     selectLastRow() {
764         this.selectRowByPos(this.pager.offset + this.pager.limit - 1);
765     }
766
767     toPrevPage(): Promise<any> {
768         if (this.pager.isFirstPage()) {
769             return Promise.reject('on first');
770         }
771         // temp ignore pager events since we're calling requestPage manually.
772         this.ignorePager();
773         this.pager.decrement();
774         this.listenToPager();
775         return this.dataSource.requestPage(this.pager);
776     }
777
778     toNextPage(): Promise<any> {
779         if (this.pager.isLastPage()) {
780             return Promise.reject('on last');
781         }
782         // temp ignore pager events since we're calling requestPage manually.
783         this.ignorePager();
784         this.pager.increment();
785         this.listenToPager();
786         return this.dataSource.requestPage(this.pager);
787     }
788
789     getAllRows(): Promise<any> {
790         const pager = new Pager();
791         pager.offset = 0;
792         pager.limit = MAX_ALL_ROW_COUNT;
793         return this.dataSource.requestPage(pager);
794     }
795
796     // Returns a key/value pair object of visible column data as text.
797     getRowAsFlatText(row: any): any {
798         const flatRow = {};
799         this.columnSet.displayColumns().forEach(col => {
800             flatRow[col.name] =
801                 this.getColumnTextContent(row, col);
802         });
803         return flatRow;
804     }
805
806     getAllRowsAsText(): Observable<any> {
807         return Observable.create(observer => {
808             this.getAllRows().then(ok => {
809                 this.dataSource.data.forEach(row => {
810                     observer.next(this.getRowAsFlatText(row));
811                 });
812                 observer.complete();
813             });
814         });
815     }
816
817     gridToCsv(): Promise<string> {
818
819         let csvStr = '';
820         const columns = this.columnSet.displayColumns();
821
822         // CSV header
823         columns.forEach(col => {
824             csvStr += this.valueToCsv(col.label),
825             csvStr += ',';
826         });
827
828         csvStr = csvStr.replace(/,$/, '\n');
829
830         return new Promise(resolve => {
831             this.getAllRowsAsText().subscribe(
832                 row => {
833                     columns.forEach(col => {
834                         csvStr += this.valueToCsv(row[col.name]);
835                         csvStr += ',';
836                     });
837                     csvStr = csvStr.replace(/,$/, '\n');
838                 },
839                 err => {},
840                 ()  => resolve(csvStr)
841             );
842         });
843     }
844
845
846     // prepares a string for inclusion within a CSV document
847     // by escaping commas and quotes and removing newlines.
848     valueToCsv(str: string): string {
849         str = '' + str;
850         if (!str) { return ''; }
851         str = str.replace(/\n/g, '');
852         if (str.match(/\,/) || str.match(/"/)) {
853             str = str.replace(/"/g, '""');
854             str = '"' + str + '"';
855         }
856         return str;
857     }
858
859     generateColumns() {
860         if (!this.columnSet.idlClass) { return; }
861
862         const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
863
864         // generate columns for all non-virtual fields on the IDL class
865         this.idl.classes[this.columnSet.idlClass].fields
866         .filter(field => !field.virtual)
867         .forEach(field => {
868             const col = new GridColumn();
869             col.name = field.name;
870             col.label = field.label || field.name;
871             col.idlFieldDef = field;
872             col.idlClass = this.columnSet.idlClass;
873             col.datatype = field.datatype;
874             col.isIndex = (field.name === pkeyField);
875             col.isAuto = true;
876
877             if (this.showLinkSelectors) {
878                 const selector = this.idl.getLinkSelector(
879                     this.columnSet.idlClass, field.name);
880                 if (selector) {
881                     col.path = field.name + '.' + selector;
882                 }
883             }
884
885             this.columnSet.add(col);
886         });
887     }
888
889     saveGridConfig(): Promise<any> {
890         if (!this.persistKey) {
891             throw new Error('Grid persistKey required to save columns');
892         }
893         const conf = new GridPersistConf();
894         conf.version = 2;
895         conf.limit = this.pager.limit;
896         conf.columns = this.columnSet.compileSaveObject();
897
898         return this.store.setItem('eg.grid.' + this.persistKey, conf);
899     }
900
901     // TODO: saveGridConfigAsOrgSetting(...)
902
903     getGridConfig(persistKey: string): Promise<GridPersistConf> {
904         if (!persistKey) { return Promise.resolve(null); }
905         return this.store.getItem('eg.grid.' + persistKey);
906     }
907 }
908
909
910 // Actions apply to specific rows
911 export class GridToolbarAction {
912     label: string;
913     action: (rows: any[]) => any;
914     disableOnRows: (rows: any[]) => boolean;
915 }
916
917 // Buttons are global actions
918 export class GridToolbarButton {
919     label: string;
920     action: () => any;
921     disabled: boolean;
922 }
923
924 export class GridToolbarCheckbox {
925     label: string;
926     onChange: (checked: boolean) => void;
927 }
928
929 export class GridDataSource {
930
931     data: any[];
932     sort: any[];
933     allRowsRetrieved: boolean;
934     getRows: (pager: Pager, sort: any[]) => Observable<any>;
935
936     constructor() {
937         this.sort = [];
938         this.reset();
939     }
940
941     reset() {
942         this.data = [];
943         this.allRowsRetrieved = false;
944     }
945
946     // called from the template -- no data fetching
947     getPageOfRows(pager: Pager): any[] {
948         if (this.data) {
949             return this.data.slice(
950                 pager.offset, pager.limit + pager.offset
951             ).filter(row => row !== undefined);
952         }
953         return [];
954     }
955
956     // called on initial component load and user action (e.g. paging, sorting).
957     requestPage(pager: Pager): Promise<any> {
958
959         if (
960             this.getPageOfRows(pager).length === pager.limit
961             // already have all data
962             || this.allRowsRetrieved
963             // have no way to get more data.
964             || !this.getRows
965         ) {
966             return Promise.resolve();
967         }
968
969         return new Promise((resolve, reject) => {
970             let idx = pager.offset;
971             return this.getRows(pager, this.sort).subscribe(
972                 row => this.data[idx++] = row,
973                 err => {
974                     console.error(`grid getRows() error ${err}`);
975                     reject(err);
976                 },
977                 ()  => {
978                     this.checkAllRetrieved(pager, idx);
979                     resolve();
980                 }
981             );
982         });
983     }
984
985     // See if the last getRows() call resulted in the final set of data.
986     checkAllRetrieved(pager: Pager, idx: number) {
987         if (this.allRowsRetrieved) { return; }
988
989         if (idx === 0 || idx < (pager.limit + pager.offset)) {
990             // last query returned nothing or less than one page.
991             // confirm we have all of the preceding pages.
992             if (!this.data.includes(undefined)) {
993                 this.allRowsRetrieved = true;
994                 pager.resultCount = this.data.length;
995             }
996         }
997     }
998 }
999
1000