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; }
437 this.columns.forEach((col, idx) => {
438 if (col.name === source.name) { sourceIdx = idx; }
441 if (sourceIdx >= 0) {
442 this.columns.splice(sourceIdx, 1);
445 this.columns.splice(targetIdx, 0, source);
448 // Move visible columns to the front of the list.
449 moveVisibleToFront() {
450 const newCols = this.displayColumns();
451 this.columns.forEach(col => {
452 if (!col.visible) { newCols.push(col); }
454 this.columns = newCols;
457 moveColumn(col: GridColumn, diff: number) {
458 let srcIdx:number, targetIdx:number;
460 this.columns.forEach((c, i) => {
461 if (c.name === col.name) { srcIdx = i; }
464 targetIdx = srcIdx + diff;
467 } else if (targetIdx >= this.columns.length) {
468 // Target index follows the last visible column.
470 this.columns.forEach((c, idx) => {
471 if (c.visible) { lastVisible = idx; }
474 // When moving a column (down) causes one or more
475 // visible columns to shuffle forward, our column
476 // moves into the slot of the last visible column.
477 // Otherwise, put it into the slot directly following
478 // the last visible column.
479 targetIdx = srcIdx <= lastVisible ? lastVisible : lastVisible + 1;
482 // Splice column out of old position, insert at new position.
483 this.columns.splice(srcIdx, 1);
484 this.columns.splice(targetIdx, 0, col);
487 compileSaveObject(): GridColumnPersistConf[] {
488 // only store information about visible columns.
489 // scrunch the data down to just the needed info.
490 return this.displayColumns().map(col => {
491 const c: GridColumnPersistConf = {name : col.name};
492 if (col.align !== 'left') { c.align = col.align; }
493 if (col.flex !== 2) { c.flex = Number(col.flex); }
494 if (Number(col.sort)) { c.sort = Number(col.sort); }
499 applyColumnSettings(conf: GridColumnPersistConf[]) {
501 if (!conf || conf.length === 0) {
502 // No configuration is available, but we have a list of
503 // fields to show or hide by default
505 if (this.defaultVisibleFields) {
506 this.columns.forEach(col => {
507 if (this.defaultVisibleFields.includes(col.name)) {
514 } else if (this.defaultHiddenFields) {
515 this.defaultHiddenFields.forEach(name => {
516 const col = this.getColByName(name);
528 conf.forEach(colConf => {
529 const col = this.getColByName(colConf.name);
530 if (!col) { return; } // no such column in this grid.
533 if (colConf.align) { col.align = colConf.align; }
534 if (colConf.flex) { col.flex = Number(colConf.flex); }
535 if (colConf.sort) { col.sort = Number(colConf.sort); }
537 // Add to new columns array, avoid dupes.
538 if (newCols.filter(c => c.name === col.name).length === 0) {
543 // columns which are not expressed within the saved
544 // configuration are marked as non-visible and
545 // appended to the end of the new list of columns.
546 this.columns.forEach(c => {
547 if (conf.filter(cf => cf.name === c.name).length === 0) {
553 this.columns = newCols;
557 // Maps colunm names to functions which return plain text values for
558 // each mapped column on a given row. This is primarily useful for
559 // generating print-friendly content for grid cells rendered via
562 // USAGE NOTE: Since a cellTemplate can be passed arbitrary context
563 // but a GridCellTextGenerator only gets the row object,
564 // if it's important to include content that's not available
565 // by default in the row object, you may want to stick
566 // it in the row object as an additional attribute.
568 export interface GridCellTextGenerator {
569 [columnName: string]: (row: any) => string;
572 export class GridRowSelector {
573 indexes: {[string: string]: boolean};
575 // Track these so we can emit the selectionChange event
576 // only when the selection actually changes.
577 previousSelection: string[] = [];
579 // Emits the selected indexes on selection change
580 selectionChange: EventEmitter<string[]> = new EventEmitter<string[]>();
586 // Returns true if all of the requested indexes exist in the selector.
587 contains(index: string | string[]): boolean {
588 const indexes = [].concat(index);
589 for (let i = 0; i < indexes.length; i++) { // early exit
590 if (!this.indexes[indexes[i]]) {
598 const keys = this.selected();
600 if (keys.length === this.previousSelection.length &&
601 this.contains(this.previousSelection)) {
602 return; // No change has occurred
605 this.previousSelection = keys;
606 this.selectionChange.emit(keys);
609 select(index: string | string[]) {
610 const indexes = [].concat(index);
611 indexes.forEach(i => this.indexes[i] = true);
615 deselect(index: string | string[]) {
616 const indexes = [].concat(index);
617 indexes.forEach(i => delete this.indexes[i]);
621 toggle(index: string) {
622 if (this.indexes[index]) {
623 this.deselect(index);
629 selected(): string[] {
630 return Object.keys(this.indexes);
634 return this.selected().length === 0;
643 export interface GridRowFlairEntry {
644 icon: string; // name of material icon
645 title?: string; // tooltip string
648 export class GridColumnPersistConf {
655 export class GridPersistConf {
658 columns: GridColumnPersistConf[];
659 hideToolbarActions: string[];
662 export class GridContext {
667 isFilterable: boolean;
668 initialFilterValues: {[field: string]: string};
669 allowNamedFilterSets: boolean;
670 migrateLegacyFilterSets: string;
671 stickyGridHeader: boolean;
672 isMultiSortable: boolean;
673 useLocalSort: boolean;
675 disableMultiSelect: boolean;
676 disableSelect: boolean;
677 dataSource: GridDataSource;
678 columnSet: GridColumnSet;
679 autoGeneratedColumnOrder: string;
680 rowSelector: GridRowSelector;
681 toolbarLabel: string;
682 toolbarButtons: GridToolbarButton[];
683 toolbarCheckboxes: GridToolbarCheckbox[];
684 toolbarActions: GridToolbarAction[];
685 lastSelectedIndex: any;
686 pageChanges: Subscription;
687 rowFlairIsEnabled: boolean;
688 rowFlairCallback: (row: any) => GridRowFlairEntry;
689 rowClassCallback: (row: any) => string;
690 cellClassCallback: (row: any, col: GridColumn) => string;
691 defaultVisibleFields: string[];
692 defaultHiddenFields: string[];
693 ignoredFields: string[];
694 overflowCells: boolean;
695 disablePaging: boolean;
696 showDeclaredFieldsOnly: boolean;
697 cellTextGenerator: GridCellTextGenerator;
698 reloadOnColumnChange: boolean;
700 // Allow calling code to know when the select-all-rows-in-page
701 // action has occurred.
702 selectRowsInPageEmitter: EventEmitter<void>;
704 filterControls: QueryList<GridFilterControlComponent>;
706 // Services injected by our grid component
709 store: ServerStoreService;
710 format: FormatService;
715 store: ServerStoreService,
716 format: FormatService) {
721 this.format = format;
722 this.pager = new Pager();
723 this.rowSelector = new GridRowSelector();
724 this.toolbarButtons = [];
725 this.toolbarCheckboxes = [];
726 this.toolbarActions = [];
730 this.selectRowsInPageEmitter = new EventEmitter<void>();
731 this.columnSet = new GridColumnSet(this.idl, this.idlClass);
732 this.columnSet.isSortable = this.isSortable === true;
733 this.columnSet.isFilterable = this.isFilterable === true;
734 this.columnSet.isMultiSortable = this.isMultiSortable === true;
735 this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
736 this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
737 if (!this.pager.limit) {
738 this.pager.limit = this.disablePaging ? MAX_ALL_ROW_COUNT : 10;
740 this.generateColumns();
743 // Load initial settings and data.
745 this.applyGridConfig()
746 .then(() => this.dataSource.requestPage(this.pager))
747 .then(() => this.listenToPager());
754 async applyGridConfig(): Promise<void> {
756 const conf = await this.getGridConfig(this.persistKey);
759 columns = conf.columns;
760 if (conf.limit && !this.disablePaging) {
761 this.pager.limit = conf.limit;
763 this.applyToolbarActionVisibility(conf.hideToolbarActions);
766 // This is called regardless of the presence of saved
767 // settings so defaults can be applied.
768 this.columnSet.applyColumnSettings(columns);
770 console.error('Error applying grid config:', error);
775 applyToolbarActionVisibility(hidden: string[]) {
776 if (!hidden || hidden.length === 0) { return; }
779 this.toolbarActions.forEach(action => {
780 if (action.isGroup) {
782 } else if (!action.isSeparator) {
783 action.hidden = hidden.includes(action.label);
787 // If all actions in a group are hidden, hide the group as well.
788 // Note the group may be marked as hidden in the configuration,
789 // but the addition of new entries within a group should cause
790 // it to be visible again.
791 groups.forEach(group => {
792 const visible = this.toolbarActions
793 .filter(action => action.group === group.label && !action.hidden);
794 group.hidden = visible.length === 0;
799 // Give the UI time to settle before reloading grid data.
800 // This can help when data retrieval depends on a value
801 // getting modified by an angular digest cycle.
804 this.dataSource.reset();
805 this.dataSource.requestPage(this.pager);
809 reloadWithoutPagerReset() {
811 this.dataSource.reset();
812 this.dataSource.requestPage(this.pager);
816 // Sort the existing data source instead of requesting sorted
817 // data from the client. Reset pager to page 1. As with reload(),
818 // give the client a chance to setting before redisplaying.
822 this.sortLocalData();
823 this.dataSource.requestPage(this.pager);
827 // Subscribe or unsubscribe to page-change events from the pager.
829 if (this.pageChanges) { return; }
830 this.pageChanges = this.pager.onChange$.subscribe(
831 () => this.dataSource.requestPage(this.pager));
835 if (!this.pageChanges) { return; }
836 this.pageChanges.unsubscribe();
837 this.pageChanges = null;
840 // Sort data in the data source array
843 const sortDefs = this.dataSource.sort.map(sort => {
844 const column = this.columnSet.getColByName(sort.name);
852 if (!def.col.comparator) {
853 switch (def.col.datatype) {
857 def.col.comparator = (a, b) => {
860 if (a < b) { return -1; }
861 if (a > b) { return 1; }
866 def.col.comparator = (a, b) => {
867 if (a < b) { return -1; }
868 if (a > b) { return 1; }
877 this.dataSource.data.sort((rowA, rowB) => {
879 for (let idx = 0; idx < sortDefs.length; idx++) {
880 const sortDef = sortDefs[idx];
882 const valueA = this.getRowColumnValue(rowA, sortDef.col);
883 const valueB = this.getRowColumnValue(rowB, sortDef.col);
885 if (valueA === '' && valueB === '') { continue; }
886 if (valueA === '' && valueB !== '') { return 1; }
887 if (valueA !== '' && valueB === '') { return -1; }
889 const diff = sortDef.col.comparator(valueA, valueB);
890 if (diff === 0) { continue; }
892 return sortDef.dir === 'DESC' ? -diff : diff;
895 return 0; // No differences found.
899 getRowIndex(row: any): any {
900 const col = this.columnSet.indexColumn;
902 throw new Error('grid index column required');
904 return this.getRowColumnValue(row, col);
907 // Returns position in the data source array of the row with
908 // the provided index.
909 getRowPosition(index: any): number {
910 // for-loop for early exit
911 for (let idx = 0; idx < this.dataSource.data.length; idx++) {
912 const row = this.dataSource.data[idx];
913 if (row !== undefined && index === this.getRowIndex(row)) {
919 // Return the row with the provided index.
920 getRowByIndex(index: any): any {
921 for (let idx = 0; idx < this.dataSource.data.length; idx++) {
922 const row = this.dataSource.data[idx];
923 if (row !== undefined && index === this.getRowIndex(row)) {
929 // Returns all selected rows, regardless of whether they are
930 // currently visible in the grid display.
931 // De-selects previously selected rows which are no longer
932 // present in the grid.
933 getSelectedRows(): any[] {
937 this.rowSelector.selected().forEach(index => {
938 const row = this.getRowByIndex(index);
946 this.rowSelector.deselect(deleted);
950 rowIsSelected(row: any): boolean {
951 const index = this.getRowIndex(row);
952 return this.rowSelector.selected().filter(
957 getRowColumnBareValue(row: any, col: GridColumn): any {
958 if (col.name in row) {
959 return this.getObjectFieldValue(row, col.name);
960 } else if (col.path) {
961 return this.nestedItemFieldValue(row, col);
965 getRowColumnValue(row: any, col: GridColumn): any {
966 const val = this.getRowColumnBareValue(row, col);
968 if (col.datatype === 'bool') {
969 // Avoid string-ifying bools so we can use an <eg-bool/>
970 // in the grid template.
975 const intField = col.dateOnlyIntervalField;
978 this.columnSet.columns.filter(c => c.path === intField)[0];
980 interval = this.getRowColumnBareValue(row, intCol);
984 return this.format.transform({
986 idlClass: col.idlClass,
987 idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
988 datatype: col.datatype,
989 datePlusTime: Boolean(col.datePlusTime),
990 timezoneContextOrg: Number(col.timezoneContextOrg),
991 dateOnlyInterval: interval
995 getObjectFieldValue(obj: any, name: string): any {
996 if (typeof obj[name] === 'function') {
1003 nestedItemFieldValue(obj: any, col: GridColumn): string {
1006 let idlClassDef:any;
1007 const original = obj;
1008 const steps = col.path.split('.');
1010 for (let i = 0; i < steps.length; i++) {
1011 const step = steps[i];
1013 if (obj === null || obj === undefined || typeof obj !== 'object') {
1014 // We have run out of data to step through before
1015 // reaching the end of the path. Conclude fleshing via
1016 // callback if provided then exit.
1017 if (col.flesher && obj !== undefined) {
1018 return col.flesher(obj, col, original);
1023 const class_ = obj.classname;
1024 if (class_ && (idlClassDef = this.idl.classes[class_])) {
1025 idlField = idlClassDef.field_map[step];
1028 obj = this.getObjectFieldValue(obj, step);
1031 // We found a nested IDL object which may or may not have
1032 // been configured as a top-level column. Flesh the column
1033 // metadata with our newly found IDL info.
1035 if (!col.datatype) {
1036 col.datatype = idlField.datatype;
1038 if (!col.idlFieldDef) {
1039 idlField = col.idlFieldDef;
1041 if (!col.idlClass) {
1042 col.idlClass = idlClassDef.name;
1045 col.label = idlField.label || idlField.name;
1053 getColumnTextContent(row: any, col: GridColumn): string {
1054 if (this.columnHasTextGenerator(col)) {
1055 const str = this.cellTextGenerator[col.name](row);
1056 return (str === null || str === undefined) ? '' : str;
1058 if (col.cellTemplate) {
1059 return ''; // avoid 'undefined' values
1061 return this.getRowColumnValue(row, col);
1066 selectOneRow(index: any) {
1067 this.rowSelector.clear();
1068 this.rowSelector.select(index);
1069 this.lastSelectedIndex = index;
1072 selectMultipleRows(indexes: any[]) {
1073 this.rowSelector.clear();
1074 this.rowSelector.select(indexes);
1075 this.lastSelectedIndex = indexes[indexes.length - 1];
1078 // selects or deselects an item, without affecting the others.
1079 // returns true if the item is selected; false if de-selected.
1080 toggleSelectOneRow(index: any) {
1081 if (this.rowSelector.contains(index)) {
1082 this.rowSelector.deselect(index);
1086 this.rowSelector.select(index);
1087 this.lastSelectedIndex = index;
1091 selectRowByPos(pos: number) {
1092 const row = this.dataSource.data[pos];
1094 this.selectOneRow(this.getRowIndex(row));
1098 selectPreviousRow() {
1099 if (!this.lastSelectedIndex) { return; }
1100 const pos = this.getRowPosition(this.lastSelectedIndex);
1101 if (pos === this.pager.offset) {
1102 this.toPrevPage().then(() => this.selectLastRow(), err => { console.log('grid: in selectPreviousRow',err); });
1104 this.selectRowByPos(pos - 1);
1109 if (!this.lastSelectedIndex) { return; }
1110 const pos = this.getRowPosition(this.lastSelectedIndex);
1111 if (pos === (this.pager.offset + this.pager.limit - 1)) {
1112 this.toNextPage().then(() => this.selectFirstRow(), err => { console.log('grid: in selectNextRow',err); });
1114 this.selectRowByPos(pos + 1);
1119 // Select the previous row in addition to any currently selected row.
1120 // However, if the previous row is already selected, assume the user
1121 // has reversed direction and now wants to de-select the last selected row.
1122 selectMultiRowsPrevious() {
1123 if (!this.lastSelectedIndex) { return; }
1124 const pos = this.getRowPosition(this.lastSelectedIndex);
1125 const selectedIndexes = this.rowSelector.selected();
1127 const promise = // load the previous page of data if needed
1128 (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
1132 const row = this.dataSource.data[pos - 1];
1133 const newIndex = this.getRowIndex(row);
1134 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1135 // Prev row is already selected. User is reversing direction.
1136 this.rowSelector.deselect(this.lastSelectedIndex);
1137 this.lastSelectedIndex = newIndex;
1139 this.selectMultipleRows(selectedIndexes.concat(newIndex));
1142 err => { console.log('grid: inside selectMultiRowsPrevious',err); }
1146 // Select all rows between the previously selected row and
1147 // the provided row, including the provided row.
1148 // This is additive only -- rows are never de-selected.
1149 selectRowRange(index: any) {
1151 if (!this.lastSelectedIndex) {
1152 this.selectOneRow(index);
1156 const next = this.getRowPosition(index);
1157 const prev = this.getRowPosition(this.lastSelectedIndex);
1158 const start = Math.min(prev, next);
1159 const end = Math.max(prev, next);
1161 for (let idx = start; idx <= end; idx++) {
1162 const row = this.dataSource.data[idx];
1164 this.rowSelector.select(this.getRowIndex(row));
1168 this.lastSelectedIndex = index;
1172 // Select the next row in addition to any currently selected row.
1173 // However, if the next row is already selected, assume the user
1174 // has reversed direction and wants to de-select the last selected row.
1175 selectMultiRowsNext() {
1176 if (!this.lastSelectedIndex) { return; }
1177 const pos = this.getRowPosition(this.lastSelectedIndex);
1178 const selectedIndexes = this.rowSelector.selected();
1180 const promise = // load the next page of data if needed
1181 (pos === (this.pager.offset + this.pager.limit - 1)) ?
1182 this.toNextPage() : Promise.resolve();
1186 const row = this.dataSource.data[pos + 1];
1187 const newIndex = this.getRowIndex(row);
1188 if (selectedIndexes.filter(i => i === newIndex).length > 0) {
1189 // Next row is already selected. User is reversing direction.
1190 this.rowSelector.deselect(this.lastSelectedIndex);
1191 this.lastSelectedIndex = newIndex;
1193 this.selectMultipleRows(selectedIndexes.concat(newIndex));
1196 err => { console.log('grid: inside selectMultiRowsNext',err); }
1200 getFirstRowInPage(): any {
1201 return this.dataSource.data[this.pager.offset];
1204 getLastRowInPage(): any {
1205 return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
1209 this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
1213 this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
1216 selectRowsInPage() {
1217 const rows = this.dataSource.getPageOfRows(this.pager);
1218 const indexes = rows.map(r => this.getRowIndex(r));
1219 this.rowSelector.select(indexes);
1220 this.selectRowsInPageEmitter.emit();
1223 toPrevPage(): Promise<any> {
1224 if (this.pager.isFirstPage()) {
1225 return Promise.reject('on first');
1227 // temp ignore pager events since we're calling requestPage manually.
1229 this.pager.decrement();
1230 this.listenToPager();
1231 return this.dataSource.requestPage(this.pager);
1234 toNextPage(): Promise<any> {
1235 if (this.pager.isLastPage()) {
1236 return Promise.reject('on last');
1238 // temp ignore pager events since we're calling requestPage manually.
1240 this.pager.increment();
1241 this.listenToPager();
1242 return this.dataSource.requestPage(this.pager);
1245 getAllRows(): Promise<any> {
1246 const pager = new Pager();
1248 pager.limit = MAX_ALL_ROW_COUNT;
1249 return this.dataSource.requestPage(pager);
1252 // Returns a key/value pair object of visible column data as text.
1253 getRowAsFlatText(row: any): any {
1255 this.columnSet.displayColumns().forEach(col => {
1257 this.getColumnTextContent(row, col);
1262 getAllRowsAsText(): Observable<any> {
1263 return new Observable((observer: any) => {
1264 this.getAllRows().then(() => {
1265 this.dataSource.data.forEach(row => {
1266 observer.next(this.getRowAsFlatText(row));
1268 observer.complete();
1273 removeFilters(): void {
1274 this.dataSource.filters = {};
1275 this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
1276 this.filterControls.forEach(ctl => ctl.reset());
1279 saveFilters(asName: string): void {
1281 'filters' : this.dataSource.filters, // filters isn't 100% reversible to column filter values, so...
1282 'controls' : Object.fromEntries(new Map( this.columnSet.columns.map( c => [c.name, c.getFilter()] ) ))
1284 this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1285 console.log('grid: saveFilters, setting = ', setting);
1287 setting[asName] = obj;
1288 console.log('grid: saving ' + asName, JSON.stringify(obj));
1289 this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1290 console.log('grid: save toast here',res);
1294 deleteFilters(withName: string): void {
1295 this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1297 if (setting[withName]) {
1298 setting[withName] = undefined;
1299 delete setting[withName]; /* not releasing right away */
1301 console.warn('Could not find ' + withName + ' in eg.grid.filters.' + this.persistKey,setting);
1303 this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
1304 console.log('grid: delete toast here',res);
1307 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1311 loadFilters(fromName: string): void {
1312 console.log('grid: fromName',fromName);
1313 this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
1315 const obj = setting[fromName];
1317 this.dataSource.filters = obj.filters;
1318 Object.keys(obj.controls).forEach( col_name => {
1319 const col = this.columnSet.columns.find(c => c.name === col_name);
1321 col.loadFilter( obj.controls[col_name] );
1326 console.warn('Could not find ' + fromName + ' in eg.grid.filters.' + this.persistKey, obj);
1329 console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
1333 filtersSet(): boolean {
1334 return Object.keys(this.dataSource.filters).length > 0;
1337 gridToCsv(): Promise<string> {
1340 const columns = this.columnSet.displayColumns();
1343 columns.forEach(col => {
1344 // eslint-disable-next-line no-unused-expressions
1345 csvStr += this.valueToCsv(col.label),
1349 csvStr = csvStr.replace(/,$/, '\n');
1351 return new Promise(resolve => {
1352 this.getAllRowsAsText().subscribe({
1354 columns.forEach(col => {
1355 csvStr += this.valueToCsv(row[col.name]);
1358 csvStr = csvStr.replace(/,$/, '\n');
1360 error: (err: unknown) => { console.log('grid: in gridToCsv',err); },
1361 complete: () => resolve(csvStr)
1367 // prepares a string for inclusion within a CSV document
1368 // by escaping commas and quotes and removing newlines.
1369 valueToCsv(str: string): string {
1371 if (!str) { return ''; }
1372 str = str.replace(/\n/g, '');
1373 if (str.match(/,/) || str.match(/"/)) {
1374 str = str.replace(/"/g, '""');
1375 str = '"' + str + '"';
1381 if (!this.columnSet.idlClass) { return; }
1383 const pkeyField = this.idl.classes[this.columnSet.idlClass].pkey;
1384 // const specifiedColumnOrder = this.autoGeneratedColumnOrder ?
1385 // this.autoGeneratedColumnOrder.split(/,/) : [];
1387 // generate columns for all non-virtual fields on the IDL class
1388 const fields = this.idl.classes[this.columnSet.idlClass].fields
1389 .filter((field:any) => !field.virtual);
1391 const sortedFields = this.autoGeneratedColumnOrder ?
1392 this.idl.sortIdlFields(fields, this.autoGeneratedColumnOrder.split(/,/)) :
1395 sortedFields.forEach((field:any) => {
1396 if (!this.ignoredFields.filter(ignored => ignored === field.name).length) {
1397 const col = new GridColumn();
1398 col.name = field.name;
1399 col.label = field.label || field.name;
1400 col.idlFieldDef = field;
1401 col.idlClass = this.columnSet.idlClass;
1402 col.datatype = field.datatype;
1403 col.isIndex = (field.name === pkeyField);
1405 col.headerLabel = col.label;
1407 if (this.showDeclaredFieldsOnly) {
1411 col.filterValue = this?.initialFilterValues?.[field.name];
1413 this.columnSet.add(col);
1418 saveGridConfig(): Promise<any> {
1419 if (!this.persistKey) {
1420 throw new Error('Grid persistKey required to save columns');
1422 const conf = new GridPersistConf();
1424 conf.limit = this.pager.limit;
1425 conf.columns = this.columnSet.compileSaveObject();
1427 // Avoid persisting group visibility since that may change
1428 // with the addition of new columns. Always calculate that
1430 conf.hideToolbarActions = this.toolbarActions
1431 .filter(action => !action.isGroup && action.hidden)
1432 .map(action => action.label);
1434 return this.store.setItem('eg.grid.' + this.persistKey, conf);
1437 // TODO: saveGridConfigAsOrgSetting(...)
1439 getGridConfig(persistKey: string): Promise<GridPersistConf> {
1440 if (!persistKey) { return Promise.resolve(null); }
1441 return this.store.getItem('eg.grid.' + persistKey);
1444 columnHasTextGenerator(col: GridColumn): boolean {
1445 return this.cellTextGenerator && col.name in this.cellTextGenerator;
1450 // Actions apply to specific rows
1451 export class GridToolbarAction {
1453 onClick: EventEmitter<any []>;
1454 action: (rows: any[]) => any; // DEPRECATED
1457 isGroup: boolean; // used for group placeholder entries
1458 isSeparator: boolean;
1459 disableOnRows: (rows: any[]) => boolean;
1463 // Buttons are global actions
1464 export class GridToolbarButton {
1466 adjacentPreceedingLabel: string;
1467 adjacentSubsequentLabel: string;
1468 onClick: EventEmitter<any []>;
1469 action: () => any; // DEPRECATED
1474 export class GridToolbarCheckbox {
1477 onChange: EventEmitter<boolean>;
1480 export interface GridColumnSort {
1485 export class GridDataSource {
1488 sort: GridColumnSort[];
1490 allRowsRetrieved: boolean;
1491 requestingData: boolean;
1492 retrievalError: boolean;
1493 getRows: (pager: Pager, sort: GridColumnSort[]) => Observable<any>;
1503 this.allRowsRetrieved = false;
1506 // called from the template -- no data fetching
1507 getPageOfRows(pager: Pager): any[] {
1509 return this.data.slice(
1510 pager.offset, pager.limit + pager.offset
1511 ).filter(row => row !== undefined);
1516 // called on initial component load and user action (e.g. paging, sorting).
1517 requestPage(pager: Pager): Promise<any> {
1520 this.getPageOfRows(pager).length === pager.limit
1521 // already have all data
1522 || this.allRowsRetrieved
1523 // have no way to get more data.
1526 return Promise.resolve();
1529 // If we have to call out for data, set inFetch
1530 this.requestingData = true;
1531 this.retrievalError = false;
1533 return new Promise((resolve, reject) => {
1534 let idx = pager.offset;
1535 return this.getRows(pager, this.sort).subscribe({
1537 this.data[idx++] = row;
1538 // not updating this.requestingData, as having
1539 // retrieved one row doesn't mean we're done
1540 this.retrievalError = false;
1542 error: (err: unknown) => {
1543 console.error(`grid getRows() error ${err}`);
1544 this.requestingData = false;
1545 this.retrievalError = true;
1549 this.checkAllRetrieved(pager, idx);
1550 this.requestingData = false;
1551 this.retrievalError = false;
1558 // See if the last getRows() call resulted in the final set of data.
1559 checkAllRetrieved(pager: Pager, idx: number) {
1560 if (this.allRowsRetrieved) { return; }
1562 if (idx === 0 || idx < (pager.limit + pager.offset)) {
1563 // last query returned nothing or less than one page.
1564 // confirm we have all of the preceding pages.
1565 if (!this.data.includes(undefined)) {
1566 this.allRowsRetrieved = true;
1567 pager.resultCount = this.data.length;