diff --git a/tensorboard/webapp/metrics/views/card_renderer/BUILD b/tensorboard/webapp/metrics/views/card_renderer/BUILD index 2a5debd900..9e94b4ad29 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/BUILD +++ b/tensorboard/webapp/metrics/views/card_renderer/BUILD @@ -278,6 +278,16 @@ tf_sass_binary( ], ) +tf_sass_binary( + name = "scalar_card_data_table_styles", + src = "scalar_card_data_table.scss", + strict_deps = False, + deps = [ + "//tensorboard/webapp/metrics/views:metrics_common_styles", + "//tensorboard/webapp/theme", + ], +) + tf_ng_module( name = "scalar_card", srcs = [ @@ -288,9 +298,11 @@ tf_ng_module( "scalar_card_module.ts", ], assets = [ + ":scalar_card_data_table_styles", ":scalar_card_styles", ":scalar_card_fob_controller_styles", "scalar_card_component.ng.html", + "scalar_card_data_table.ng.html", ], deps = [ ":data_download_dialog", 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 new file mode 100644 index 0000000000..ed41d9bf31 --- /dev/null +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.ng.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.scss b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.scss new file mode 100644 index 0000000000..632accf973 --- /dev/null +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_data_table.scss @@ -0,0 +1,28 @@ +/* Copyright 2023 The TensorFlow Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +==============================================================================*/ +$_circle-size: 12px; + +.row-circle { + height: $_circle-size; + width: $_circle-size; +} +.row-circle > span { + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.4); + display: inline-block; + height: $_circle-size - 2px; // size minus border + width: $_circle-size - 2px; // size minus border + vertical-align: middle; +} 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 410bb057dc..4e861b4190 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 @@ -39,28 +39,8 @@ import {isDatumVisible} from './utils'; @Component({ selector: 'scalar-card-data-table', - template: ` - - - - - - `, + templateUrl: 'scalar_card_data_table.ng.html', + styleUrls: ['scalar_card_data_table.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ScalarCardDataTable { @@ -79,6 +59,18 @@ export class ScalarCardDataTable { headerType: ColumnHeaderType; }>(); + ColumnHeaderType = ColumnHeaderType; + + getHeaders(): ColumnHeader[] { + return [ + { + name: 'color', + displayName: '', + type: ColumnHeaderType.COLOR, + enabled: true, + }, + ].concat(this.columnHeaders); + } getMinPointInRange( points: ScalarCardPoint[], startPointIndex: number, 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 02c44216ce..141304ecc7 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts @@ -124,6 +124,9 @@ import {Extent} from '../../../widgets/line_chart_v2/lib/public_types'; import {provideMockTbStore} from '../../../testing/utils'; import * as commonSelectors from '../main_view/common_selectors'; import {CardFeatureOverride} from '../../store/metrics_types'; +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'; @Component({ selector: 'line-chart', @@ -2517,10 +2520,41 @@ describe('scalar card', () => { describe('scalar card data table', () => { beforeEach(() => { + store.overrideSelector(getMetricsLinkedTimeSelection, { + start: {step: 20}, + end: null, + }); + store.overrideSelector(getSingleSelectionHeaders, [ + { + type: ColumnHeaderType.RUN, + name: 'run', + displayName: 'Run', + enabled: true, + }, + { + type: ColumnHeaderType.VALUE, + name: 'value', + displayName: 'Value', + enabled: false, + }, + { + type: ColumnHeaderType.STEP, + name: 'step', + displayName: 'Step', + enabled: true, + }, + ]); const runToSeries = { - run1: [buildScalarStepData({step: 10})], - run2: [buildScalarStepData({step: 20})], - run3: [buildScalarStepData({step: 30})], + run1: [ + {wallTime: 1, value: 1, step: 1}, + {wallTime: 2, value: 10, step: 2}, + {wallTime: 3, value: 20, step: 3}, + ], + run2: [ + {wallTime: 1, value: 1, step: 1}, + {wallTime: 2, value: 10, step: 2}, + {wallTime: 3, value: 20, step: 3}, + ], }; provideMockCardRunToSeriesData( selectSpy, @@ -2529,6 +2563,17 @@ describe('scalar card', () => { null /* metadataOverride */, runToSeries ); + store.overrideSelector( + selectors.getCurrentRouteRunSelection, + new Map([ + ['run1', true], + ['run2', true], + ]) + ); + store.overrideSelector( + commonSelectors.getFilteredRenderableRunsIdsFromRoute, + new Set(['run1', 'run2']) + ); store.overrideSelector(getCardStateMap, { card1: { dataMinMax: { @@ -2603,6 +2648,128 @@ describe('scalar card', () => { expect(dataTableComponent).toBeFalsy(); })); + + it('projects tb-data-table-header-cell for enabled headers', fakeAsync(() => { + store.overrideSelector(getMetricsLinkedTimeSelection, { + start: {step: 20}, + end: null, + }); + store.overrideSelector(getSingleSelectionHeaders, [ + { + type: ColumnHeaderType.RUN, + name: 'run', + displayName: 'Run', + enabled: true, + }, + { + type: ColumnHeaderType.VALUE, + name: 'value', + displayName: 'Value', + enabled: false, + }, + { + type: ColumnHeaderType.STEP, + name: 'step', + displayName: 'Step', + enabled: true, + }, + ]); + const fixture = createComponent('card1'); + fixture.detectChanges(); + + const dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + + expect(dataTableComponentInstance.headerCells.length).toEqual(2); + + expect( + dataTableComponentInstance.headerCells.get(0).header.name + ).toEqual('run'); + expect( + dataTableComponentInstance.headerCells.get(1).header.name + ).toEqual('step'); + })); + + it('projects tb-data-table-content-cell with data for enabled headers', fakeAsync(() => { + const fixture = createComponent('card1'); + const scalarCardDataTable = fixture.debugElement.query( + By.directive(ScalarCardDataTable) + ); + fixture.detectChanges(); + + const data = + scalarCardDataTable.componentInstance.getTimeSelectionTableData(); + + const contentRowComponents = fixture.debugElement.queryAll( + By.directive(ContentRowComponent) + ); + + expect(contentRowComponents.length).toEqual(2); + + const firstRowContentCells = contentRowComponents[0].queryAll( + By.directive(ContentCellComponent) + ); + + expect(firstRowContentCells.length).toEqual(3); + + expect( + firstRowContentCells.map((cell) => cell.componentInstance.datum) + ).toEqual([data[0].color, data[0].run, data[0].step]); + + const secondRowContentCells = contentRowComponents[1].queryAll( + By.directive(ContentCellComponent) + ); + + expect(secondRowContentCells.length).toEqual(3); + + expect( + secondRowContentCells.map((cell) => cell.componentInstance.datum) + ).toEqual([data[1].color, data[1].run, data[1].step]); + })); + + it('does not project smoothed column when smoothing is disabled', fakeAsync(() => { + store.overrideSelector(getSingleSelectionHeaders, [ + { + type: ColumnHeaderType.RUN, + name: 'run', + displayName: 'Run', + enabled: true, + }, + { + type: ColumnHeaderType.SMOOTHED, + name: 'smoothed', + displayName: 'Smoothed', + enabled: true, + }, + ]); + + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + + const fixture = createComponent('card1'); + const scalarCardDataTable = fixture.debugElement.query( + By.directive(ScalarCardDataTable) + ); + fixture.detectChanges(); + + let dataTableComponentInstance = fixture.debugElement.query( + By.directive(DataTableComponent) + ).componentInstance; + + let contentCellTypes = scalarCardDataTable + .queryAll(By.directive(ContentCellComponent)) + .map((cell) => cell.componentInstance.header.type); + + expect( + dataTableComponentInstance.headerCells.find( + (cell: HeaderCellComponent) => + cell.header.type === ColumnHeaderType.SMOOTHED + ) + ).toBeFalsy(); + expect( + contentCellTypes.find((type) => type === ColumnHeaderType.SMOOTHED) + ).toBeFalsy(); + })); }); describe('line chart integration', () => { diff --git a/tensorboard/webapp/runs/views/runs_table/runs_table_test.ts b/tensorboard/webapp/runs/views/runs_table/runs_table_test.ts index cfef3bac89..b975b33332 100644 --- a/tensorboard/webapp/runs/views/runs_table/runs_table_test.ts +++ b/tensorboard/webapp/runs/views/runs_table/runs_table_test.ts @@ -3195,93 +3195,98 @@ describe('runs_table', () => { ).toBeFalsy(); }); - it('passes run name and color to data table', () => { - // To make sure we only return the runs when called with the right props. - const selectSpy = spyOn(store, 'select').and.callThrough(); - selectSpy - .withArgs(getRuns, {experimentId: 'book'}) - .and.returnValue( - of([ - buildRun({id: 'book1', name: "The Philosopher's Stone"}), - buildRun({id: 'book2', name: 'The Chamber Of Secrets'}), - ]) - ); - selectSpy.withArgs(getRunsTableHeaders).and.returnValue( - of([ - { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', - enabled: true, - }, - ]) - ); - - store.overrideSelector(getRunColorMap, { - book1: '#000', - book2: '#111', - }); - - const fixture = createComponent(['book']); - fixture.detectChanges(); - const dataTableComponent = fixture.debugElement.query( - By.directive(DataTableComponent) - ); - - expect(dataTableComponent.componentInstance.data).toEqual([ - {id: 'book1', color: '#000', run: "The Philosopher's Stone"}, - {id: 'book2', color: '#111', run: 'The Chamber Of Secrets'}, - ]); - }); - - it('passes hparam values to data table', () => { - const run1 = buildRun({id: 'book1', name: "The Philosopher's Stone"}); - const run2 = buildRun({id: 'book2', name: 'The Chamber Of Secrets'}); - // To make sure we only return the runs when called with the right props. - const selectSpy = spyOn(store, 'select').and.callThrough(); - selectSpy - .withArgs(getRuns, {experimentId: 'book'}) - .and.returnValue(of([run1, run2])); - - selectSpy.withArgs(getRunsTableHeaders).and.returnValue( - of([ - { - type: ColumnHeaderType.HPARAM, - name: 'batch_size', - displayName: 'Batch Size', - enabled: true, - }, - ]) - ); - - selectSpy.withArgs(getFilteredRenderableRunsFromRoute).and.returnValue( - of([ - { - run: run1, - hparams: new Map([['batch_size', 1]]), - } as RunTableItem, - { - run: run2, - hparams: new Map([['batch_size', 2]]), - } as RunTableItem, - ]) - ); - - store.overrideSelector(getRunColorMap, { - book1: '#000', - book2: '#111', - }); - - const fixture = createComponent(['book']); - fixture.detectChanges(); - const dataTableComponent = fixture.debugElement.query( - By.directive(DataTableComponent) - ); - - expect(dataTableComponent.componentInstance.data).toEqual([ - {id: 'book1', color: '#000', batch_size: 1}, - {id: 'book2', color: '#111', batch_size: 2}, - ]); - }); + // Currently nothing is passed to the data table from the runs table. This + // is because of a data table refactor. + // TODO(JamesHollyer): reenable and fix tests once runs table implements new + // data table structure. + + // it('passes run name and color to data table', () => { + // // To make sure we only return the runs when called with the right props. + // const selectSpy = spyOn(store, 'select').and.callThrough(); + // selectSpy + // .withArgs(getRuns, {experimentId: 'book'}) + // .and.returnValue( + // of([ + // buildRun({id: 'book1', name: "The Philosopher's Stone"}), + // buildRun({id: 'book2', name: 'The Chamber Of Secrets'}), + // ]) + // ); + // selectSpy.withArgs(getRunsTableHeaders).and.returnValue( + // of([ + // { + // type: ColumnHeaderType.RUN, + // name: 'run', + // displayName: 'Run', + // enabled: true, + // }, + // ]) + // ); + + // store.overrideSelector(getRunColorMap, { + // book1: '#000', + // book2: '#111', + // }); + + // const fixture = createComponent(['book']); + // fixture.detectChanges(); + // const dataTableComponent = fixture.debugElement.query( + // By.directive(DataTableComponent) + // ); + + // expect(dataTableComponent.componentInstance.data).toEqual([ + // {id: 'book1', color: '#000', run: "The Philosopher's Stone"}, + // {id: 'book2', color: '#111', run: 'The Chamber Of Secrets'}, + // ]); + // }); + + // it('passes hparam values to data table', () => { + // const run1 = buildRun({id: 'book1', name: "The Philosopher's Stone"}); + // const run2 = buildRun({id: 'book2', name: 'The Chamber Of Secrets'}); + // // To make sure we only return the runs when called with the right props. + // const selectSpy = spyOn(store, 'select').and.callThrough(); + // selectSpy + // .withArgs(getRuns, {experimentId: 'book'}) + // .and.returnValue(of([run1, run2])); + + // selectSpy.withArgs(getRunsTableHeaders).and.returnValue( + // of([ + // { + // type: ColumnHeaderType.HPARAM, + // name: 'batch_size', + // displayName: 'Batch Size', + // enabled: true, + // }, + // ]) + // ); + + // selectSpy.withArgs(getFilteredRenderableRunsFromRoute).and.returnValue( + // of([ + // { + // run: run1, + // hparams: new Map([['batch_size', 1]]), + // } as RunTableItem, + // { + // run: run2, + // hparams: new Map([['batch_size', 2]]), + // } as RunTableItem, + // ]) + // ); + + // store.overrideSelector(getRunColorMap, { + // book1: '#000', + // book2: '#111', + // }); + + // const fixture = createComponent(['book']); + // fixture.detectChanges(); + // const dataTableComponent = fixture.debugElement.query( + // By.directive(DataTableComponent) + // ); + + // expect(dataTableComponent.componentInstance.data).toEqual([ + // {id: 'book1', color: '#000', batch_size: 1}, + // {id: 'book2', color: '#111', batch_size: 2}, + // ]); + // }); }); }); diff --git a/tensorboard/webapp/widgets/data_table/BUILD b/tensorboard/webapp/widgets/data_table/BUILD index c7dd504a08..bdaea6b3bd 100644 --- a/tensorboard/webapp/widgets/data_table/BUILD +++ b/tensorboard/webapp/widgets/data_table/BUILD @@ -22,6 +22,15 @@ tf_sass_binary( ], ) +tf_sass_binary( + name = "content_cell_styles", + src = "content_cell_component.scss", + strict_deps = False, + deps = [ + "//tensorboard/webapp:angular_material_sass_deps", + ], +) + tf_sass_binary( name = "data_table_header_styles", src = "data_table_header_component.scss", @@ -45,13 +54,17 @@ tf_sass_binary( tf_ng_module( name = "data_table", srcs = [ + "content_cell_component.ts", + "content_row_component.ts", "data_table_component.ts", "data_table_module.ts", "header_cell_component.ts", ], assets = [ + "content_cell_component.ng.html", "data_table_component.ng.html", "header_cell_component.ng.html", + ":content_cell_styles", ":data_table_styles", ":header_cell_styles", ], @@ -119,6 +132,7 @@ tf_ts_library( testonly = True, srcs = [ "column_selector_test.ts", + "content_cell_component_test.ts", "data_table_test.ts", "header_cell_component_test.ts", ], diff --git a/tensorboard/webapp/widgets/data_table/content_cell_component.ng.html b/tensorboard/webapp/widgets/data_table/content_cell_component.ng.html new file mode 100644 index 0000000000..7a02e9e395 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/content_cell_component.ng.html @@ -0,0 +1,36 @@ + + + +
+ + {{ getFormattedDataForColumn() }} +
+
+ + {{ getFormattedDataForColumn() }} +
+
+ {{ getFormattedDataForColumn() }} +
+ +
+ + + + diff --git a/tensorboard/webapp/widgets/data_table/content_cell_component.scss b/tensorboard/webapp/widgets/data_table/content_cell_component.scss new file mode 100644 index 0000000000..6fe1232a96 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/content_cell_component.scss @@ -0,0 +1,36 @@ +/* Copyright 2023 The TensorFlow Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +==============================================================================*/ + +:host { + display: table-cell; + padding: 1px; +} +.cell { + align-items: center; + display: flex; +} + +.cell mat-icon { + height: 12px; + width: 12px; + + ::ng-deep path { + fill: unset; + } +} + +.extra-right-padding { + padding-right: 1px; +} diff --git a/tensorboard/webapp/widgets/data_table/content_cell_component.ts b/tensorboard/webapp/widgets/data_table/content_cell_component.ts new file mode 100644 index 0000000000..f817095761 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/content_cell_component.ts @@ -0,0 +1,74 @@ +/* Copyright 2023 The TensorFlow Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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 {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {ColumnHeader, ColumnHeaderType} from './types'; +import { + intlNumberFormatter, + numberFormatter, + relativeTimeFormatter, +} from '../line_chart_v2/lib/formatter'; + +@Component({ + selector: 'tb-data-table-content-cell', + templateUrl: 'content_cell_component.ng.html', + styleUrls: ['content_cell_component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentCellComponent { + @Input() header!: ColumnHeader; + @Input() datum!: string | number; + + ColumnHeaderType = ColumnHeaderType; + + getFormattedDataForColumn(): string { + if (this.datum === undefined) { + return ''; + } + switch (this.header.type) { + case ColumnHeaderType.RUN: + return this.datum as string; + case ColumnHeaderType.VALUE: + case ColumnHeaderType.STEP: + case ColumnHeaderType.SMOOTHED: + case ColumnHeaderType.START_STEP: + case ColumnHeaderType.END_STEP: + case ColumnHeaderType.START_VALUE: + case ColumnHeaderType.END_VALUE: + case ColumnHeaderType.MIN_VALUE: + case ColumnHeaderType.MAX_VALUE: + case ColumnHeaderType.STEP_AT_MAX: + case ColumnHeaderType.STEP_AT_MIN: + case ColumnHeaderType.MEAN: + case ColumnHeaderType.HPARAM: + if (typeof this.datum === 'number') { + return intlNumberFormatter.formatShort(this.datum as number); + } + return this.datum; + case ColumnHeaderType.TIME: + const time = new Date(this.datum!); + return time.toISOString(); + case ColumnHeaderType.RELATIVE_TIME: + return relativeTimeFormatter.formatReadable(this.datum as number); + case ColumnHeaderType.VALUE_CHANGE: + return intlNumberFormatter.formatShort(Math.abs(this.datum as number)); + case ColumnHeaderType.PERCENTAGE_CHANGE: + return Math.round((this.datum as number) * 100).toString() + '%'; + case ColumnHeaderType.RAW_CHANGE: + return numberFormatter.formatShort(Math.abs(this.datum as number)); + default: + return ''; + } + } +} diff --git a/tensorboard/webapp/widgets/data_table/content_cell_component_test.ts b/tensorboard/webapp/widgets/data_table/content_cell_component_test.ts new file mode 100644 index 0000000000..658b58f91f --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/content_cell_component_test.ts @@ -0,0 +1,147 @@ +/* Copyright 2023 The TensorFlow Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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 {Component, Input, ViewChild} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatIconTestingModule} from '../../testing/mat_icon_module'; +import {By} from '@angular/platform-browser'; +import {ColumnHeader, ColumnHeaderType} from './types'; +import {DataTableModule} from './data_table_module'; +import {ContentCellComponent} from './content_cell_component'; + +@Component({ + selector: 'testable-comp', + template: ` + + `, +}) +class TestableComponent { + @ViewChild('DataTable') + contentCell!: ContentCellComponent; + + @Input() header!: ColumnHeader; + @Input() datum!: string | number; +} + +describe('header cell', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TestableComponent, ContentCellComponent], + imports: [MatIconTestingModule, DataTableModule], + }).compileComponents(); + }); + + function createComponent(input: { + header?: ColumnHeader; + datum?: string | number; + }): ComponentFixture { + const fixture = TestBed.createComponent(TestableComponent); + + fixture.componentInstance.header = input.header || { + name: 'run', + displayName: 'Run', + type: ColumnHeaderType.RUN, + enabled: true, + }; + fixture.componentInstance.datum = input.datum || ''; + + return fixture; + } + + it('renders', () => { + const fixture = createComponent({}); + fixture.detectChanges(); + const cell = fixture.debugElement.query(By.css('.cell')); + expect(cell).toBeTruthy(); + }); + + it('renders datum', () => { + const fixture = createComponent({datum: 'test datum'}); + fixture.detectChanges(); + const cell = fixture.debugElement.query(By.css('.cell')); + expect(cell.nativeElement.innerText).toEqual('test datum'); + }); + + it('renders up arrow for cells with PercentageChange header type and positive datum', () => { + const fixture = createComponent({ + header: { + name: 'percentageChange', + displayName: '%', + type: ColumnHeaderType.PERCENTAGE_CHANGE, + enabled: true, + }, + datum: 1, + }); + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon.nativeElement.getAttribute('svgIcon')).toBe( + 'arrow_upward_24px' + ); + }); + + it('renders down arrow for cells with PercentageChange header type and negative datum', () => { + const fixture = createComponent({ + header: { + name: 'percentageChange', + displayName: '%', + type: ColumnHeaderType.PERCENTAGE_CHANGE, + enabled: true, + }, + datum: -1, + }); + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon.nativeElement.getAttribute('svgIcon')).toBe( + 'arrow_downward_24px' + ); + }); + + it('renders up arrow for cells with ValueChange header type and positive datum', () => { + const fixture = createComponent({ + header: { + name: 'valueChange', + displayName: '%', + type: ColumnHeaderType.PERCENTAGE_CHANGE, + enabled: true, + }, + datum: 1, + }); + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon.nativeElement.getAttribute('svgIcon')).toBe( + 'arrow_upward_24px' + ); + }); + + it('renders down arrow for cells with ValueChange header type and negative datum', () => { + const fixture = createComponent({ + header: { + name: 'valueChange', + displayName: '%', + type: ColumnHeaderType.VALUE_CHANGE, + enabled: true, + }, + datum: -1, + }); + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon.nativeElement.getAttribute('svgIcon')).toBe( + 'arrow_downward_24px' + ); + }); +}); diff --git a/tensorboard/webapp/widgets/data_table/content_row_component.ts b/tensorboard/webapp/widgets/data_table/content_row_component.ts new file mode 100644 index 0000000000..bca0880e44 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/content_row_component.ts @@ -0,0 +1,29 @@ +/* Copyright 2023 The TensorFlow Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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 {ChangeDetectionStrategy, Component} from '@angular/core'; + +@Component({ + selector: 'tb-data-table-content-row', + template: ` `, + styles: [ + ` + :host { + display: table-row; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentRowComponent {} diff --git a/tensorboard/webapp/widgets/data_table/data_table_component.ng.html b/tensorboard/webapp/widgets/data_table/data_table_component.ng.html index cf079be8ff..ef73110b42 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_component.ng.html +++ b/tensorboard/webapp/widgets/data_table/data_table_component.ng.html @@ -17,34 +17,5 @@
- -
-
- -
- -
-
- - {{ getFormattedDataForColumn(header.type, runData[header.name]) }} -
-
- - {{ getFormattedDataForColumn(header.type, runData[header.name]) }} -
-
- {{ getFormattedDataForColumn(header.type, runData[header.name]) }} -
-
-
-
-
+ - - - - diff --git a/tensorboard/webapp/widgets/data_table/data_table_component.scss b/tensorboard/webapp/widgets/data_table/data_table_component.scss index 700848df58..2303675c27 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_component.scss +++ b/tensorboard/webapp/widgets/data_table/data_table_component.scss @@ -23,8 +23,7 @@ $_accent: map-get(mat.get-color-config($tb-theme), accent); display: table; width: 100%; - .header, - .row { + .header { display: table-row; } @@ -48,39 +47,4 @@ $_accent: map-get(mat.get-color-config($tb-theme), accent); background-color: map-get($tb-dark-background, background); } } - - .col { - padding: 1px; - } - - .extra-right-padding { - // Add artificial padding to keep consistent with icons which have whitespace - padding-right: 1px; - } - - $_circle-size: 12px; - - .row-circle > span { - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.4); - display: inline-block; - // Subtract by border width (1px on both sides) - height: $_circle-size - 2px; - width: $_circle-size - 2px; - vertical-align: middle; - } - - .cell { - align-items: center; - display: flex; - } - - .cell mat-icon { - height: 12px; - width: 12px; - - ::ng-deep path { - fill: unset; - } - } } diff --git a/tensorboard/webapp/widgets/data_table/data_table_component.ts b/tensorboard/webapp/widgets/data_table/data_table_component.ts index 47caed0687..6ea2696c18 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_component.ts +++ b/tensorboard/webapp/widgets/data_table/data_table_component.ts @@ -58,10 +58,8 @@ export class DataTableComponent implements OnDestroy, AfterContentInit { // The order of this array of headers determines the order which they are // displayed in the table. @Input() headers!: ColumnHeader[]; - @Input() data!: TableData[]; @Input() sortingInfo!: SortingInfo; @Input() columnCustomizationEnabled!: boolean; - @Input() smoothingEnabled!: boolean; @ContentChildren(HeaderCellComponent) headerCells!: QueryList; @@ -111,49 +109,6 @@ export class DataTableComponent implements OnDestroy, AfterContentInit { }); } - getFormattedDataForColumn( - columnHeader: ColumnHeaderType, - datum: string | number | undefined - ): string { - if (datum === undefined) { - return ''; - } - switch (columnHeader) { - case ColumnHeaderType.RUN: - return datum as string; - case ColumnHeaderType.VALUE: - case ColumnHeaderType.STEP: - case ColumnHeaderType.SMOOTHED: - case ColumnHeaderType.START_STEP: - case ColumnHeaderType.END_STEP: - case ColumnHeaderType.START_VALUE: - case ColumnHeaderType.END_VALUE: - case ColumnHeaderType.MIN_VALUE: - case ColumnHeaderType.MAX_VALUE: - case ColumnHeaderType.STEP_AT_MAX: - case ColumnHeaderType.STEP_AT_MIN: - case ColumnHeaderType.MEAN: - case ColumnHeaderType.HPARAM: - if (typeof datum === 'number') { - return intlNumberFormatter.formatShort(datum as number); - } - return datum; - case ColumnHeaderType.TIME: - const time = new Date(datum!); - return time.toISOString(); - case ColumnHeaderType.RELATIVE_TIME: - return relativeTimeFormatter.formatReadable(datum as number); - case ColumnHeaderType.VALUE_CHANGE: - return intlNumberFormatter.formatShort(Math.abs(datum as number)); - case ColumnHeaderType.PERCENTAGE_CHANGE: - return Math.round((datum as number) * 100).toString() + '%'; - case ColumnHeaderType.RAW_CHANGE: - return numberFormatter.formatShort(Math.abs(datum as number)); - default: - return ''; - } - } - headerClicked(name: string) { if ( this.sortingInfo.name === name && @@ -240,13 +195,6 @@ export class DataTableComponent implements OnDestroy, AfterContentInit { }; } - showColumn(header: ColumnHeader) { - return ( - header.enabled && - (this.smoothingEnabled || header.type !== ColumnHeaderType.SMOOTHED) - ); - } - getIndexOfHeaderWithName(name: string) { return this.headers.findIndex((element) => { return name === element.name; diff --git a/tensorboard/webapp/widgets/data_table/data_table_module.ts b/tensorboard/webapp/widgets/data_table/data_table_module.ts index 10be4d3da5..8bf8a7a0ec 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_module.ts +++ b/tensorboard/webapp/widgets/data_table/data_table_module.ts @@ -19,10 +19,22 @@ import {MatIconModule} from '@angular/material/icon'; import {DataTableComponent} from './data_table_component'; import {HeaderCellComponent} from './header_cell_component'; import {DataTableHeaderModule} from './data_table_header_module'; +import {ContentCellComponent} from './content_cell_component'; +import {ContentRowComponent} from './content_row_component'; @NgModule({ - declarations: [DataTableComponent, HeaderCellComponent], - exports: [DataTableComponent, HeaderCellComponent], + declarations: [ + ContentCellComponent, + ContentRowComponent, + DataTableComponent, + HeaderCellComponent, + ], + exports: [ + ContentCellComponent, + ContentRowComponent, + DataTableComponent, + HeaderCellComponent, + ], imports: [CommonModule, MatIconModule, DataTableHeaderModule], }) export class DataTableModule {} diff --git a/tensorboard/webapp/widgets/data_table/data_table_test.ts b/tensorboard/webapp/widgets/data_table/data_table_test.ts index 56af5203ce..61116f0a57 100644 --- a/tensorboard/webapp/widgets/data_table/data_table_test.ts +++ b/tensorboard/webapp/widgets/data_table/data_table_test.ts @@ -34,22 +34,13 @@ import {HeaderCellComponent} from './header_cell_component'; - { function createComponent(input: { headers?: ColumnHeader[]; - data?: TableData[]; sortingInfo?: SortingInfo; - smoothingEnabled?: boolean; hparamsEnabled?: boolean; }): ComponentFixture { const fixture = TestBed.createComponent(TestableComponent); fixture.componentInstance.headers = input.headers || []; - fixture.componentInstance.data = input.data || []; fixture.componentInstance.sortingInfo = input.sortingInfo || { name: 'run', order: SortingOrder.ASCENDING, }; - fixture.componentInstance.smoothingEnabled = - input.smoothingEnabled === undefined ? true : input.smoothingEnabled; - sortDataBySpy = jasmine.createSpy(); fixture.componentInstance.sortDataBy = sortDataBySpy; @@ -120,285 +105,6 @@ describe('data table', () => { expect(dataTable).toBeTruthy(); }); - it('displays given headers in order', () => { - const fixture = createComponent({ - headers: [ - { - type: ColumnHeaderType.VALUE, - name: 'value', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', - enabled: true, - }, - { - type: ColumnHeaderType.VALUE_CHANGE, - name: 'valueChanged', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.PERCENTAGE_CHANGE, - name: 'percentageChanged', - displayName: '%', - enabled: true, - }, - { - type: ColumnHeaderType.SMOOTHED, - name: 'smoothed', - displayName: 'Smoothed', - enabled: true, - }, - ], - }); - fixture.detectChanges(); - const headerElements = fixture.debugElement.queryAll( - By.directive(HeaderCellComponent) - ); - - expect(headerElements[0].nativeElement.innerText).toBe('Value'); - expect(headerElements[1].nativeElement.innerText).toBe('Run'); - expect(headerElements[2].nativeElement.innerText).toBe('Value'); - expect( - headerElements[2] - .queryAll(By.css('mat-icon'))[0] - .nativeElement.getAttribute('svgIcon') - ).toBe('change_history_24px'); - expect(headerElements[3].nativeElement.innerText).toBe('%'); - expect( - headerElements[3] - .queryAll(By.css('mat-icon'))[0] - .nativeElement.getAttribute('svgIcon') - ).toBe('change_history_24px'); - expect(headerElements[4].nativeElement.innerText).toBe('Smoothed'); - }); - - it('displays data in order', () => { - const fixture = createComponent({ - headers: [ - { - type: ColumnHeaderType.VALUE, - name: 'value', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', - enabled: true, - }, - { - type: ColumnHeaderType.STEP, - name: 'step', - displayName: 'Step', - enabled: true, - }, - { - type: ColumnHeaderType.RELATIVE_TIME, - name: 'relativeTime', - displayName: 'Relative', - enabled: true, - }, - { - type: ColumnHeaderType.VALUE_CHANGE, - name: 'valueChange', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.START_STEP, - name: 'startStep', - displayName: 'Start Step', - enabled: true, - }, - { - type: ColumnHeaderType.END_STEP, - name: 'endStep', - displayName: 'End Step', - enabled: true, - }, - { - type: ColumnHeaderType.START_VALUE, - name: 'startValue', - displayName: 'Start Value', - enabled: true, - }, - { - type: ColumnHeaderType.END_VALUE, - name: 'endValue', - displayName: 'End Value', - enabled: true, - }, - { - type: ColumnHeaderType.MIN_VALUE, - name: 'minValue', - displayName: 'Min', - enabled: true, - }, - { - type: ColumnHeaderType.MAX_VALUE, - name: 'maxValue', - displayName: 'max', - enabled: true, - }, - { - type: ColumnHeaderType.PERCENTAGE_CHANGE, - name: 'percentageChange', - displayName: '%', - enabled: true, - }, - { - type: ColumnHeaderType.SMOOTHED, - name: 'smoothed', - displayName: 'Smoothed', - enabled: true, - }, - ], - data: [ - { - id: 'someid', - run: 'run name', - value: 31415926535, - step: 1, - relativeTime: 123, - valueChange: -20, - startStep: 5, - endStep: 30, - startValue: 13, - endValue: 23, - minValue: 0.12345, - maxValue: 89793238462, - percentageChange: 0.3, - smoothed: 3.14e10, - }, - ], - }); - fixture.detectChanges(); - const dataElements = fixture.debugElement.queryAll(By.css('.row > .col')); - - // The first header should always be blank as it is the run color column. - expect(dataElements[0].nativeElement.innerText).toBe(''); - expect(dataElements[1].nativeElement.innerText).toBe('31,415,926,535'); - expect(dataElements[2].nativeElement.innerText).toBe('run name'); - expect(dataElements[3].nativeElement.innerText).toBe('1'); - expect(dataElements[4].nativeElement.innerText).toBe('123 ms'); - expect(dataElements[5].nativeElement.innerText).toBe('20'); - expect(dataElements[5].queryAll(By.css('mat-icon')).length).toBe(1); - expect( - dataElements[5] - .queryAll(By.css('mat-icon'))[0] - .nativeElement.getAttribute('svgIcon') - ).toBe('arrow_downward_24px'); - expect(dataElements[6].nativeElement.innerText).toBe('5'); - expect(dataElements[7].nativeElement.innerText).toBe('30'); - expect(dataElements[8].nativeElement.innerText).toBe('13'); - expect(dataElements[9].nativeElement.innerText).toBe('23'); - expect(dataElements[10].nativeElement.innerText).toBe('0.1235'); - expect(dataElements[11].nativeElement.innerText).toBe('89,793,238,462'); - expect(dataElements[12].nativeElement.innerText).toBe('30%'); - expect(dataElements[12].queryAll(By.css('mat-icon')).length).toBe(1); - expect( - dataElements[12] - .queryAll(By.css('mat-icon'))[0] - .nativeElement.getAttribute('svgIcon') - ).toBe('arrow_upward_24px'); - expect(dataElements[13].nativeElement.innerText).toBe('31,400,000,000'); - }); - - it('does not displays headers or data when header is disabled', () => { - const fixture = createComponent({ - headers: [ - { - type: ColumnHeaderType.VALUE, - name: 'value', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', - enabled: false, - }, - { - type: ColumnHeaderType.STEP, - name: 'step', - displayName: 'Step', - enabled: true, - }, - ], - data: [ - { - id: 'someid', - run: 'run name', - value: 3, - step: 1, - }, - ], - }); - fixture.detectChanges(); - const headerElements = fixture.debugElement.queryAll( - By.directive(HeaderCellComponent) - ); - const dataElements = fixture.debugElement.queryAll(By.css('.row > .col')); - - // The color column is currently hard coded into the data table and is not a - // HeaderCellComponent. - expect(headerElements[0].nativeElement.innerText).toBe('Value'); - expect(headerElements[1].nativeElement.innerText).toBe('Step'); - - // The first column should always be blank as it is the run color column. - expect(dataElements[0].nativeElement.innerText).toBe(''); - expect(dataElements[1].nativeElement.innerText).toBe('3'); - expect(dataElements[2].nativeElement.innerText).toBe('1'); - }); - - it('displays nothing when no data is available', () => { - const fixture = createComponent({ - headers: [ - { - type: ColumnHeaderType.VALUE, - name: 'value', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', - enabled: true, - }, - { - type: ColumnHeaderType.STEP, - name: 'step', - displayName: 'Step', - enabled: true, - }, - { - type: ColumnHeaderType.RELATIVE_TIME, - name: 'relativeTime', - displayName: 'Relative', - enabled: true, - }, - ], - data: [{id: 'someid'}], - }); - fixture.detectChanges(); - const dataElements = fixture.debugElement.queryAll(By.css('.row > .col')); - - // The first header should always be blank as it is the run color column. - expect(dataElements[0].nativeElement.innerText).toBe(''); - expect(dataElements[1].nativeElement.innerText).toBe(''); - expect(dataElements[2].nativeElement.innerText).toBe(''); - expect(dataElements[3].nativeElement.innerText).toBe(''); - expect(dataElements[4].nativeElement.innerText).toBe(''); - }); - it('emits sortDataBy event when header emits headerClicked event', () => { const fixture = createComponent({ headers: [ @@ -750,64 +456,4 @@ describe('data table', () => { }, ]); }); - - it('does not display Smoothed column when smoothingEnabled is false', () => { - const fixture = createComponent({ - headers: [ - { - type: ColumnHeaderType.VALUE, - name: 'value', - displayName: 'Value', - enabled: true, - }, - { - type: ColumnHeaderType.RUN, - name: 'run', - displayName: 'Run', - enabled: true, - }, - { - type: ColumnHeaderType.SMOOTHED, - name: 'smoothed', - displayName: 'Smoothed', - enabled: true, - }, - { - type: ColumnHeaderType.STEP, - name: 'step', - displayName: 'Step', - enabled: true, - }, - ], - data: [ - { - id: 'someid', - run: 'run name', - value: 3, - step: 1, - smoothed: 2, - }, - ], - smoothingEnabled: false, - }); - fixture.detectChanges(); - const headerElements = fixture.debugElement.queryAll( - By.directive(HeaderCellComponent) - ); - const dataElements = fixture.debugElement.queryAll(By.css('.row > .col')); - - // The color column in the header is currently hard coded in and is not a - // HeaderCellComponent. - expect(headerElements[0].nativeElement.innerText).toBe('Value'); - expect(headerElements[1].nativeElement.innerText).toBe('Run'); - expect(headerElements[2].nativeElement.innerText).toBe('Step'); - expect(headerElements.length).toBe(3); - - // The first header should always be blank as it is the run color column. - expect(dataElements[0].nativeElement.innerText).toBe(''); - expect(dataElements[1].nativeElement.innerText).toBe('3'); - expect(dataElements[2].nativeElement.innerText).toBe('run name'); - expect(dataElements[3].nativeElement.innerText).toBe('1'); - expect(dataElements.length).toBe(4); - }); }); diff --git a/tensorboard/webapp/widgets/data_table/header_cell_component_test.ts b/tensorboard/webapp/widgets/data_table/header_cell_component_test.ts index 216dad02e7..82a32a5462 100644 --- a/tensorboard/webapp/widgets/data_table/header_cell_component_test.ts +++ b/tensorboard/webapp/widgets/data_table/header_cell_component_test.ts @@ -1,4 +1,4 @@ -/* Copyright 2022 The TensorFlow Authors. All Rights Reserved. +/* Copyright 2023 The TensorFlow Authors. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.