diff --git a/tensorboard/webapp/metrics/actions/index.ts b/tensorboard/webapp/metrics/actions/index.ts index 55cf464875..be36857ecc 100644 --- a/tensorboard/webapp/metrics/actions/index.ts +++ b/tensorboard/webapp/metrics/actions/index.ts @@ -29,7 +29,6 @@ import { HeaderEditInfo, HeaderToggleInfo, HistogramMode, - MinMaxStep, PluginType, TooltipSort, XAxisType, diff --git a/tensorboard/webapp/metrics/store/metrics_selectors_test.ts b/tensorboard/webapp/metrics/store/metrics_selectors_test.ts index 28b63e1a70..24d02a0fe3 100644 --- a/tensorboard/webapp/metrics/store/metrics_selectors_test.ts +++ b/tensorboard/webapp/metrics/store/metrics_selectors_test.ts @@ -1830,30 +1830,30 @@ describe('metrics selectors', () => { }); expect(selectors.getGroupedHeadersForCard('card1')(state)).toEqual([ - { + jasmine.objectContaining({ type: ColumnHeaderType.RUN, name: 'run', displayName: 'My Run name', enabled: false, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.HPARAM, name: 'conv_layers', displayName: 'Conv Layers', enabled: true, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.HPARAM, name: 'conv_kernel_size', displayName: 'Conv Kernel Size', enabled: true, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.COLOR, name: 'color', displayName: 'Color', enabled: true, - }, + }), ]); }); @@ -1874,30 +1874,30 @@ describe('metrics selectors', () => { }); expect(selectors.getGroupedHeadersForCard('card1')(state)).toEqual([ - { + jasmine.objectContaining({ type: ColumnHeaderType.RUN, name: 'run', displayName: 'My Run name', enabled: false, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.HPARAM, name: 'conv_layers', displayName: 'Conv Layers', enabled: true, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.HPARAM, name: 'conv_kernel_size', displayName: 'Conv Kernel Size', enabled: true, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.MEAN, name: 'mean', displayName: 'Mean', enabled: true, - }, + }), ]); }); @@ -1914,30 +1914,30 @@ describe('metrics selectors', () => { }); expect(selectors.getGroupedHeadersForCard('card1')(state)).toEqual([ - { + jasmine.objectContaining({ type: ColumnHeaderType.RUN, name: 'run', displayName: 'My Run name', enabled: false, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.HPARAM, name: 'conv_layers', displayName: 'Conv Layers', enabled: true, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.HPARAM, name: 'conv_kernel_size', displayName: 'Conv Kernel Size', enabled: true, - }, - { + }), + jasmine.objectContaining({ type: ColumnHeaderType.MEAN, name: 'mean', displayName: 'Mean', enabled: true, - }, + }), ]); }); }); diff --git a/tensorboard/webapp/metrics/views/card_renderer/BUILD b/tensorboard/webapp/metrics/views/card_renderer/BUILD index 4d9f6ac0b3..470c226f15 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/BUILD +++ b/tensorboard/webapp/metrics/views/card_renderer/BUILD @@ -335,6 +335,8 @@ tf_ng_module( "//tensorboard/webapp/angular:expect_angular_material_progress_spinner", "//tensorboard/webapp/experiments:types", "//tensorboard/webapp/feature_flag/store", + "//tensorboard/webapp/hparams", + "//tensorboard/webapp/hparams:types", "//tensorboard/webapp/metrics:types", "//tensorboard/webapp/metrics/actions", "//tensorboard/webapp/metrics/data_source", @@ -456,6 +458,9 @@ tf_ts_library( "//tensorboard/webapp/angular:expect_angular_platform_browser_animations", "//tensorboard/webapp/angular:expect_ngrx_store_testing", "//tensorboard/webapp/experiments:types", + "//tensorboard/webapp/hparams/_redux:hparams_actions", + "//tensorboard/webapp/hparams/_redux:hparams_selectors", + "//tensorboard/webapp/hparams/_redux:types", "//tensorboard/webapp/metrics:test_lib", "//tensorboard/webapp/metrics:types", "//tensorboard/webapp/metrics/actions", @@ -463,6 +468,7 @@ tf_ts_library( "//tensorboard/webapp/metrics/store", "//tensorboard/webapp/metrics/store:types", "//tensorboard/webapp/metrics/views/main_view:common_selectors", + "//tensorboard/webapp/runs/store:selectors", "//tensorboard/webapp/runs/store:testing", "//tensorboard/webapp/runs/store:types", "//tensorboard/webapp/testing:mat_icon", diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html index b26d5617db..964d74cfe5 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html @@ -201,8 +201,15 @@ [columnContextMenusEnabled]="columnContextMenusEnabled" [smoothingEnabled]="smoothingEnabled" [hparamsEnabled]="hparamsEnabled" + [columnFilters]="columnFilters" + [runToHparamMap]="runToHparamMap" + [selectableColumns]="selectableColumns" (sortDataBy)="sortDataBy($event)" (editColumnHeaders)="editColumnHeaders.emit($event)" + (addColumn)="addColumn.emit($event)" + (removeColumn)="removeColumn.emit($event)" + (hideColumn)="hideColumn.emit($event)" + (addFilter)="addFilter.emit($event)" > diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts index 7ea60f216a..d92e70474d 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts @@ -44,7 +44,12 @@ import { TooltipDatum, } from '../../../widgets/line_chart_v2/types'; import {CardState} from '../../store'; -import {HeaderEditInfo, TooltipSort, XAxisType} from '../../types'; +import { + HeaderEditInfo, + HeaderToggleInfo, + TooltipSort, + XAxisType, +} from '../../types'; import { MinMaxStep, ScalarCardDataSeries, @@ -56,8 +61,13 @@ import { DataTableMode, SortingInfo, SortingOrder, + DiscreteFilter, + IntervalFilter, + FilterAddedEvent, + AddColumnEvent, } from '../../../widgets/data_table/types'; import {isDatumVisible, TimeSelectionView} from './utils'; +import {RunToHparamMap} from '../../../runs/types'; type ScalarTooltipDatum = TooltipDatum< ScalarCardSeriesMetadata & { @@ -103,6 +113,9 @@ export class ScalarCardComponent { @Input() columnHeaders!: ColumnHeader[]; @Input() rangeEnabled!: boolean; @Input() hparamsEnabled?: boolean; + @Input() columnFilters!: Map; + @Input() selectableColumns!: ColumnHeader[]; + @Input() runToHparamMap!: RunToHparamMap; @Output() onFullSizeToggle = new EventEmitter(); @Output() onPinClicked = new EventEmitter(); @@ -115,6 +128,9 @@ export class ScalarCardComponent { @Output() onDataTableSorting = new EventEmitter(); @Output() editColumnHeaders = new EventEmitter(); @Output() openTableEditMenuToMode = new EventEmitter(); + @Output() addColumn = new EventEmitter(); + @Output() removeColumn = new EventEmitter(); + @Output() addFilter = new EventEmitter(); @Output() onLineChartZoom = new EventEmitter(); @Output() onCardStateChanged = new EventEmitter>(); diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_container.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_container.ts index 571707121c..13ec624875 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_container.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_container.ts @@ -37,6 +37,7 @@ import { } from 'rxjs/operators'; import {State} from '../../../app_state'; import {ExperimentAlias} from '../../../experiments/types'; +import {actions as hparamsActions} from '../../../hparams'; import { getEnableHparamsInTimeSeries, getForceSvgFeatureFlag, @@ -58,8 +59,8 @@ import { getRun, getRunColorMap, getCurrentRouteRunSelection, - getColumnHeadersForCard, - getDashboardRunsToHparams, + getGroupedHeadersForCard, + getRunToHparamMap, } from '../../../selectors'; import {DataLoadState} from '../../../types/data'; import { @@ -79,6 +80,7 @@ import { timeSelectionChanged, metricsSlideoutMenuOpened, dataTableColumnOrderChanged, + dataTableColumnToggled, } from '../../actions'; import {PluginType, ScalarStepDatum} from '../../data_source'; import { @@ -94,8 +96,19 @@ import { getMetricsXAxisType, RunToSeries, } from '../../store'; -import {CardId, CardMetadata, HeaderEditInfo, XAxisType} from '../../types'; -import {getFilteredRenderableRunsIds} from '../main_view/common_selectors'; +import { + CardId, + CardMetadata, + HeaderEditInfo, + HeaderToggleInfo, + XAxisType, +} from '../../types'; +import {RunToHparamMap} from '../../../runs/types'; +import { + getFilteredRenderableRunsIds, + getCurrentColumnFilters, + getSelectableColumns, +} from '../main_view/common_selectors'; import {CardRenderer} from '../metrics_view_types'; import {getTagDisplayName} from '../utils'; import {DataDownloadDialogContainer} from './data_download_dialog_container'; @@ -112,6 +125,8 @@ import { ColumnHeader, DataTableMode, SortingInfo, + FilterAddedEvent, + AddColumnEvent, } from '../../../widgets/data_table/types'; import { maybeClipTimeSelectionView, @@ -176,6 +191,9 @@ function areSeriesEqual( [columnHeaders]="columnHeaders$ | async" [rangeEnabled]="rangeEnabled$ | async" [hparamsEnabled]="hparamsEnabled$ | async" + [columnFilters]="columnFilters$ | async" + [runToHparamMap]="runToHparamMap$ | async" + [selectableColumns]="selectableColumns$ | async" (onFullSizeToggle)="onFullSizeToggle()" (onPinClicked)="pinStateChanged.emit($event)" observeIntersection @@ -187,6 +205,9 @@ function areSeriesEqual( (editColumnHeaders)="editColumnHeaders($event)" (onCardStateChanged)="onCardStateChanged($event)" (openTableEditMenuToMode)="openTableEditMenuToMode($event)" + (addColumn)="onAddColumn($event)" + (removeColumn)="onRemoveColumn($event)" + (addFilter)="addHparamFilter($event)" > `, styles: [ @@ -226,6 +247,9 @@ export class ScalarCardContainer implements CardRenderer, OnInit, OnDestroy { cardState$?: Observable>; rangeEnabled$?: Observable; hparamsEnabled$?: Observable; + columnFilters$ = this.store.select(getCurrentColumnFilters); + runToHparamMap$?: Observable; + selectableColumns$?: Observable; onVisibilityChange({visible}: {visible: boolean}) { this.isVisible = visible; @@ -464,7 +488,7 @@ export class ScalarCardContainer implements CardRenderer, OnInit, OnDestroy { ); this.columnHeaders$ = this.store.select( - getColumnHeadersForCard(this.cardId) + getGroupedHeadersForCard(this.cardId) ); this.chartMetadataMap$ = partitionedSeries$.pipe( @@ -593,6 +617,10 @@ export class ScalarCardContainer implements CardRenderer, OnInit, OnDestroy { ); this.hparamsEnabled$ = this.store.select(getEnableHparamsInTimeSeries); + + this.runToHparamMap$ = this.store.select(getRunToHparamMap); + + this.selectableColumns$ = this.store.select(getSelectableColumns); } ngOnDestroy() { @@ -679,11 +707,55 @@ export class ScalarCardContainer implements CardRenderer, OnInit, OnDestroy { ); } - editColumnHeaders(headerEditInfo: HeaderEditInfo) { - this.store.dispatch(dataTableColumnOrderChanged(headerEditInfo)); + editColumnHeaders({ + source, + destination, + side, + dataTableMode, + }: HeaderEditInfo) { + if (source.type === 'HPARAM') { + this.store.dispatch( + hparamsActions.dashboardHparamColumnOrderChanged({ + source, + destination, + side, + }) + ); + } else { + this.store.dispatch( + dataTableColumnOrderChanged({source, destination, side, dataTableMode}) + ); + } } openTableEditMenuToMode(tableMode: DataTableMode) { this.store.dispatch(metricsSlideoutMenuOpened({mode: tableMode})); } + + onAddColumn(addColumnEvent: AddColumnEvent) { + this.store.dispatch( + hparamsActions.dashboardHparamColumnAdded(addColumnEvent) + ); + } + + onRemoveColumn({header, dataTableMode}: HeaderToggleInfo) { + if (header.type === 'HPARAM') { + this.store.dispatch( + hparamsActions.dashboardHparamColumnRemoved({column: header}) + ); + } else { + this.store.dispatch( + dataTableColumnToggled({header, cardId: this.cardId, dataTableMode}) + ); + } + } + + addHparamFilter(event: FilterAddedEvent) { + this.store.dispatch( + hparamsActions.dashboardHparamFilterAdded({ + name: event.name, + filter: event.value, + }) + ); + } } diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ng.html b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ng.html index c17700e465..8c06670b8e 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ng.html +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ng.html @@ -18,8 +18,13 @@ diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ts index 00feae5640..64845bb401 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ts @@ -21,11 +21,13 @@ import { } from '@angular/core'; import {TimeSelection} from '../../../widgets/card_fob/card_fob_types'; import {findClosestIndex} from '../../../widgets/line_chart_v2/sub_view/line_chart_interactive_utils'; -import {HeaderEditInfo} from '../../types'; +import {HeaderEditInfo, HeaderToggleInfo} from '../../types'; +import {RunToHparamMap} from '../../../runs/types'; import { ScalarCardDataSeries, ScalarCardPoint, ScalarCardSeriesMetadataMap, + SmoothedSeriesMetadata, } from './scalar_card_types'; import { ColumnHeader, @@ -34,7 +36,11 @@ import { TableData, SortingInfo, SortingOrder, + DiscreteFilter, + IntervalFilter, + FilterAddedEvent, ReorderColumnEvent, + AddColumnEvent, } from '../../../widgets/data_table/types'; import {isDatumVisible} from './utils'; @@ -54,9 +60,15 @@ export class ScalarCardDataTable { @Input() columnContextMenusEnabled!: boolean; @Input() smoothingEnabled!: boolean; @Input() hparamsEnabled?: boolean; + @Input() columnFilters!: Map; + @Input() selectableColumns!: ColumnHeader[]; + @Input() runToHparamMap!: RunToHparamMap; @Output() sortDataBy = new EventEmitter(); @Output() editColumnHeaders = new EventEmitter(); + @Output() addColumn = new EventEmitter(); + @Output() removeColumn = new EventEmitter(); + @Output() addFilter = new EventEmitter(); ColumnHeaderType = ColumnHeaderType; @@ -70,6 +82,7 @@ export class ScalarCardDataTable { }, ].concat(this.columnHeaders); } + getMinPointInRange( points: ScalarCardPoint[], startPointIndex: number, @@ -252,6 +265,13 @@ export class ScalarCardDataTable { selectedStepData[header.name] = closestEndPoint.value - closestStartPoint.value; continue; + case ColumnHeaderType.HPARAM: + const runId = + (metadata as SmoothedSeriesMetadata).originalSeriesId || + metadata.id; + selectedStepData[header.name] = + this.runToHparamMap?.[runId].get(header.name) ?? ''; + continue; default: continue; } @@ -305,6 +325,10 @@ export class ScalarCardDataTable { dataTableMode: this.getDataTableMode(), }); } + + onRemoveColumn(header: ColumnHeader) { + this.removeColumn.emit({header, dataTableMode: this.getDataTableMode()}); + } } function makeValueSortable( diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts index 894c80893c..f4284acb76 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts @@ -114,9 +114,13 @@ import { SeriesType, } from './scalar_card_types'; import { + AddColumnEvent, ColumnHeader, ColumnHeaderType, DataTableMode, + DomainType, + FilterAddedEvent, + IntervalFilter, ReorderColumnEvent, Side, SortingOrder, @@ -128,6 +132,10 @@ import * as commonSelectors from '../main_view/common_selectors'; import {ContentCellComponent} from '../../../widgets/data_table/content_cell_component'; import {ContentRowComponent} from '../../../widgets/data_table/content_row_component'; import {HeaderCellComponent} from '../../../widgets/data_table/header_cell_component'; +import {HparamFilter} from '../../../hparams/_redux/types'; +import * as hparamsSelectors from '../../../hparams/_redux/hparams_selectors'; +import * as hparamsActions from '../../../hparams/_redux/hparams_actions'; +import * as runsSelectors from '../../../runs/store/runs_selectors'; import {getIsScalarColumnContextMenusEnabled} from '../../../selectors'; @Component({ @@ -2785,6 +2793,133 @@ describe('scalar card', () => { contentCellTypes.find((type) => type === ColumnHeaderType.SMOOTHED) ).toBeFalsy(); })); + + it('passes columnFilters to table', fakeAsync(() => { + store.overrideSelector( + commonSelectors.getCurrentColumnFilters, + new Map([ + [ + 'discrete hparam', + { + type: DomainType.DISCRETE, + includeUndefined: true, + possibleValues: [2, 4, 6, 8], + filterValues: [2, 4, 6, 8], + }, + ], + [ + 'interval metric', + { + type: DomainType.INTERVAL, + includeUndefined: true, + minValue: 2, + maxValue: 5, + filterLowerValue: 2, + filterUpperValue: 5, + }, + ], + ]) + ); + const fixture = createComponent('card1'); + fixture.detectChanges(); + + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + + expect(dataTableComponentInstance.columnFilters).toEqual( + new Map([ + [ + 'discrete hparam', + { + type: DomainType.DISCRETE, + includeUndefined: true, + possibleValues: [2, 4, 6, 8], + filterValues: [2, 4, 6, 8], + }, + ], + [ + 'interval metric', + { + type: DomainType.INTERVAL, + includeUndefined: true, + minValue: 2, + maxValue: 5, + filterLowerValue: 2, + filterUpperValue: 5, + }, + ], + ]) + ); + })); + + it('passes selectableColumns to table', fakeAsync(() => { + store.overrideSelector(commonSelectors.getSelectableColumns, [ + { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }, + { + type: ColumnHeaderType.HPARAM, + name: 'conv_kernel_size', + displayName: 'Conv Kernel Size', + enabled: true, + }, + ]); + const fixture = createComponent('card1'); + fixture.detectChanges(); + + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + + expect(dataTableComponentInstance.selectableColumns).toEqual([ + { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }, + { + type: ColumnHeaderType.HPARAM, + name: 'conv_kernel_size', + displayName: 'Conv Kernel Size', + enabled: true, + }, + ]); + })); + + it('disables context menus when column customization is disabled', fakeAsync(() => { + store.overrideSelector( + selectors.getIsScalarColumnCustomizationEnabled, + false + ); + const fixture = createComponent('card1'); + fixture.detectChanges(); + + const headerCellComponentInstance = fixture.debugElement.query( + By.directive(HeaderCellComponent) + ).componentInstance; + + expect(headerCellComponentInstance.disableContextMenu).toBeTrue; + })); + + it('enables context menus when column customization is enabled', fakeAsync(() => { + store.overrideSelector( + selectors.getIsScalarColumnCustomizationEnabled, + true + ); + const fixture = createComponent('card1'); + fixture.detectChanges(); + + const headerCellComponentInstance = fixture.debugElement.query( + By.directive(HeaderCellComponent) + ).componentInstance; + + expect(headerCellComponentInstance.disableContextMenu).toBeFalse; + })); }); describe('line chart integration', () => { @@ -3860,20 +3995,48 @@ describe('scalar card', () => { ]) ); + store.overrideSelector(getMetricsLinkedTimeSelection, { + start: {step: 1}, + end: null, + }); + store.overrideSelector( commonSelectors.getFilteredRenderableRunsIds, new Set(['run1']) ); - store.overrideSelector(getMetricsLinkedTimeSelection, { - start: {step: 1}, - end: null, + store.overrideSelector(selectors.getEnableHparamsInTimeSeries, true); + + store.overrideSelector( + hparamsSelectors.getDashboardDisplayedHparamColumns, + [ + { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }, + { + type: ColumnHeaderType.HPARAM, + name: 'conv_kernel_size', + displayName: 'Conv Kernel Size', + enabled: true, + }, + ] + ); + store.overrideSelector(runsSelectors.getRunToHparamMap, { + run1: new Map([ + ['conv_layers', 1], + ['conv_kernel_size', 2], + ]), + run2: new Map([ + ['conv_layers', 3], + ['conv_kernel_size', 4], + ]), }); }); it('filters runs by hparam when enableHparamsInTimeSeries is true', fakeAsync(() => { - store.overrideSelector(selectors.getEnableHparamsInTimeSeries, true); - const fixture = createComponent('card1'); const scalarCardDataTable = fixture.debugElement.query( By.directive(ScalarCardDataTable) @@ -3881,13 +4044,13 @@ describe('scalar card', () => { const data = scalarCardDataTable.componentInstance.getTimeSelectionTableData(); + expect(data.length).toEqual(1); expect(data[0].run).toEqual('run1'); })); it('does not filter runs by hparam when enableHparamsInTimeSeries is false', fakeAsync(() => { store.overrideSelector(selectors.getEnableHparamsInTimeSeries, false); - const fixture = createComponent('card1'); const scalarCardDataTable = fixture.debugElement.query( By.directive(ScalarCardDataTable) @@ -3895,10 +4058,66 @@ describe('scalar card', () => { const data = scalarCardDataTable.componentInstance.getTimeSelectionTableData(); + expect(data.length).toEqual(2); expect(data[0].run).toEqual('run1'); expect(data[1].run).toEqual('run2'); })); + + it('shows hparam values for selected hparam columns', fakeAsync(() => { + store.overrideSelector( + commonSelectors.getFilteredRenderableRunsIds, + new Set(['run1', 'run2']) + ); + const fixture = createComponent('card1'); + const scalarCardDataTable = fixture.debugElement.query( + By.directive(ScalarCardDataTable) + ); + + const data = + scalarCardDataTable.componentInstance.getTimeSelectionTableData(); + + expect(data).toEqual([ + jasmine.objectContaining({ + id: 'run1', + conv_layers: 1, + conv_kernel_size: 2, + }), + jasmine.objectContaining({ + id: 'run2', + conv_layers: 3, + conv_kernel_size: 4, + }), + ]); + })); + + it('shows hparam values with smoothing enabled', fakeAsync(() => { + store.overrideSelector( + commonSelectors.getFilteredRenderableRunsIds, + new Set(['run1', 'run2']) + ); + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0.3); + const fixture = createComponent('card1'); + const scalarCardDataTable = fixture.debugElement.query( + By.directive(ScalarCardDataTable) + ); + + const data = + scalarCardDataTable.componentInstance.getTimeSelectionTableData(); + + expect(data).toEqual([ + jasmine.objectContaining({ + id: '["smoothed","run1"]', + conv_layers: 1, + conv_kernel_size: 2, + }), + jasmine.objectContaining({ + id: '["smoothed","run2"]', + conv_layers: 3, + conv_kernel_size: 4, + }), + ]); + })); }); }); @@ -4560,6 +4779,220 @@ describe('scalar card', () => { }), ]); })); + + it('dispatches dashboardHparamColumnOrderChanged when reordering hparam columns', fakeAsync(() => { + store.overrideSelector(getCardStateMap, { + card1: { + dataMinMax: { + minStep: 0, + maxStep: 100, + }, + }, + }); + store.overrideSelector(getMetricsCardTimeSelection, { + start: {step: 1}, + end: null, + }); + store.overrideSelector(selectors.getMetricsStepSelectorEnabled, true); + const fixture = createComponent('card1'); + fixture.detectChanges(); + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + const reorderColumnEvent: ReorderColumnEvent = { + source: { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }, + destination: { + type: ColumnHeaderType.HPARAM, + name: 'conv_kernel_size', + displayName: 'Conv Kernel Size', + enabled: true, + }, + side: Side.RIGHT, + }; + + dataTableComponentInstance.orderColumns.emit(reorderColumnEvent); + + expect(dispatchedActions).toEqual([ + hparamsActions.dashboardHparamColumnOrderChanged(reorderColumnEvent), + ]); + })); + + it('dispatches dashboardHparamColumnAdded on column add event', fakeAsync(() => { + store.overrideSelector(getCardStateMap, { + card1: { + dataMinMax: { + minStep: 0, + maxStep: 100, + }, + }, + }); + store.overrideSelector(getMetricsCardTimeSelection, { + start: {step: 1}, + end: null, + }); + store.overrideSelector(selectors.getMetricsStepSelectorEnabled, true); + const fixture = createComponent('card1'); + fixture.detectChanges(); + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + const addColumnEvent: AddColumnEvent = { + column: { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }, + nextTo: { + type: ColumnHeaderType.HPARAM, + name: 'conv_kernel_size', + displayName: 'Conv Kernel Size', + enabled: true, + }, + side: Side.RIGHT, + }; + + dataTableComponentInstance.addColumn.emit(addColumnEvent); + + expect(dispatchedActions).toEqual([ + hparamsActions.dashboardHparamColumnAdded(addColumnEvent), + ]); + })); + + it('dispatches dashboardHparamColumnRemoved on column remove event for hparam columns', fakeAsync(() => { + store.overrideSelector(getCardStateMap, { + card1: { + dataMinMax: { + minStep: 0, + maxStep: 100, + }, + }, + }); + store.overrideSelector(getMetricsCardTimeSelection, { + start: {step: 1}, + end: null, + }); + store.overrideSelector(selectors.getMetricsStepSelectorEnabled, true); + const fixture = createComponent('card1'); + fixture.detectChanges(); + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + const removeColumnEvent: ColumnHeader = { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }; + + dataTableComponentInstance.removeColumn.emit(removeColumnEvent); + + expect(dispatchedActions).toEqual([ + hparamsActions.dashboardHparamColumnRemoved({ + column: removeColumnEvent, + }), + ]); + })); + + [ + { + testDesc: 'for single selection', + timeSelectionOverride: { + start: {step: 1}, + end: null, + }, + expectedDataTableMode: DataTableMode.SINGLE, + }, + { + testDesc: 'for range selection', + timeSelectionOverride: { + start: {step: 1}, + end: {step: 20}, + }, + expectedDataTableMode: DataTableMode.RANGE, + }, + ].forEach(({testDesc, timeSelectionOverride, expectedDataTableMode}) => { + it(`dispatches dataTableColumnToggled on column remove event for standard columns ${testDesc}`, fakeAsync(() => { + store.overrideSelector(getCardStateMap, { + card1: { + dataMinMax: { + minStep: 0, + maxStep: 100, + }, + }, + }); + store.overrideSelector( + getMetricsCardTimeSelection, + timeSelectionOverride + ); + store.overrideSelector(selectors.getMetricsStepSelectorEnabled, true); + const fixture = createComponent('card1'); + fixture.detectChanges(); + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + const columnToRemove: ColumnHeader = { + type: ColumnHeaderType.VALUE, + name: 'value', + displayName: 'Value', + enabled: true, + }; + + dataTableComponentInstance.removeColumn.emit(columnToRemove); + + expect(dispatchedActions).toEqual([ + dataTableColumnToggled({ + header: columnToRemove, + cardId: 'card1', + dataTableMode: expectedDataTableMode, + }), + ]); + })); + }); + + it('dispatches dashboardHparamFilterAdded on column filter event', fakeAsync(() => { + store.overrideSelector(getCardStateMap, { + card1: { + dataMinMax: { + minStep: 0, + maxStep: 100, + }, + }, + }); + store.overrideSelector(getMetricsCardTimeSelection, { + start: {step: 1}, + end: null, + }); + store.overrideSelector(selectors.getMetricsStepSelectorEnabled, true); + const fixture = createComponent('card1'); + fixture.detectChanges(); + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + const filterAddedEvent: FilterAddedEvent = { + name: 'conv_kernel_size', + value: { + type: DomainType.DISCRETE, + includeUndefined: true, + filterValues: [5], + possibleValues: [5, 7, 8], + }, + }; + + dataTableComponentInstance.addFilter.emit(filterAddedEvent); + + expect(dispatchedActions).toEqual([ + hparamsActions.dashboardHparamFilterAdded({ + name: filterAddedEvent.name, + filter: filterAddedEvent.value, + }), + ]); + })); }); }); diff --git a/tensorboard/webapp/runs/data_source/runs_data_source_types.ts b/tensorboard/webapp/runs/data_source/runs_data_source_types.ts index 73c77f88fe..dc7d466cb1 100644 --- a/tensorboard/webapp/runs/data_source/runs_data_source_types.ts +++ b/tensorboard/webapp/runs/data_source/runs_data_source_types.ts @@ -76,8 +76,3 @@ export interface Run { export abstract class RunsDataSource { abstract fetchRuns(experimentId: string): Observable; } - -export type RunToHParamValues = Record< - string, - Map ->; diff --git a/tensorboard/webapp/runs/store/runs_selectors.ts b/tensorboard/webapp/runs/store/runs_selectors.ts index ef9b7fe204..6cc9e0372b 100644 --- a/tensorboard/webapp/runs/store/runs_selectors.ts +++ b/tensorboard/webapp/runs/store/runs_selectors.ts @@ -14,7 +14,7 @@ limitations under the License. ==============================================================================*/ import {createFeatureSelector, createSelector} from '@ngrx/store'; import {DataLoadState, LoadState} from '../../types/data'; -import {GroupBy} from '../types'; +import {GroupBy, RunToHparamMap} from '../types'; import { ExperimentId, Run, @@ -152,6 +152,19 @@ export const getDashboardRunsToHparams = createSelector( } ); +export const getRunToHparamMap = createSelector( + getDashboardRunsToHparams, + (runToHparamsAndMetrics: RunToHparamsAndMetrics): RunToHparamMap => { + const runToHparamMap: RunToHparamMap = {}; + for (const [runName, {hparams}] of Object.entries(runToHparamsAndMetrics)) { + runToHparamMap[runName] = new Map( + hparams.map(({name, value}) => [name, value]) + ); + } + return runToHparamMap; + } +); + /** * Get the runs used on the dashboard. */ diff --git a/tensorboard/webapp/runs/store/runs_selectors_test.ts b/tensorboard/webapp/runs/store/runs_selectors_test.ts index 80651a40ea..92ecd077cc 100644 --- a/tensorboard/webapp/runs/store/runs_selectors_test.ts +++ b/tensorboard/webapp/runs/store/runs_selectors_test.ts @@ -490,6 +490,65 @@ describe('runs_selectors', () => { }); }); + describe('#getRunToHparamMap', () => { + it('returns a map from run id to hparam name to hparam value', () => { + const state = buildMockState({ + ...buildStateFromAppRoutingState( + buildAppRoutingState({ + activeRoute: { + routeKind: RouteKind.COMPARE_EXPERIMENT, + params: {experimentIds: 'exp1:123,exp2:456,exp3:789'}, + }, + }) + ), + ...buildStateFromHparamsState( + buildHparamsState({ + dashboardSessionGroups: [ + buildSessionGroup({ + name: 'SessionGroup1', + sessions: [{name: 's1'}], + hparams: {hparam: 'value1'}, + }), + buildSessionGroup({ + name: 'SessionGroup2', + sessions: [{name: 's2'}], + hparams: {hparam: 'value2'}, + }), + buildSessionGroup({ + name: 'SessionGrup3', + sessions: [{name: 's3'}, {name: 's4'}, {name: 's5'}], + hparams: {hparam: 'value3'}, + }), + ], + }) + ), + ...buildStateFromRunsState( + buildRunsState({ + runIds: { + 123: ['s2/run1', 's2/run2'], + 456: ['s1/run1', 's3/run1', 's4/run1', 's4/run2', 's5/run1'], + // One experiment has a run that does not match any of the + // sessions and, so, is not included in the result. + 789: ['does_not_match'], + // Additional experiment's runs are not included in the result. + AAA: ['s1/run1'], + }, + }) + ), + }); + + expect(selectors.getRunToHparamMap(state)).toEqual({ + 's2/run1': new Map([['hparam', 'value2']]), + 's2/run2': new Map([['hparam', 'value2']]), + 's1/run1': new Map([['hparam', 'value1']]), + 's3/run1': new Map([['hparam', 'value3']]), + 's4/run1': new Map([['hparam', 'value3']]), + 's4/run2': new Map([['hparam', 'value3']]), + 's5/run1': new Map([['hparam', 'value3']]), + }); + }); + }); + describe('#getDashboardRuns', () => { it('returns runs', () => { const state = buildMockState({ diff --git a/tensorboard/webapp/runs/types.ts b/tensorboard/webapp/runs/types.ts index 8a284d1c39..07d2c73bb8 100644 --- a/tensorboard/webapp/runs/types.ts +++ b/tensorboard/webapp/runs/types.ts @@ -12,9 +12,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ -import {Run} from './data_source/runs_data_source_types'; +import {Run, HparamValue} from './data_source/runs_data_source_types'; -export {Run} from './data_source/runs_data_source_types'; +export {Run, DiscreteHparamValue} from './data_source/runs_data_source_types'; export type ExperimentIdToRuns = Record< string, @@ -58,3 +58,7 @@ export interface URLDeserializedState { regexFilter: string | null; }; } + +export type HparamMap = Map; + +export type RunToHparamMap = Record; diff --git a/tensorboard/webapp/widgets/custom_modal/custom_modal_component.ts b/tensorboard/webapp/widgets/custom_modal/custom_modal_component.ts index d082de4911..a2e1a22539 100644 --- a/tensorboard/webapp/widgets/custom_modal/custom_modal_component.ts +++ b/tensorboard/webapp/widgets/custom_modal/custom_modal_component.ts @@ -82,10 +82,11 @@ export class CustomModalComponent implements OnInit { public openAtPosition(position: {x: number; y: number}) { const root = this.viewRef.element.nativeElement; - const top = root.getBoundingClientRect().top; - if (top !== 0) { - root.style.top = top * -1 + root.offsetTop + 'px'; - } + // Set left/top to viewport (0,0) if the element has another "containing block" ancestor. + root.style.top = `${root.offsetTop - root.getBoundingClientRect().top}px`; + root.style.left = `${ + root.offsetLeft - root.getBoundingClientRect().left + }px`; this.content.nativeElement.style.left = position.x + 'px'; this.content.nativeElement.style.top = position.y + 'px'; diff --git a/tensorboard/webapp/widgets/data_table/BUILD b/tensorboard/webapp/widgets/data_table/BUILD index 9246d59017..cbe0766af7 100644 --- a/tensorboard/webapp/widgets/data_table/BUILD +++ b/tensorboard/webapp/widgets/data_table/BUILD @@ -79,6 +79,7 @@ tf_ng_module( ":data_table_header", ":filter_dialog", ":types", + ":utils", "//tensorboard/webapp/angular:expect_angular_material_button", "//tensorboard/webapp/angular:expect_angular_material_icon", "//tensorboard/webapp/angular:expect_angular_material_progress_spinner", diff --git a/tensorboard/webapp/widgets/data_table/data_table_component.ts b/tensorboard/webapp/widgets/data_table/data_table_component.ts index 09224b3c3b..8521f4d1c4 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_component.ts +++ b/tensorboard/webapp/widgets/data_table/data_table_component.ts @@ -44,6 +44,7 @@ import {CustomModalComponent} from '../custom_modal/custom_modal_component'; import {ColumnSelectorComponent} from './column_selector_component'; import {ContentCellComponent} from './content_cell_component'; import {RangeValues} from '../range_input/types'; +import {dataTableUtils} from './utils'; const preventDefault = function (e: MouseEvent) { e.preventDefault(); @@ -60,7 +61,6 @@ export class DataTableComponent implements OnDestroy, AfterContentInit { // displayed in the table. @Input() headers!: ColumnHeader[]; @Input() sortingInfo!: SortingInfo; - @Input() columnCustomizationEnabled!: boolean; @Input() selectableColumns?: ColumnHeader[]; @Input() columnFilters!: Map; @Input() loading: boolean = false; @@ -201,6 +201,16 @@ export class DataTableComponent implements OnDestroy, AfterContentInit { ) { return; } + const draggingHeader = this.getHeaderByName(this.draggingHeaderName); + // Prevent drag between groups + if ( + draggingHeader && + dataTableUtils.columnToGroup(header) !== + dataTableUtils.columnToGroup(draggingHeader) + ) { + return; + } + if ( this.getIndexOfHeaderWithName(header.name) < this.getIndexOfHeaderWithName(this.draggingHeaderName!) @@ -303,7 +313,8 @@ export class DataTableComponent implements OnDestroy, AfterContentInit { return ( this.selectableColumns && this.selectableColumns.length && - this.contextMenuHeader?.movable + this.contextMenuHeader?.movable && + this.contextMenuHeader?.type === 'HPARAM' ); } diff --git a/tensorboard/webapp/widgets/data_table/data_table_test.ts b/tensorboard/webapp/widgets/data_table/data_table_test.ts index 0356f9c76a..aed7559e29 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_test.ts +++ b/tensorboard/webapp/widgets/data_table/data_table_test.ts @@ -411,9 +411,9 @@ describe('data table', () => { enabled: true, }, { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', + type: ColumnHeaderType.SMOOTHED, + name: 'smoothed', + displayName: 'Smoothed', enabled: true, }, { @@ -450,9 +450,9 @@ describe('data table', () => { expect(orderColumnsSpy).toHaveBeenCalledOnceWith({ source: { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', + type: ColumnHeaderType.SMOOTHED, + name: 'smoothed', + displayName: 'Smoothed', enabled: true, }, destination: { @@ -475,9 +475,9 @@ describe('data table', () => { enabled: true, }, { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', + type: ColumnHeaderType.SMOOTHED, + name: 'smoothed', + displayName: 'Smoothed', enabled: true, }, { @@ -514,9 +514,9 @@ describe('data table', () => { expect(orderColumnsSpy).toHaveBeenCalledOnceWith({ source: { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', + type: ColumnHeaderType.SMOOTHED, + name: 'smoothed', + displayName: 'Smoothed', enabled: true, }, destination: { @@ -529,6 +529,51 @@ describe('data table', () => { }); }); + it('does not emit orderColumns when dragging between column groups', () => { + const fixture = createComponent({ + headers: [ + { + type: ColumnHeaderType.VALUE, + name: 'value', + displayName: 'Value', + enabled: true, + }, + { + type: ColumnHeaderType.SMOOTHED, + name: 'smoothed', + displayName: 'Smoothed', + enabled: true, + }, + { + type: ColumnHeaderType.RUN, + name: 'run', + displayName: 'Run', + enabled: true, + }, + ], + sortingInfo: { + name: 'step', + order: SortingOrder.DESCENDING, + }, + }); + fixture.detectChanges(); + const headerElements = fixture.debugElement.queryAll( + By.directive(HeaderCellComponent) + ); + + headerElements[1].query(By.css('.cell')).triggerEventHandler('dragstart'); + headerElements[2].query(By.css('.cell')).triggerEventHandler('dragenter'); + fixture.detectChanges(); + expect( + headerElements[2] + .query(By.css('.cell')) + .nativeElement.classList.contains('highlight') + ).toBe(false); + headerElements[1].query(By.css('.cell')).triggerEventHandler('dragend'); + + expect(orderColumnsSpy).not.toHaveBeenCalled(); + }); + it('does not show add button when there are no selectable columns', () => { const fixture = createComponent({}); expect(fixture.debugElement.query(By.css('.add-column-button'))).toBeNull(); @@ -581,7 +626,7 @@ describe('data table', () => { { name: 'other_header', type: ColumnHeaderType.HPARAM, - displayName: 'Display This', + displayName: 'Hparam 1', enabled: true, removable: true, movable: true, @@ -589,7 +634,7 @@ describe('data table', () => { { name: 'another_hparam', type: ColumnHeaderType.HPARAM, - displayName: 'Display This', + displayName: 'Hparam 2', enabled: true, removable: true, movable: false, @@ -597,7 +642,7 @@ describe('data table', () => { }, { name: 'some static column', - type: ColumnHeaderType.HPARAM, + type: ColumnHeaderType.MEAN, displayName: 'cant touch this', enabled: true, }, @@ -647,6 +692,7 @@ describe('data table', () => { const cell = fixture.debugElement.query( By.directive(HeaderCellComponent) ); + cell.nativeElement.dispatchEvent(new MouseEvent('contextmenu')); fixture.detectChanges(); @@ -661,9 +707,10 @@ describe('data table', () => { data: mockTableData, potentialColumns: mockPotentialColumns, }); - const cell = fixture.debugElement.query( - By.directive(ContentCellComponent) - ); + const cell = fixture.debugElement + .queryAll(By.directive(HeaderCellComponent)) + .find((cell) => cell.nativeElement.innerHTML.includes('Hparam 1'))!; + cell.nativeElement.dispatchEvent(new MouseEvent('contextmenu')); fixture.detectChanges(); @@ -680,7 +727,9 @@ describe('data table', () => { By.directive(DataTableComponent) ); expect(dataTable.componentInstance.insertColumnTo).toEqual(Side.LEFT); - expect(dataTable.componentInstance.contextMenuHeader.name).toEqual('run'); + expect(dataTable.componentInstance.contextMenuHeader.name).toEqual( + 'other_header' + ); }); it('renders column selector when add column to the right is clicked', () => { @@ -689,9 +738,9 @@ describe('data table', () => { data: mockTableData, potentialColumns: mockPotentialColumns, }); - const cell = fixture.debugElement.query( - By.directive(ContentCellComponent) - ); + const cell = fixture.debugElement + .queryAll(By.directive(HeaderCellComponent)) + .find((cell) => cell.nativeElement.innerHTML.includes('Hparam 1'))!; cell.nativeElement.dispatchEvent(new MouseEvent('contextmenu')); fixture.detectChanges(); @@ -708,7 +757,9 @@ describe('data table', () => { By.directive(DataTableComponent) ); expect(dataTable.componentInstance.insertColumnTo).toEqual(Side.RIGHT); - expect(dataTable.componentInstance.contextMenuHeader.name).toEqual('run'); + expect(dataTable.componentInstance.contextMenuHeader.name).toEqual( + 'other_header' + ); }); it('only shows the remove button when the column is removable', () => { @@ -786,7 +837,7 @@ describe('data table', () => { ).toBeUndefined(); }); - it('only includes add buttons when header is movable', () => { + it('only includes add buttons for non-movable hparams', () => { const fixture = createComponent({ headers: mockHeaders, data: mockTableData, @@ -811,7 +862,10 @@ describe('data table', () => { element.nativeElement.innerHTML.includes('Right') )!; - if (cell.componentInstance.header.movable) { + if ( + cell.componentInstance.header.type === 'HPARAM' && + cell.componentInstance.header.movable + ) { expect(addLeft).toBeDefined(); expect(addRight).toBeDefined(); } else { diff --git a/tensorboard/webapp/widgets/data_table/utils.ts b/tensorboard/webapp/widgets/data_table/utils.ts index e661c81f56..23b1267e45 100644 --- a/tensorboard/webapp/widgets/data_table/utils.ts +++ b/tensorboard/webapp/widgets/data_table/utils.ts @@ -83,4 +83,5 @@ function groupColumns(columns: ColumnHeader[]): ColumnHeader[] { export const dataTableUtils = { moveColumn, groupColumns, + columnToGroup, }; diff --git a/tensorboard/webapp/widgets/data_table/utils_test.ts b/tensorboard/webapp/widgets/data_table/utils_test.ts index eacb80b52c..58bb81a040 100644 --- a/tensorboard/webapp/widgets/data_table/utils_test.ts +++ b/tensorboard/webapp/widgets/data_table/utils_test.ts @@ -13,10 +13,69 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ -import {ColumnHeaderType, Side} from './types'; +import {ColumnGroup, ColumnHeaderType, Side} from './types'; import {dataTableUtils} from './utils'; describe('data table utils', () => { + describe('columnToGroup', () => { + [ + { + testDesc: 'run column', + column: { + type: ColumnHeaderType.RUN, + name: 'run', + displayName: 'Run', + enabled: true, + }, + expectedGroup: ColumnGroup.RUN, + }, + { + testDesc: 'experiment alias column', + column: { + type: ColumnHeaderType.CUSTOM, + name: 'experimentAlias', + displayName: 'Experiment Alias', + enabled: true, + }, + expectedGroup: ColumnGroup.EXPERIMENT_ALIAS, + }, + { + testDesc: 'hparam column', + column: { + type: ColumnHeaderType.HPARAM, + name: 'conv_layers', + displayName: 'Conv Layers', + enabled: true, + }, + expectedGroup: ColumnGroup.HPARAM, + }, + { + testDesc: 'standard column', + column: { + type: ColumnHeaderType.VALUE, + name: 'value', + displayName: 'Value', + enabled: true, + }, + expectedGroup: ColumnGroup.OTHER, + }, + { + testDesc: 'custom column not named experiment alias', + column: { + type: ColumnHeaderType.CUSTOM, + name: 'notExperimentAlias', + displayName: 'Not Experiment Alias', + enabled: true, + }, + expectedGroup: ColumnGroup.OTHER, + }, + ].forEach(({testDesc, column, expectedGroup}) => { + it(`returns the group for ${testDesc}`, () => { + expect(dataTableUtils.columnToGroup(column)).toEqual(expectedGroup); + }); + }); + }); + describe('groupColumns', () => { it('groups columns according to a predefined order', () => { const inputColumns = [