2 * Collection of grid related classses and interfaces.
4 import {TemplateRef, EventEmitter, QueryList} from '@angular/core';
5 import {Observable, Subscription} from 'rxjs';
6 import {IdlService, IdlObject} from '@eg/core/idl.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {ServerStoreService} from '@eg/core/server-store.service';
9 import {FormatService} from '@eg/core/format.service';
10 import {Pager} from '@eg/share/util/pager';
11 import {GridFilterControlComponent} from './grid-filter-control.component';
13 const MAX_ALL_ROW_COUNT = 10000;
15 export class GridColumn {
25 // IDL class of the object which contains this field.
26 // Not to be confused with the class of a linked object.
30 datePlusTime: boolean;
32 timezoneContextOrg: number;
33 cellTemplate: TemplateRef<any>;
34 dateOnlyIntervalField: string;
38 isDragTarget: boolean;
40 isFilterable: boolean;
42 isMultiSortable: boolean;
43 disableTooltip: boolean;
44 asyncSupportsEmptyTermClick: boolean;
45 comparator: (valueA: any, valueB: any) => number;
48 // True if the column was automatically generated.
53 filterOperator: string;
54 filterInputDisabled: boolean;
55 filterIncludeOrgAncestors: boolean;
56 filterIncludeOrgDescendants: boolean;
58 flesher: (obj: any, col: GridColumn, item: any) => any;
60 getCellContext(row: any) {
64 userContext: this.cellContext
73 this.isFiltered = false;
74 this.filterValue = undefined;
75 this.filterOperator = '=';
76 this.filterInputDisabled = false;
77 this.filterIncludeOrgAncestors = false;
78 this.filterIncludeOrgDescendants = false;
82 this.isFiltered = f.isFiltered;
83 this.filterValue = f.filterValue;
84 this.filterOperator = f.filterOperator;
85 this.filterInputDisabled = f.filterInputDisabled;
86 this.filterIncludeOrgAncestors = f.filterIncludeOrgAncestors;
87 this.filterIncludeOrgDescendants = f.IncludeOrgDescendants;
90 getIdlId(value: any) {
91 const obj: IdlObject = (value as unknown) as IdlObject;
97 'isFiltered': this.isFiltered,
98 'filterValue': typeof this.filterValue === 'object' ? this.getIdlId(this.filterValue) : this.filterValue,
99 'filterOperator': this.filterOperator,
100 'filterInputDisabled': this.filterInputDisabled,
101 'filterIncludeOrgAncestors': this.filterIncludeOrgAncestors,
102 'filterIncludeOrgDescendants': this.filterIncludeOrgDescendants
106 clone(): GridColumn {
107 const col = new GridColumn();
109 col.name = this.name;
110 col.path = this.path;
111 col.label = this.label;
112 col.flex = this.flex;
113 col.required = this.required;
114 col.hidden = this.hidden;
115 col.asyncSupportsEmptyTermClick = this.asyncSupportsEmptyTermClick;
116 col.isIndex = this.isIndex;
117 col.cellTemplate = this.cellTemplate;
118 col.cellContext = this.cellContext;
119 col.disableTooltip = this.disableTooltip;
120 col.isSortable = this.isSortable;
121 col.isFilterable = this.isFilterable;
122 col.isMultiSortable = this.isMultiSortable;
123 col.datatype = this.datatype;
124 col.datePlusTime = this.datePlusTime;
125 col.ternaryBool = this.ternaryBool;
126 col.timezoneContextOrg = this.timezoneContextOrg;
127 col.idlClass = this.idlClass;
128 col.isAuto = this.isAuto;
135 export class GridColumnSet {
136 columns: GridColumn[];
138 indexColumn: GridColumn;
140 isFilterable: boolean;
141 isMultiSortable: boolean;
142 stockVisible: string[];
144 defaultHiddenFields: string[];
145 defaultVisibleFields: string[];
147 constructor(idl: IdlService, idlClass?: string) {
150 this.stockVisible = [];
151 this.idlClass = idlClass;
154 add(col: GridColumn) {
156 if (col.path && col.path.match(/\*$/)) {
157 return this.generateWildcardColumns(col);
160 this.applyColumnDefaults(col);
162 if (!this.insertColumn(col)) {
163 // Column was rejected as a duplicate.
167 if (col.isIndex) { this.indexColumn = col; }
169 // track which fields are visible on page load.
171 this.stockVisible.push(col.name);
174 this.applyColumnSortability(col);
175 this.applyColumnFilterability(col);
178 generateWildcardColumns(col: GridColumn) {
180 const dotpath = col.path.replace(/\.?\*$/, '');
181 let classObj:IdlObject, idlField:any;
184 classObj = this.idl.classes[col.idlClass];
186 classObj = this.idl.classes[this.idlClass];
189 if (!classObj) { return; }
191 const pathParts = dotpath.split(/\./);
194 // find the IDL class definition for the last element in the
195 // path before the .*
196 // An empty pathParts means expand the root class
197 pathParts.forEach((part, pathIdx) => {
198 //oldField = idlField;
199 idlField = classObj.field_map[part];
201 // unless we're at the end of the list, this field should
202 // link to another class.
203 if (idlField && idlField['class'] && (
204 idlField.datatype === 'link' || idlField.datatype === 'org_unit')) {
205 classObj = this.idl.classes[idlField['class']];
208 if (pathIdx < (pathParts.length - 1)) {
209 // we ran out of classes to hop through before
210 // we ran out of path components
211 console.warn('Grid: invalid IDL path: ' + dotpath);
218 'Grid: wildcard path does not resolve to an object:' + dotpath);
222 classObj.fields.forEach((field:any) => {
224 // Only show wildcard fields where we have data to show
225 // Virtual and un-fleshed links will not have any data.
227 field.datatype === 'link' || field.datatype === 'org_unit') {
231 const newCol = col.clone();
232 newCol.isAuto = true;
233 newCol.path = dotpath ? dotpath + '.' + field.name : field.name;
234 newCol.label = dotpath ? classObj.label + ': ' + field.label : field.label;
235 newCol.datatype = field.datatype;
237 // Avoid including the class label prefix in the main grid
238 // header display so it doesn't take up so much horizontal space.
239 newCol.headerLabel = field.label;
245 // Returns true if the new column was inserted, false otherwise.
246 // Declared columns take precedence over auto-generated columns
247 // when collisions occur.
248 // Declared columns are inserted in front of auto columns.
249 insertColumn(col: GridColumn): boolean {
252 if (this.getColByName(col.name) || this.getColByPath(col.path)) {
253 // New auto-generated column conflicts with existing
257 // No collisions. Add to the end of the list
258 this.columns.push(col);
263 // Adding a declared column.
266 for (let idx = 0; idx < this.columns.length; idx++) {
267 const testCol = this.columns[idx];
268 if (testCol.name === col.name) { // match found
269 if (testCol.isAuto) {
270 // new column takes precedence, remove the existing column.
271 this.columns.splice(idx, 1);
274 // New column does not take precedence. Avoid
281 // Delcared columns are inserted just before the first auto-column
282 for (let idx = 0; idx < this.columns.length; idx++) {
283 const testCol = this.columns[idx];
284 if (testCol.isAuto) {
286 this.columns.unshift(col);
288 this.columns.splice(idx, 0, col);
294 // No insertion point found. Toss the new column on the end.
295 this.columns.push(col);
299 getColByName(name: string): GridColumn {
300 return this.columns.filter(c => c.name === name)[0];
303 getColByPath(path: string): GridColumn {
305 return this.columns.filter(c => c.path === path)[0];
309 idlInfoFromDotpath(dotpath: string): any {
310 if (!dotpath || !this.idlClass) { return null; }
315 let nextIdlClass = this.idl.classes[this.idlClass];
317 const pathParts = dotpath.split(/\./);
319 for (let i = 0; i < pathParts.length; i++) {
321 const part = pathParts[i];
322 idlParent = idlField;
323 idlClass = nextIdlClass;
324 idlField = idlClass.field_map[part];
326 if (!idlField) { return null; } // invalid IDL path
328 if (i === pathParts.length - 1) {
329 // No more links to process.
333 if (idlField['class'] && (
334 idlField.datatype === 'link' ||
335 idlField.datatype === 'org_unit')) {
336 // The link class on the current field refers to the
337 // class of the link destination, not the current field.
338 // Mark it for processing during the next iteration.
339 nextIdlClass = this.idl.classes[idlField['class']];
344 idlParent: idlParent,
352 this.columns.forEach(col => {
356 col.visible = this.stockVisible.includes(col.name);
360 applyColumnDefaults(col: GridColumn) {
362 if (!col.idlFieldDef) {
363 const idlInfo = this.idlInfoFromDotpath(col.path || col.name);
365 col.idlFieldDef = idlInfo.idlField;
366 col.idlClass = idlInfo.idlClass.name;
368 col.datatype = col.idlFieldDef.datatype;
371 col.label = col.idlFieldDef.label || col.idlFieldDef.name;
376 if (!col.name) { col.name = col.path; }
377 if (!col.flex) { col.flex = 2; }
378 if (!col.align) { col.align = 'left'; }
379 if (!col.label) { col.label = col.name; }
380 if (!col.datatype) { col.datatype = 'text'; }
381 if (!col.isAuto) { col.headerLabel = col.label; }
383 col.visible = !col.hidden;
386 applyColumnSortability(col: GridColumn) {
387 // column sortability defaults to the sortability of the column set.
388 if (col.isSortable === undefined && this.isSortable) {
389 col.isSortable = true;
392 if (col.isMultiSortable === undefined && this.isMultiSortable) {
393 col.isMultiSortable = true;
396 if (col.isMultiSortable) {
397 col.isSortable = true;
400 applyColumnFilterability(col: GridColumn) {
401 // column filterability defaults to the afilterability of the column set.
402 if (col.isFilterable === undefined && this.isFilterable) {
403 col.isFilterable = true;
407 displayColumns(): GridColumn[] {
408 return this.columns.filter(c => c.visible);
411 // Sorted visible columns followed by sorted non-visible columns.
412 // Note we don't sort this.columns directly as it would impact
413 // grid column display ordering.
414 sortForColPicker(): GridColumn[] {
415 const visible = this.columns.filter(c => c.visible);
416 const invisible = this.columns.filter(c => !c.visible);
418 visible.sort((a, b) => a.label < b.label ? -1 : 1);
419 invisible.sort((a, b) => a.label < b.label ? -1 : 1);
421 return visible.concat(invisible);
424 requiredColumns(): GridColumn[] {
425 const visible = this.displayColumns();
426 return visible.concat(
427 this.columns.filter(c => c.required && !c.visible));
430 insertBefore(source: GridColumn, target: GridColumn) {
433 this.columns.forEach((col, idx) => {
434 if (col.name === target.name) { targetIdx = idx; }});
436 this.columns.forEach((col, idx) => {
437 if (col.name === source.name) { sourceIdx = idx; }});
439 if (sourceIdx >= 0) {
440 this.columns.splice(sourceIdx, 1);
443 this.columns.splice(targetIdx, 0, source);
446 // Move visible columns to the front of the list.
447 moveVisibleToFront() {
448 const newCols = this.displayColumns();
449 this.columns.forEach(col => {
450 if (!col.visible) { newCols.push(col); }});
451 this.columns = newCols;
454 moveColumn(col: GridColumn, diff: number) {
455 let srcIdx:number, targetIdx:number;
457 this.columns.forEach((c, i) => {
458 if (c.name === col.name) { srcIdx = i; }
461 targetIdx = srcIdx + diff;
464 } else if (targetIdx >= this.columns.length) {
465 // Target index follows the last visible column.
467 this.columns.forEach((c, idx) => {
468 if (c.visible) { lastVisible = idx; }
471 // When moving a column (down) causes one or more
472 // visible columns to shuffle forward, our column
473 // moves into the slot of the last visible column.
474 // Otherwise, put it into the slot directly following
475 // the last visible column.
476 targetIdx = srcIdx <= lastVisible ? lastVisible : lastVisible + 1;
479 // Splice column out of old position, insert at new position.
480 this.columns.splice(srcIdx, 1);
481 this.columns.splice(targetIdx, 0, col);
484 compileSaveObject(): GridColumnPersistConf[] {
485 // only store information about visible columns.
486 // scrunch the data down to just the needed info.
487 return this.displayColumns().map(col => {
488 const c: GridColumnPersistConf = {name : col.name};
489 if (col.align !== 'left') { c.align = col.align; }
490 if (col.flex !== 2) { c.flex = Number(col.flex); }
491 if (Number(col.sort)) { c.sort = Number(col.sort); }
496 applyColumnSettings(conf: GridColumnPersistConf[]) {
498 if (!conf || conf.length === 0) {
499 // No configuration is available, but we have a list of
500 // fields to show or hide by default
502 if (this.defaultVisibleFields) {
503 this.columns.forEach(col => {
504 if (this.defaultVisibleFields.includes(col.name)) {
511 } else if (this.defaultHiddenFields) {
512 this.defaultHiddenFields.forEach(name => {
513 const col = this.getColByName(name);
525 conf.forEach(colConf => {
526 const col = this.getColByName(colConf.name);
527 if (!col) { return; } // no such column in this grid.
530 if (colConf.align) { col.align = colConf.align; }
531 if (colConf.flex) { col.flex = Number(colConf.flex); }
532 if (colConf.sort) { col.sort = Number(colConf.sort); }
534 // Add to new columns array, avoid dupes.
535 if (newCols.filter(c => c.name === col.name).length === 0) {
540 // columns which are not expressed within the saved
541 // configuration are marked as non-visible and
542 // appended to the end of the new list of columns.
543 this.columns.forEach(c => {
544 if (conf.filter(cf => cf.name === c.name).length === 0) {
550 this.columns = newCols;
554 // Maps colunm names to functions which return plain text values for
555 // each mapped column on a given row. This is primarily useful for
556 // generating print-friendly content for grid cells rendered via
559 // USAGE NOTE: Since a cellTemplate can be passed arbitrary context
560 // but a GridCellTextGenerator only gets the row object,
561 // if it's important to include content that's not available
562 // by default in the row object, you may want to stick
563 // it in the row object as an additional attribute.
565 export interface GridCellTextGenerator {
566 [columnName: string]: (row: any) => string;
569 export class GridRowSelector {
570 indexes: {[string: string]: boolean};
572 // Track these so we can emit the selectionChange event
573 // only when the selection actually changes.
574 previousSelection: string[] = [];
576 // Emits the selected indexes on selection change
577 selectionChange: EventEmitter<string[]> = new EventEmitter<string[]>();
583 // Returns true if all of the requested indexes exist in the selector.
584 contains(index: string | string[]): boolean {
585 const indexes = [].concat(index);
586 for (let i = 0; i < indexes.length; i++) { // early exit
587 if (!this.indexes[indexes[i]]) {
595 const keys = this.selected();
597 if (keys.length === this.previousSelection.length &&
598 this.contains(this.previousSelection)) {
599 return; // No change has occurred
602 this.previousSelection = keys;
603 this.selectionChange.emit(keys);
606 select(index: string | string[]) {
607 const indexes = [].concat(index);
608 indexes.forEach(i => this.indexes[i] = true);
612 deselect(index: string | string[]) {
613 const indexes = [].concat(index);
614 indexes.forEach(i => delete this.indexes[i]);
618 toggle(index: string) {
619 if (this.indexes[index]) {
620 this.deselect(index);
626 selected(): string[] {
627 return Object.keys(this.indexes);
631 return this.selected().length === 0;
640 export interface GridRowFlairEntry {
641 icon: string; // name of material icon
642 title?: string; // tooltip string
645 export class GridColumnPersistConf {
652 export class GridPersistConf {
655 columns: GridColumnPersistConf[];
656 hideToolbarActions: string[];
659 export class GridContext {
664 isFilterable: boolean;
665 initialFilterValues: {[field: string]: string};
666 allowNamedFilterSets: boolean;
667 migrateLegacyFilterSets: string;
668 stickyGridHeader: boolean;
669 isMultiSortable: boolean;
670 useLocalSort: boolean;
672 disableMultiSelect: boolean;
673 disableSelect: boolean;
674 dataSource: GridDataSource;
675 columnSet: GridColumnSet;
676 autoGeneratedColumnOrder: string;
677 rowSelector: GridRowSelector;
678 toolbarLabel: string;
679 toolbarButtons: GridToolbarButton[];
680 toolbarCheckboxes: GridToolbarCheckbox[];
681 toolbarActions: GridToolbarAction[];
682 lastSelectedIndex: any;
683 pageChanges: Subscription;
684 rowFlairIsEnabled: boolean;
685 rowFlairCallback: (row: any) => GridRowFlairEntry;
686 rowClassCallback: (row: any) => string;
687 cellClassCallback: (row: any, col: GridColumn) => string;
688 defaultVisibleFields: string[];
689 defaultHiddenFields: string[];
690 ignoredFields: string[];
691 overflowCells: boolean;
692 disablePaging: boolean;
693 showDeclaredFieldsOnly: boolean;
694 cellTextGenerator: GridCellTextGenerator;
695 reloadOnColumnChange: boolean;
697 // Allow calling code to know when the select-all-rows-in-page
698 // action has occurred.
699 selectRowsInPageEmitter: EventEmitter<void>;
701 filterControls: QueryList<GridFilterControlComponent>;
703 // Services injected by our grid component
706 store: ServerStoreService;
707 format: FormatService;
712 store: ServerStoreService,
713 format: FormatService) {
718 this.format = format;
719 this.pager = new Pager();
720 this.rowSelector = new GridRowSelector();
721 this.toolbarButtons = [];
722 this.toolbarCheckboxes = [];
723 this.toolbarActions = [];
727 this.selectRowsInPageEmitter = new EventEmitter<void>();
728 this.columnSet = new GridColumnSet(this.idl, this.idlClass);
729 this.columnSet.isSortable = this.isSortable === true;
730 this.columnSet.isFilterable = this.isFilterable === true;
731 this.columnSet.isMultiSortable = this.isMultiSortable === true;
732 this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
733 this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
734 if (!this.pager.limit) {
735 this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
737 this.generateColumns();
740 // Load initial settings and data.
742 this.applyGridConfig()
743 .then(() => this.dataSource.requestPage(this.pager))
744 .then(() => this.listenToPager());
751 async applyGridConfig(): Promise<void> {
753 const conf = await this.getGridConfig(this.persistKey);
756 columns = conf.columns;
757 if (conf.limit && !this.disablePaging) {
758 this.pager.limit = conf.limit;
760 this.applyToolbarActionVisibility(conf.hideToolbarActions);
763 // This is called regardless of the presence of saved
764 // settings so defaults can be applied.
765 this.columnSet.applyColumnSettings(columns);
767 console.error('Error applying grid config:', error);
772 applyToolbarActionVisibility(hidden: string[]) {
773 if (!hidden || hidden.length === 0) { return; }
776 this.toolbarActions.forEach(action => {
777 if (action.isGroup) {
779 } else if (!action.isSeparator) {
780 action.hidden = hidden.includes(action.label);
784 // If all actions in a group are hidden, hide the group as well.
785 // Note the group may be marked as hidden in the configuration,
786 // but the addition of new entries within a group should cause
787 // it to be visible again.
788 groups.forEach(group => {
789 const visible = this.toolbarActions
790 .filter(action => action.group === group.label && !action.hidden);
791 group.hidden = visible.length === 0;
796 // Give the UI time to settle before reloading grid data.
797 // This can help when data retrieval depends on a value
798 // getting modified by an angular digest cycle.
801 this.dataSource.reset();
802 this.dataSource.requestPage(this.pager);
806 reloadWithoutPagerReset() {
808 this.dataSource.reset();
809 this.dataSource.requestPage(this.pager);
813 // Sort the existing data source instead of requesting sorted
814 // data from the client. Reset pager to page 1. As with reload(),
815 // give the client a chance to setting before redisplaying.
819 this.sortLocalData();
820 this.dataSource.requestPage(this.pager);
824 // Subscribe or unsubscribe to page-change events from the pager.
826 if (this.pageChanges) { return; }
827 this.pageChanges = this.pager.onChange$.subscribe(
828 () => this.dataSource.requestPage(this.pager));
832 if (!this.pageChanges) { return; }
833 this.pageChanges.unsubscribe();
834 this.pageChanges = null;
837 // Sort data in the data source array
840 const sortDefs = this.dataSource.sort.map(sort => {
841 const column = this.columnSet.getColByName(sort.name);
849 if (!def.col.comparator) {
850 switch (def.col.datatype) {
854 def.col.comparator = (a, b) => {
857 if (a < b) { return -1; }
858 if (a > b) { return 1; }
863 def.col.comparator = (a, b) => {
864 if (a < b) { return -1; }
865 if (a > b) { return 1; }
874 this.dataSource.data.sort((rowA, rowB) => {
876 for (let idx = 0; idx < sortDefs.length; idx++) {
877 const sortDef = sortDefs[idx];
879 const valueA = this.getRowColumnValue(rowA, sortDef.col);
880 const valueB = this.getRowColumnValue(rowB, sortDef.col);
882 if (valueA === '' && valueB === '') { continue; }
883 if (valueA === '' && valueB !== '') { return 1; }
884 if (valueA !== '' && valueB === '') { return -1; }
886 const diff = sortDef.col.comparator(valueA, valueB);
887 if (diff === 0) { continue; }
889 return sortDef.dir === 'DESC' ? -diff : diff;
892 return 0; // No differences found.
896 getRowIndex(row: any): any {
897 const col = this.columnSet.indexColumn;
899 throw new Error('grid index column required');
901 return this.getRowColumnValue(row, col);
904 // Returns position in the data source array of the row with
905 // the provided index.
906 getRowPosition(index: any): number {
907 // for-loop for early exit
908 for (let idx = 0; idx < this.dataSource.data.length; idx++) {
909 const row = this.dataSource.data[idx];
910 if (row !== undefined && index === this.getRowIndex(row)) {
916 // Return the row with the provided index.
917 getRowByIndex(index: any): any {
918 for (let idx = 0; idx < this.dataSource.data.length; idx++) {
919 const row = this.dataSource.data[idx];
920 if (row !== undefined && index === this.getRowIndex(row)) {
926 // Returns all selected rows, regardless of whether they are
927 // currently visible in the grid display.
928 // De-selects previously selected rows which are no longer
929 // present in the grid.
930 getSelectedRows(): any[] {
934 this.rowSelector.selected().forEach(index => {
935 const row = this.getRowByIndex(index);
943 this.rowSelector.deselect(deleted);
947 rowIsSelected(row: any): boolean {
948 const index = this.getRowIndex(row);
949 return this.rowSelector.selected().filter(
954 getRowColumnBareValue(row: any, col: GridColumn): any {
955 if (col.name in row) {
956 return this.getObjectFieldValue(row, col.name);
957 } else if (col.path) {
958 return this.nestedItemFieldValue(row, col);
962 getRowColumnValue(row: any, col: GridColumn): any {
963 const val = this.getRowColumnBareValue(row, col);
965 if (col.datatype === 'bool') {
966 // Avoid string-ifying bools so we can use an <eg-bool/>
967 // in the grid template.
972 const intField = col.dateOnlyIntervalField;
975 this.columnSet.columns.filter(c => c.path === intField)[0];
977 interval = this.getRowColumnBareValue(row, intCol);
981 return this.format.transform({
983 idlClass: col.idlClass,
984 idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
985 datatype: col.datatype,
986 datePlusTime: Boolean(col.datePlusTime),
987 timezoneContextOrg: Number(col.timezoneContextOrg),
988 dateOnlyInterval: interval
992 getObjectFieldValue(obj: any, name: string): any {
993 if (typeof obj[name] === 'function') {
1000 nestedItemFieldValue(obj: any, col: GridColumn): string {
1003 let idlClassDef:any;
1004 const original = obj;
1005 const steps = col.path.split('.');
1007 for (let i = 0; i < steps.length; i++) {
1008 const step = steps[i];
1010 if (obj === null || obj === undefined || typeof obj !== 'object') {
1011 // We have run out of data to step through before
1012 // reaching the end of the path. Conclude fleshing via
1013 // callback if provided then exit.
1014 if (col.flesher && obj !== undefined) {
1015 return col.flesher(obj, col, original);
1020 const class_ = obj.classname;
1021 if (class_ && (idlClassDef = this.idl.classes[class_])) {
1022 idlField = idlClassDef.field_map[step];
1025 obj = this.getObjectFieldValue(obj, step);
1028 // We found a nested IDL object which may or may not have
1029 // been configured as a top-level column. Flesh the column
1030 // metadata with our newly found IDL info.
1032 if (!col.datatype) {
1033 col.datatype = idlField.datatype;
1035 if (!col.idlFieldDef) {
1036 idlField = col.idlFieldDef;
1038 if (!col.idlClass) {
1039 col.idlClass = idlClassDef.name;
1042 col.label = idlField.label || idlField.name;
1050 getColumnTextContent(row: any, col: GridColumn): string {
1051 if (this.columnHasTextGenerator(col)) {
1052 const str = this.cellTextGenerator[col.name](row);
1053 return (str === null || str === undefined) ? '' : str;
1055 if (col.cellTemplate) {
1056 return ''; // avoid 'undefined' values
1058 return this.getRowColumnValue(row, col);
1063 selectOneRow(index: any) {
1064 this.rowSelector.clear();
1065 this.rowSelector.select(index);
1066 this.lastSelectedIndex = index;
1069 selectMultipleRows(indexes: any[]) {
1070 this.rowSelector.clear();
1071 this.rowSelector.select(indexes);
1072 this.lastSelectedIndex = indexes[indexes.length - 1];
1075 // selects or deselects an item, without affecting the others.
1076 // returns true if the item is selected; false if de-selected.
1077 toggleSelectOneRow(index: any) {
1078 if (this.rowSelector.contains(index)) {
1079 this.rowSelector.deselect(index);
1083 this.rowSelector.select(index);
1084 this.lastSelectedIndex = index;
1088 selectRowByPos(pos: number) {
1089 const row = this.dataSource.data[pos];
1091 this.selectOneRow(this.getRowIndex(row));
1095 selectPreviousRow() {
1096 if (!this.lastSelectedIndex) { return; }
1097 const pos = this.getRowPosition(this.lastSelectedIndex);
1098 if (pos === this.pager.offset) {
1099 this.toPrevPage().then(() => this.selectLastRow(), err => { console.log('grid: in selectPreviousRow',err); });
1101 this.selectRowByPos(pos - 1);
1106 if (!this.lastSelectedIndex) { return; }
1107 const pos = this.getRowPosition(this.lastSelectedIndex);
1108 if (pos === (this.pager.offset + this.pager.limit - 1)) {
1109 this.toNextPage().then(() => this.selectFirstRow(), err => { console.log('grid: in selectNextRow',err); });
1111 this.selectRowByPos(pos + 1);
1116 // Select the previous row in addition to any currently selected row.
1117 // However, if the previous row is already selected, assume the user
1118 // has reversed direction and now wants to de-select the last selected row.
1119 selectMultiRowsPrevious() {
1120 if (!this.lastSelectedIndex) { return; }
1121 const pos = this.getRowPosition(this.lastSelectedIndex);
1122 const selectedIndexes = this.rowSelector.selected();
1124 const promise = // load the previous page of data if needed
1125 (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
1129 const row = this.dataSource.data[pos - 1];
1130 const newIndex = this.getRowIndex(row);
1131 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1132 // Prev row is already selected. User is reversing direction.
1133 this.rowSelector.deselect(this.lastSelectedIndex);
1134 this.lastSelectedIndex = newIndex;
1136 this.selectMultipleRows(selectedIndexes.concat(newIndex));
1139 err => { console.log('grid: inside selectMultiRowsPrevious',err); }
1143 // Select all rows between the previously selected row and
1144 // the provided row, including the provided row.
1145 // This is additive only -- rows are never de-selected.
1146 selectRowRange(index: any) {
1148 if (!this.lastSelectedIndex) {
1149 this.selectOneRow(index);
1153 const next = this.getRowPosition(index);
1154 const prev = this.getRowPosition(this.lastSelectedIndex);
1155 const start = Math.min(prev, next);
1156 const end = Math.max(prev, next);
1158 for (let idx = start; idx <= end; idx++) {
1159 const row = this.dataSource.data[idx];
1161 this.rowSelector.select(this.getRowIndex(row));
1165 this.lastSelectedIndex = index;
1169 // Select the next row in addition to any currently selected row.
1170 // However, if the next row is already selected, assume the user
1171 // has reversed direction and wants to de-select the last selected row.
1172 selectMultiRowsNext() {
1173 if (!this.lastSelectedIndex) { return; }
1174 const pos = this.getRowPosition(this.lastSelectedIndex);
1175 const selectedIndexes = this.rowSelector.selected();
1177 const promise = // load the next page of data if needed
1178 (pos === (this.pager.offset + this.pager.limit - 1)) ?
1179 this.toNextPage() : Promise.resolve();
1183 const row = this.dataSource.data[pos + 1];
1184 const newIndex = this.getRowIndex(row);
1185 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1186 // Next row is already selected. User is reversing direction.
1187 this.rowSelector.deselect(this.lastSelectedIndex);
1188 this.lastSelectedIndex = newIndex;
1190 this.selectMultipleRows(selectedIndexes.concat(newIndex));
1193 err => { console.log('grid: inside selectMultiRowsNext',err); }
1197 getFirstRowInPage(): any {
1198 return this.dataSource.data[this.pager.offset];
1201 getLastRowInPage(): any {
1202 return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
1206 this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
1210 this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
1213 selectRowsInPage() {
1214 const rows = this.dataSource.getPageOfRows(this.pager);
1215 const indexes = rows.map(r => this.getRowIndex(r));
1216 this.rowSelector.select(indexes);
1217 this.selectRowsInPageEmitter.emit();
1220 toPrevPage(): Promise<any> {
1221 if (this.pager.isFirstPage()) {
1222 return Promise.reject('on first');
1224 // temp ignore pager events since we're calling requestPage manually.
1226 this.pager.decrement();
1227 this.listenToPager();
1228 return this.dataSource.requestPage(this.pager);
1231 toNextPage(): Promise<any> {
1232 if (this.pager.isLastPage()) {
1233 return Promise.reject('on last');
1235 // temp ignore pager events since we're calling requestPage manually.
1237 this.pager.increment();
1238 this.listenToPager();
1239 return this.dataSource.requestPage(this.pager);
1242 getAllRows(): Promise<any> {
1243 const pager = new Pager();
1245 pager.limit = MAX_ALL_ROW_COUNT;
1246 return this.dataSource.requestPage(pager);
1249 // Returns a key/value pair object of visible column data as text.
1250 getRowAsFlatText(row: any): any {
1252 this.columnSet.displayColumns().forEach(col => {
1254 this.getColumnTextContent(row, col);
1259 getAllRowsAsText(): Observable<any> {
1260 return new Observable((observer: any) => {
1261 this.getAllRows().then(() => {
1262 this.dataSource.data.forEach(row => {
1263 observer.next(this.getRowAsFlatText(row));
1265 observer.complete();
1270 removeFilters(): void {
1271 this.dataSource.filters = {};
1272 this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
1273 this.filterControls.forEach(ctl => ctl.reset());
1276 saveFilters(asName: string): void {
1278 'filters' : this.dataSource.filters, // filters isn't 100% reversible to column filter values, so...
1279 'controls' : Object.fromEntries(new Map( this.columnSet.columns.map( c => [c.name, c.getFilter()] ) ))
1281 this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1282 console.log('grid: saveFilters, setting = ', setting);
1284 setting[asName] = obj;
1285 console.log('grid: saving ' + asName, JSON.stringify(obj));
1286 this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1287 console.log('grid: save toast here',res);
1291 deleteFilters(withName: string): void {
1292 this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1294 if (setting[withName]) {
1295 setting[withName] = undefined;
1296 delete setting[withName]; /* not releasing right away */
1298 console.warn('Could not find ' + withName + ' in eg.grid.filters.' + this.persistKey,setting);
1300 this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1301 console.log('grid: delete toast here',res);
1304 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1308 loadFilters(fromName: string): void {
1309 console.log('grid: fromName',fromName);
1310 this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1312 const obj = setting[fromName];
1314 this.dataSource.filters = obj.filters;
1315 Object.keys(obj.controls).forEach( col_name => {
1316 let col = this.columnSet.columns.find(c => c.name === col_name);
1318 col.loadFilter( obj.controls[col_name] );
1323 console.warn('Could not find ' + fromName + ' in eg.grid.filters.' + this.persistKey, obj);
1326 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1330 filtersSet(): boolean {
1331 return Object.keys(this.dataSource.filters).length > 0;
1334 gridToCsv(): Promise<string> {
1337 const columns = this.columnSet.displayColumns();
1340 columns.forEach(col => {
1341 csvStr += this.valueToCsv(col.label),
1345 csvStr = csvStr.replace(/,$/, '\n');
1347 return new Promise(resolve => {
1348 this.getAllRowsAsText().subscribe({
1350 columns.forEach(col => {
1351 csvStr += this.valueToCsv(row[col.name]);
1354 csvStr = csvStr.replace(/,$/, '\n');
1356 error: err => { console.log('grid: in gridToCsv',err); },
1357 complete: () => resolve(csvStr)
1363 // prepares a string for inclusion within a CSV document
1364 // by escaping commas and quotes and removing newlines.
1365 valueToCsv(str: string): string {
1367 if (!str) { return ''; }
1368 str = str.replace(/\n/g, '');
1369 if (str.match(/\,/) || str.match(/"/)) {
1370 str = str.replace(/"/g, '""');
1371 str = '"' + str + '"';
1377 if (!this.columnSet.idlClass) { return; }
1379 const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
1380 //const specifiedColumnOrder = this.autoGeneratedColumnOrder ?
1381 // this.autoGeneratedColumnOrder.split(/,/) : [];
1383 // generate columns for all non-virtual fields on the IDL class
1384 const fields = this.idl.classes[this.columnSet.idlClass].fields
1385 .filter((field:any) => !field.virtual);
1387 const sortedFields = this.autoGeneratedColumnOrder ?
1388 this.idl.sortIdlFields(fields, this.autoGeneratedColumnOrder.split(/,/)) :
1391 sortedFields.forEach((field:any) => {
1392 if (!this.ignoredFields.filter(ignored => ignored === field.name).length) {
1393 const col = new GridColumn();
1394 col.name = field.name;
1395 col.label = field.label || field.name;
1396 col.idlFieldDef = field;
1397 col.idlClass = this.columnSet.idlClass;
1398 col.datatype = field.datatype;
1399 col.isIndex = (field.name === pkeyField);
1401 col.headerLabel = col.label;
1403 if (this.showDeclaredFieldsOnly) {
1407 col.filterValue = this?.initialFilterValues?.[field.name];
1409 this.columnSet.add(col);
1414 saveGridConfig(): Promise<any> {
1415 if (!this.persistKey) {
1416 throw new Error('Grid persistKey required to save columns');
1418 const conf = new GridPersistConf();
1420 conf.limit = this.pager.limit;
1421 conf.columns = this.columnSet.compileSaveObject();
1423 // Avoid persisting group visibility since that may change
1424 // with the addition of new columns. Always calculate that
1426 conf.hideToolbarActions = this.toolbarActions
1427 .filter(action => !action.isGroup && action.hidden)
1428 .map(action => action.label);
1430 return this.store.setItem('eg.grid.' + this.persistKey, conf);
1433 // TODO: saveGridConfigAsOrgSetting(...)
1435 getGridConfig(persistKey: string): Promise<GridPersistConf> {
1436 if (!persistKey) { return Promise.resolve(null); }
1437 return this.store.getItem('eg.grid.' + persistKey);
1440 columnHasTextGenerator(col: GridColumn): boolean {
1441 return this.cellTextGenerator && col.name in this.cellTextGenerator;
1446 // Actions apply to specific rows
1447 export class GridToolbarAction {
1449 onClick: EventEmitter<any []>;
1450 action: (rows: any[]) => any; // DEPRECATED
1453 isGroup: boolean; // used for group placeholder entries
1454 isSeparator: boolean;
1455 disableOnRows: (rows: any[]) => boolean;
1459 // Buttons are global actions
1460 export class GridToolbarButton {
1462 adjacentPreceedingLabel: string;
1463 adjacentSubsequentLabel: string;
1464 onClick: EventEmitter<any []>;
1465 action: () => any; // DEPRECATED
1470 export class GridToolbarCheckbox {
1473 onChange: EventEmitter<boolean>;
1476 export interface GridColumnSort {
1481 export class GridDataSource {
1484 sort: GridColumnSort[];
1486 allRowsRetrieved: boolean;
1487 requestingData: boolean;
1488 retrievalError: boolean;
1489 getRows: (pager: Pager, sort: GridColumnSort[]) => Observable<any>;
1499 this.allRowsRetrieved = false;
1502 // called from the template -- no data fetching
1503 getPageOfRows(pager: Pager): any[] {
1505 return this.data.slice(
1506 pager.offset, pager.limit + pager.offset
1507 ).filter(row => row !== undefined);
1512 // called on initial component load and user action (e.g. paging, sorting).
1513 requestPage(pager: Pager): Promise<any> {
1516 this.getPageOfRows(pager).length === pager.limit
1517 // already have all data
1518 || this.allRowsRetrieved
1519 // have no way to get more data.
1522 return Promise.resolve();
1525 // If we have to call out for data, set inFetch
1526 this.requestingData = true;
1527 this.retrievalError = false;
1529 return new Promise((resolve, reject) => {
1530 let idx = pager.offset;
1531 return this.getRows(pager, this.sort).subscribe({
1533 this.data[idx++] = row;
1534 // not updating this.requestingData, as having
1535 // retrieved one row doesn't mean we're done
1536 this.retrievalError = false;
1539 console.error(`grid getRows() error ${err}`);
1540 this.requestingData = false;
1541 this.retrievalError = true;
1545 this.checkAllRetrieved(pager, idx);
1546 this.requestingData = false;
1547 this.retrievalError = false;
1554 // See if the last getRows() call resulted in the final set of data.
1555 checkAllRetrieved(pager: Pager, idx: number) {
1556 if (this.allRowsRetrieved) { return; }
1558 if (idx === 0 || idx < (pager.limit + pager.offset)) {
1559 // last query returned nothing or less than one page.
1560 // confirm we have all of the preceding pages.
1561 if (!this.data.includes(undefined)) {
1562 this.allRowsRetrieved = true;
1563 pager.resultCount = this.data.length;