diff --git a/projects/components/src/table/controls/table-controls.component.ts b/projects/components/src/table/controls/table-controls.component.ts index 12d7319dc..1606362e0 100644 --- a/projects/components/src/table/controls/table-controls.component.ts +++ b/projects/components/src/table/controls/table-controls.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { IconType } from '@hypertrace/assets-library'; import { TypedSimpleChanges } from '@hypertrace/common'; +import { isEqual } from 'lodash-es'; import { IconSize } from '../../icon/icon-size'; import { MultiSelectJustify } from '../../multi-select/multi-select-justify'; import { MultiSelectSearchMode, TriggerLabelDisplayMode } from '../../multi-select/multi-select.component'; @@ -101,6 +102,8 @@ import { }) export class TableControlsComponent implements OnChanges { public readonly DEFAULT_SEARCH_PLACEHOLDER: string = 'Search...'; + @Input() + public persistenceId?: string; @Input() public searchEnabled?: boolean; @@ -207,7 +210,7 @@ export class TableControlsComponent implements OnChanges { private setActiveViewItem(): void { if (this.viewItems !== undefined) { - this.activeViewItem = this.viewItems.find(item => item === this.activeViewItem) ?? this.viewItems[0]; + this.activeViewItem = this.findViewItem(this.activeViewItem); } } @@ -259,4 +262,8 @@ export class TableControlsComponent implements OnChanges { public onViewChange(item: ToggleItem): void { this.viewChange.emit(item.value); } + + private findViewItem(viewItem?: ToggleItem): ToggleItem | undefined { + return this.viewItems?.find(item => isEqual(item, viewItem)) ?? this.viewItems![0]; + } } diff --git a/projects/dashboards/src/widgets/base.model.ts b/projects/dashboards/src/widgets/base.model.ts index 69659ca54..05a49b25e 100644 --- a/projects/dashboards/src/widgets/base.model.ts +++ b/projects/dashboards/src/widgets/base.model.ts @@ -15,4 +15,8 @@ export abstract class BaseModel { type: STRING_PROPERTY.type }) public id?: string; + + public getId(): string | undefined { + return this.id; + } } diff --git a/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts b/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts index e732c85db..8fadc04c7 100644 --- a/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts +++ b/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts @@ -23,6 +23,13 @@ import { TableWidgetControlCheckboxOptionModel } from './table-widget-control-ch import { TableWidgetControlSelectOptionModel } from './table-widget-control-select-option.model'; export abstract class TableWidgetBaseModel extends BaseModel { + @ModelProperty({ + key: 'viewId', + displayName: 'Model View ID', + type: STRING_PROPERTY.type + }) + public viewId?: string; + @ModelProperty({ // tslint:disable-next-line: no-object-literal-type-assertion type: { @@ -126,6 +133,11 @@ export abstract class TableWidgetBaseModel extends BaseModel { return []; } + public getViewId(): string | undefined { + // No-op here, but can be overridden + return this.viewId; + } + public setView(_view: string): void { // No-op here, but can be overridden return; diff --git a/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts b/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts index 9c04d9b4a..7202d1b3c 100644 --- a/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts +++ b/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts @@ -13,6 +13,8 @@ import { StatefulTableRow, TableCheckboxChange, TableCheckboxControl, + TableCheckboxControlOption, + TableCheckboxOptions, TableColumnConfig, TableControlOption, TableControlOptionType, @@ -22,6 +24,7 @@ import { TableRow, TableSelectChange, TableSelectControl, + TableSelectControlOption, TableStyle, ToggleItem, toInFilter @@ -31,7 +34,7 @@ import { Renderer } from '@hypertrace/hyperdash'; import { RendererApi, RENDERER_API } from '@hypertrace/hyperdash-angular'; import { capitalize, isEmpty, isEqual, pick } from 'lodash-es'; import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; -import { filter, map, pairwise, share, startWith, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { filter, first, map, pairwise, share, startWith, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { AttributeMetadata, toFilterAttributeType } from '../../../graphql/model/metadata/attribute-metadata'; import { MetadataService } from '../../../services/metadata/metadata.service'; import { InteractionHandler } from '../../interaction/interaction-handler'; @@ -41,6 +44,7 @@ import { SpecificationBackedTableColumnDef } from './table-widget-column.model'; import { TableWidgetViewToggleModel } from './table-widget-view-toggle.model'; import { TableWidgetModel } from './table-widget.model'; +// tslint:disable: max-file-line-count @Renderer({ modelClass: TableWidgetModel }) @Renderer({ modelClass: TableWidgetViewToggleModel }) @Component({ @@ -64,6 +68,7 @@ import { TableWidgetModel } from './table-widget.model'; [selectedRows]="this.selectedRows" [customControlContent]="(this.isCustomControlPresent | htMemoize) ? customControlDetail : undefined" [viewItems]="this.viewItems" + [activeViewItem]="this.activeViewItem$ | async" (searchChange)="this.onSearchChange($event)" (selectChange)="this.onSelectChange($event)" (checkboxChange)="this.onCheckboxChange($event)" @@ -105,7 +110,15 @@ import { TableWidgetModel } from './table-widget.model'; export class TableWidgetRendererComponent extends WidgetRenderer | undefined> implements OnInit { + private static readonly DEFAULT_PREFERENCES: TableWidgetPreferences = { + columns: [], + checkboxes: [] + }; + + private static readonly DEFAULT_TAB_INDEX: number = 0; + public viewItems: ToggleItem[] = []; + public activeViewItem$!: Observable>; public selectControls$!: Observable; public checkboxControls$!: Observable; @@ -137,11 +150,13 @@ export class TableWidgetRendererComponent public ngOnInit(): void { super.ngOnInit(); + this.viewItems = this.model.getViewOptions().map(viewOption => this.buildViewItem(viewOption)); + this.metadata$ = this.getScopeAttributes(); - this.columnConfigs$ = (isNonEmptyString(this.model.id) - ? this.preferenceService.get(this.model.id, []) - : of([]) - ).pipe(switchMap(persistedColumns => this.getColumnConfigs(persistedColumns))); + + this.activeViewItem$ = this.getActiveViewItem(); + + this.columnConfigs$ = this.getColumnConfigs(); this.combinedFilters$ = combineLatest([ this.toggleFilterSubject, @@ -150,11 +165,6 @@ export class TableWidgetRendererComponent ]).pipe( map(([toggleFilters, searchFilters, selectFilters]) => [...toggleFilters, ...searchFilters, ...selectFilters]) ); - - this.viewItems = this.model.getViewOptions().map(viewOption => ({ - label: capitalize(viewOption), - value: viewOption - })); } public getChildModel = (row: TableRow): object | undefined => this.model.getChildModel(row); @@ -177,24 +187,69 @@ export class TableWidgetRendererComponent } protected fetchAndPopulateSelectControls(): void { - this.selectControls$ = forkJoinSafeEmpty( - this.model - .getSelectControlOptions() - .filter(checkboxControlModel => checkboxControlModel.visible) - .map(selectControlModel => - // Fetch the values for the selectFilter dropdown - selectControlModel.getOptions().pipe( - take(1), - withLatestFrom(this.selectFilterSubject), - map(([options, filters]) => ({ - placeholder: selectControlModel.placeholder, - options: options.map(option => ({ - ...option, - applied: this.isFilterApplied(option.metaValue, filters) - })) - })) + this.selectControls$ = this.getSelectControls().pipe( + tap((selectControls: TableSelectControl[]) => { + selectControls.forEach(selectControl => + this.publishSelectValuesChange( + selectControl.options[0].metaValue.field, + selectControl.options.filter(o => o.applied) ) + ); + }) + ); + } + + protected fetchAndPopulateCheckboxControls(): void { + this.checkboxControls$ = this.getCheckboxControls().pipe( + tap((checkboxControls: TableCheckboxControl[]) => { + checkboxControls.forEach(checkboxControl => + this.publishCheckboxOptionChange( + checkboxControl.value ? checkboxControl.options[0] : checkboxControl.options[1] + ) + ); + }) + ); + } + + private getSelectControls(changed?: TableSelectControl): Observable { + return this.getPreferences().pipe( + take(1), + switchMap(preferences => + forkJoinSafeEmpty( + this.model + .getSelectControlOptions() + .filter(selectControlModel => selectControlModel.visible) + .map(selectControlModel => { + if (selectControlModel.placeholder === changed?.placeholder) { + return of(changed); + } + + // Fetch the values for the selectFilter dropdown + return selectControlModel.getOptions().pipe( + take(1), + withLatestFrom(this.selectFilterSubject), + map(([options, filters]) => { + const foundPreferences = preferences.selections + ? preferences.selections.find( + preferencesSelectionControl => + selectControlModel.placeholder === preferencesSelectionControl.placeholder + ) + : undefined; + + return ( + foundPreferences ?? { + placeholder: selectControlModel.placeholder, + options: options.map(option => ({ + ...option, + applied: this.isFilterApplied(option.metaValue, filters) + })) + } + ); + }) + ); + }) ) + ) ); } @@ -213,34 +268,6 @@ export class TableWidgetRendererComponent ); } - protected fetchAndPopulateCheckboxControls(): void { - this.checkboxControls$ = forkJoinSafeEmpty( - this.model - .getCheckboxControlOptions() - .filter(checkboxControlModel => checkboxControlModel.visible) - .map(checkboxControlModel => - checkboxControlModel.getOptions().pipe( - take(1), - map(options => ({ - label: checkboxControlModel.checked ? options[0].label : options[1].label, - value: checkboxControlModel.checked, - options: options - })) - ) - ) - ).pipe( - tap((checkboxControls: TableCheckboxControl[]) => { - // Apply initial values for checkboxes - checkboxControls.forEach(checkboxControl => { - this.onCheckboxChange({ - checkbox: checkboxControl, - option: checkboxControl.value ? checkboxControl.options[0] : checkboxControl.options[1] - }); - }); - }) - ); - } - public get syncWithUrl(): boolean { return this.model.style === TableStyle.FullPage; } @@ -249,39 +276,35 @@ export class TableWidgetRendererComponent return this.data$!.pipe(map(data => data?.getScope?.())); } - private getColumnConfigs(persistedColumns: TableColumnConfig[] = []): Observable { - return combineLatest([ - this.getScope(), - this.api.change$.pipe( - map(() => true), - startWith(true) - ) - ]).pipe( - switchMap(([scope]) => this.model.getColumns(scope)), - startWith([]), - map((columns: SpecificationBackedTableColumnDef[]) => - this.applySavedColumnPreferences(columns, persistedColumns) - ), - pairwise(), - filter(([previous, current]) => !isEqualIgnoreFunctions(previous, current)), - map(([_, current]) => current), - share(), - tap(() => this.onDashboardRefresh()) + private getActiveViewItem(): Observable> { + return this.getViewPreferences().pipe( + map(preferences => this.hydratePersistedActiveView(this.viewItems, preferences.activeView)) ); } - private applySavedColumnPreferences( - columns: SpecificationBackedTableColumnDef[], - persistedColumns: TableColumnConfig[] - ): SpecificationBackedTableColumnDef[] { - return columns.map(column => { - const found = persistedColumns.find(persistedColumn => persistedColumn.id === column.id); - - return { - ...column, // Apply default column config - ...(found ? found : {}) // Override with any saved properties - }; - }); + private getColumnConfigs(): Observable { + return this.getPreferences().pipe( + switchMap(preferences => + combineLatest([ + this.getScope(), + this.api.change$.pipe( + map(() => true), + startWith(true) + ) + ]).pipe( + switchMap(([scope]) => this.model.getColumns(scope)), + startWith([]), + map((columns: SpecificationBackedTableColumnDef[]) => + this.hydratePersistedColumnConfigs(columns, preferences.columns ?? []) + ), + pairwise(), + filter(([previous, current]) => !isEqualIgnoreFunctions(previous, current)), + map(([_, current]) => current), + share(), + tap(() => this.onDashboardRefresh()) + ) + ) + ); } private getScopeAttributes(): Observable { @@ -303,51 +326,120 @@ export class TableWidgetRendererComponent } public onSelectChange(changed: TableSelectChange): void { - if (changed.values.length === 0) { - this.selectFilterSubject.next(this.removeFilters(changed.select.options[0].metaValue.field)); + /* + * The caller doesn't modify the values, it just returns an array of which values are selected. + * We must apply the value change to the object, so we set all to false unless found in the changed value array. + */ + changed.select.options.forEach( + option => + (option.applied = changed.values.find(changedOption => changedOption.label === option.label) !== undefined) + ); + this.publishSelectValuesChange(changed.select.options[0].metaValue.field, changed.values); + + this.getSelectControls(changed.select).subscribe(tableSelectControls => + this.updateSelectionPreferences(tableSelectControls) + ); + } + + private publishSelectValuesChange(field: string, values: TableSelectControlOption[]): void { + if (values.length === 0) { + this.selectFilterSubject.next(this.removeFilters(field)); return; } - const tableFilters: TableFilter[] = changed.values.map((option: TableFilterControlOption) => option.metaValue); + const tableFilters: TableFilter[] = values.map((option: TableFilterControlOption) => option.metaValue); this.selectFilterSubject.next(this.mergeFilters(toInFilter(tableFilters))); } - public onCheckboxChange(changed: TableCheckboxChange): void { - switch (changed.option.type) { + private updateSelectionPreferences(tableSelectControls: TableSelectControl[]): void { + if (isNonEmptyString(this.model.getId())) { + this.getPreferences().subscribe(preferences => + this.setPreferences({ + ...preferences, + selections: tableSelectControls + }) + ); + } + } + + private publishCheckboxOptionChange(option: TableCheckboxControlOption): void { + switch (option.type) { case TableControlOptionType.Property: - this.queryPropertiesSubject.next(this.mergeQueryProperties(changed.option.metaValue)); + this.queryPropertiesSubject.next(this.mergeQueryProperties(option.metaValue)); break; case TableControlOptionType.Filter: - this.selectFilterSubject.next(this.mergeFilters(changed.option.metaValue)); + this.selectFilterSubject.next(this.mergeFilters(option.metaValue)); break; case TableControlOptionType.Unset: - this.selectFilterSubject.next(this.removeFilters(changed.option.metaValue)); + this.selectFilterSubject.next(this.removeFilters(option.metaValue)); break; default: - assertUnreachable(changed.option); + assertUnreachable(option); } + } - // Update checkbox option label + public onCheckboxChange(changed: TableCheckboxChange): void { + this.publishCheckboxOptionChange(changed.option); - this.checkboxControls$ = forkJoinSafeEmpty( - this.model.getCheckboxControlOptions().map(checkboxControlModel => - checkboxControlModel.getOptions().pipe( - take(1), - map(options => { - options.forEach(option => { - if (this.isLabeledOptionMatch(option, changed.option)) { - checkboxControlModel.checked = changed.option.value; - } - }); - - return { - label: checkboxControlModel.checked ? options[0].label : options[1].label, - value: checkboxControlModel.checked, - options: options - }; - }) + this.checkboxControls$ = this.getCheckboxControls(changed).pipe( + tap(tableCheckboxControls => this.updateCheckboxPreferences(tableCheckboxControls)) + ); + } + + private updateCheckboxPreferences(tableCheckboxControls: TableCheckboxControl[]): void { + if (isNonEmptyString(this.model.getId())) { + this.getPreferences().subscribe(preferences => + this.setPreferences({ + ...preferences, + checkboxes: tableCheckboxControls + }) + ); + } + } + + private getCheckboxControls(changed?: TableCheckboxChange): Observable { + return this.getPreferences().pipe( + switchMap(preferences => + forkJoinSafeEmpty( + this.model + .getCheckboxControlOptions() + .filter(checkboxControlModel => checkboxControlModel.visible) + .map(checkboxControlModel => + checkboxControlModel.getOptions().pipe( + take(1), + map((options: TableCheckboxOptions) => { + if (changed !== undefined) { + options.forEach(option => { + if (this.isLabeledOptionMatch(option, changed.option)) { + checkboxControlModel.checked = changed.option.value; + } + }); + + return { + label: checkboxControlModel.checked ? options[0].label : options[1].label, + value: checkboxControlModel.checked, + options: options + }; + } + + const found = preferences.checkboxes + ? preferences.checkboxes.find(preferencesCheckboxControl => + options.some(option => option.label === preferencesCheckboxControl.label) + ) + : undefined; + + return ( + found ?? { + label: checkboxControlModel.checked ? options[0].label : options[1].label, + value: checkboxControlModel.checked, + options: options + } + ); + }) + ) + ) ) ) ); @@ -368,14 +460,24 @@ export class TableWidgetRendererComponent public onViewChange(view: string): void { this.model.setView(view); + if (isNonEmptyString(this.model.getId())) { + this.getViewPreferences().subscribe(preferences => + this.setViewPreferences({ + ...preferences, + activeView: view + }) + ); + } this.columnConfigs$ = this.getColumnConfigs(); } public onColumnsChange(columns: TableColumnConfig[]): void { - if (isNonEmptyString(this.model.id)) { - this.preferenceService.set( - this.model.id, - columns.map(column => this.pickPersistColumnProperties(column)) + if (isNonEmptyString(this.model.getId())) { + this.getPreferences().subscribe(preferences => + this.setPreferences({ + ...preferences, + columns: columns.map(column => this.dehydratePersistedColumnConfig(column)) + }) ); } } @@ -406,7 +508,30 @@ export class TableWidgetRendererComponent return !isEmpty(matchedHandlers) ? matchedHandlers[0].handler : undefined; } - private pickPersistColumnProperties(column: TableColumnConfig): Pick { + private hydratePersistedActiveView( + viewItems: ToggleItem[], + persistedActiveView?: string + ): ToggleItem { + return persistedActiveView !== undefined + ? this.buildViewItem(persistedActiveView) + : viewItems[TableWidgetRendererComponent.DEFAULT_TAB_INDEX]; + } + + private hydratePersistedColumnConfigs( + columns: SpecificationBackedTableColumnDef[], + persistedColumns: TableColumnConfig[] + ): SpecificationBackedTableColumnDef[] { + return columns.map(column => { + const found = persistedColumns.find(persistedColumn => persistedColumn.id === column.id); + + return { + ...column, // Apply default column config + ...(found ? found : {}) // Override with any saved properties + }; + }); + } + + private dehydratePersistedColumnConfig(column: TableColumnConfig): PersistedTableColumnConfig { /* * Note: The table columns have nested methods, so those are lost here when persistService uses JSON.stringify * to convert and store. We want to just pluck the relevant properties that are required to be saved. @@ -414,6 +539,39 @@ export class TableWidgetRendererComponent return pick(column, ['id', 'visible']); } + private getViewPreferences(): Observable { + return isNonEmptyString(this.model.viewId) + ? this.preferenceService.get(this.model.viewId, {}).pipe(first()) + : of({}); + } + + private setViewPreferences(preferences: TableWidgetViewPreferences): void { + if (isNonEmptyString(this.model.viewId)) { + this.preferenceService.set(this.model.viewId, preferences); + } + } + + private getPreferences( + defaultPreferences: TableWidgetPreferences = TableWidgetRendererComponent.DEFAULT_PREFERENCES + ): Observable { + return isNonEmptyString(this.model.getId()) + ? this.preferenceService.get(this.model.getId()!, defaultPreferences).pipe(first()) + : of(defaultPreferences); + } + + private setPreferences(preferences: TableWidgetPreferences): void { + if (isNonEmptyString(this.model.getId())) { + this.preferenceService.set(this.model.getId()!, preferences); + } + } + + private buildViewItem(viewOption: string): ToggleItem { + return { + label: capitalize(viewOption), + value: viewOption + }; + } + private mergeFilters(tableFilter: TableFilter): TableFilter[] { const existingSelectFiltersWithChangedRemoved = this.removeFilters(tableFilter.field); @@ -431,3 +589,15 @@ export class TableWidgetRendererComponent }; } } + +interface TableWidgetViewPreferences { + activeView?: string; +} + +interface TableWidgetPreferences { + columns?: PersistedTableColumnConfig[]; + checkboxes?: TableCheckboxControl[]; + selections?: TableSelectControl[]; +} + +type PersistedTableColumnConfig = Pick; diff --git a/projects/observability/src/shared/dashboard/widgets/table/table-widget-view-toggle.model.ts b/projects/observability/src/shared/dashboard/widgets/table/table-widget-view-toggle.model.ts index 826d578dd..2da17a6a9 100644 --- a/projects/observability/src/shared/dashboard/widgets/table/table-widget-view-toggle.model.ts +++ b/projects/observability/src/shared/dashboard/widgets/table/table-widget-view-toggle.model.ts @@ -61,6 +61,10 @@ export class TableWidgetViewToggleModel extends TableWidgetModel implements Mode return found ? this.api.createChild(found.template) : undefined; } + public getId(): string | undefined { + return this.delegateModel && this.delegateModel?.getId(); + } + public getData(): Observable> { return this.delegateModel ? this.delegateModel?.getData() : NEVER; } diff --git a/projects/observability/src/shared/dashboard/widgets/table/table-widget.model.ts b/projects/observability/src/shared/dashboard/widgets/table/table-widget.model.ts index 7a6c89958..5d3acd07f 100644 --- a/projects/observability/src/shared/dashboard/widgets/table/table-widget.model.ts +++ b/projects/observability/src/shared/dashboard/widgets/table/table-widget.model.ts @@ -113,6 +113,10 @@ export class TableWidgetModel extends TableWidgetBaseModel { @ModelInject(TableWidgetColumnsService) private readonly tableWidgetColumnsService!: TableWidgetColumnsService; + public getId(): string | undefined { + return this.id; + } + public getData(): Observable> { return this.api.getData>(); }