diff --git a/tensorboard/webapp/hparams/BUILD b/tensorboard/webapp/hparams/BUILD index c3f0b23f58..0e7d632a7b 100644 --- a/tensorboard/webapp/hparams/BUILD +++ b/tensorboard/webapp/hparams/BUILD @@ -23,6 +23,7 @@ tf_ts_library( deps = [ "//tensorboard/webapp/runs/data_source", "//tensorboard/webapp/runs/data_source:backend_types", + "//tensorboard/webapp/widgets/data_table:types", ], ) diff --git a/tensorboard/webapp/hparams/_types.ts b/tensorboard/webapp/hparams/_types.ts index ccde5e297c..01df0df138 100644 --- a/tensorboard/webapp/hparams/_types.ts +++ b/tensorboard/webapp/hparams/_types.ts @@ -13,14 +13,12 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ import { - HparamValue, - MetricValue, - DiscreteHparamValues, - DomainType, HparamSpec, MetricSpec, } from '../runs/data_source/runs_data_source_types'; +export {DiscreteFilter, IntervalFilter} from '../widgets/data_table/types'; + export { DatasetType, DiscreteHparamValue, @@ -50,21 +48,3 @@ export interface HparamAndMetricSpec { hparams: HparamSpec[]; metrics: MetricSpec[]; } - -export interface DiscreteFilter { - type: DomainType.DISCRETE; - includeUndefined: boolean; - possibleValues: DiscreteHparamValues; - // Subset of `possibleValues` - filterValues: DiscreteHparamValues; -} - -export interface IntervalFilter { - type: DomainType.INTERVAL; - includeUndefined: boolean; - minValue: number; - maxValue: number; - // Filter values have to be in between min and max values (inclusive). - filterLowerValue: number; - filterUpperValue: number; -} diff --git a/tensorboard/webapp/runs/data_source/BUILD b/tensorboard/webapp/runs/data_source/BUILD index b93da6a6b4..fa8c2caf48 100644 --- a/tensorboard/webapp/runs/data_source/BUILD +++ b/tensorboard/webapp/runs/data_source/BUILD @@ -14,6 +14,7 @@ tf_ng_module( deps = [ ":backend_types", "//tensorboard/webapp/webapp_data_source:http_client", + "//tensorboard/webapp/widgets/data_table:types", "@npm//@angular/core", "@npm//rxjs", ], diff --git a/tensorboard/webapp/runs/data_source/runs_data_source.ts b/tensorboard/webapp/runs/data_source/runs_data_source.ts index 23ee022c2c..b613638946 100644 --- a/tensorboard/webapp/runs/data_source/runs_data_source.ts +++ b/tensorboard/webapp/runs/data_source/runs_data_source.ts @@ -20,9 +20,9 @@ import { TBHttpClient, } from '../../webapp_data_source/tb_http_client'; import * as backendTypes from './runs_backend_types'; +import {DomainType} from '../../widgets/data_table/types'; import { Domain, - DomainType, HparamsAndMetadata, HparamSpec, HparamValue, 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 fa33f26cc4..ac454f7ac1 100644 --- a/tensorboard/webapp/runs/data_source/runs_data_source_types.ts +++ b/tensorboard/webapp/runs/data_source/runs_data_source_types.ts @@ -16,6 +16,9 @@ import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import * as backendTypes from './runs_backend_types'; +import {DomainType} from '../../widgets/data_table/types'; +export {DomainType} from '../../widgets/data_table/types'; + export { BackendHparamsValueType as HparamsValueType, DatasetType, @@ -41,11 +44,6 @@ export interface RunToHparamsAndMetrics { }; } -export enum DomainType { - DISCRETE, - INTERVAL, -} - interface IntervalDomain { type: DomainType.INTERVAL; minValue: number; diff --git a/tensorboard/webapp/widgets/data_table/BUILD b/tensorboard/webapp/widgets/data_table/BUILD index df4c373cc0..eed2d32dfb 100644 --- a/tensorboard/webapp/widgets/data_table/BUILD +++ b/tensorboard/webapp/widgets/data_table/BUILD @@ -51,6 +51,16 @@ tf_sass_binary( ], ) +tf_sass_binary( + name = "filter_dialog_styles", + src = "filter_dialog_component.scss", + strict_deps = False, + deps = [ + "//tensorboard/webapp:angular_material_sass_deps", + "//tensorboard/webapp/theme", + ], +) + tf_ng_module( name = "data_table", srcs = [ @@ -71,12 +81,14 @@ tf_ng_module( deps = [ ":column_selector", ":data_table_header", + ":filter_dialog", ":types", "//tensorboard/webapp/angular:expect_angular_material_button", "//tensorboard/webapp/angular:expect_angular_material_icon", "//tensorboard/webapp/metrics/views/card_renderer:scalar_card_types", "//tensorboard/webapp/widgets/custom_modal", "//tensorboard/webapp/widgets/line_chart_v2/lib:formatter", + "//tensorboard/webapp/widgets/range_input:types", "@npm//@angular/common", "@npm//@angular/core", "@npm//rxjs", @@ -123,6 +135,27 @@ tf_ng_module( ], ) +tf_ng_module( + name = "filter_dialog", + srcs = [ + "filter_dialog_component.ts", + "filter_dialog_module.ts", + ], + assets = [ + "filter_dialog_component.ng.html", + ":filter_dialog_styles", + ], + deps = [ + ":types", + "//tensorboard/webapp/angular:expect_angular_material_checkbox", + "//tensorboard/webapp/widgets/filter_input", + "//tensorboard/webapp/widgets/range_input", + "//tensorboard/webapp/widgets/range_input:types", + "@npm//@angular/common", + "@npm//@angular/core", + ], +) + tf_ts_library( name = "types", srcs = [ @@ -137,16 +170,23 @@ tf_ts_library( "column_selector_test.ts", "content_cell_component_test.ts", "data_table_test.ts", + "filter_dialog_test.ts", "header_cell_component_test.ts", ], deps = [ ":column_selector", ":data_table", + ":filter_dialog", ":types", + "//tensorboard/webapp/angular:expect_angular_cdk_testing", + "//tensorboard/webapp/angular:expect_angular_cdk_testing_testbed", "//tensorboard/webapp/angular:expect_angular_core_testing", + "//tensorboard/webapp/angular:expect_angular_material_checkbox", "//tensorboard/webapp/angular:expect_angular_platform_browser_animations", "//tensorboard/webapp/testing:mat_icon", "//tensorboard/webapp/widgets/custom_modal", + "//tensorboard/webapp/widgets/range_input", + "//tensorboard/webapp/widgets/range_input:types", "@npm//@angular/core", "@npm//@angular/forms", "@npm//@angular/platform-browser", diff --git a/tensorboard/webapp/widgets/data_table/filter_dialog_component.ng.html b/tensorboard/webapp/widgets/data_table/filter_dialog_component.ng.html new file mode 100644 index 0000000000..cc5736e536 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/filter_dialog_component.ng.html @@ -0,0 +1,63 @@ + +
+
+ +
+ No Matching Values +
+
+ {{ value }} +
+
+ +
+ +
+ + Include Undefined +
diff --git a/tensorboard/webapp/widgets/data_table/filter_dialog_component.scss b/tensorboard/webapp/widgets/data_table/filter_dialog_component.scss new file mode 100644 index 0000000000..e6fb575d81 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/filter_dialog_component.scss @@ -0,0 +1,44 @@ +/* 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. +==============================================================================*/ +@use '@angular/material' as mat; +@import 'tensorboard/webapp/theme/tb_theme'; + +.filter-dialog { + padding: 8px; + border-radius: 4px; + border: 1px solid; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + border-color: mat.get-color-from-palette($tb-foreground, border); + background-color: mat.get-color-from-palette($tb-background, background); + + @include tb-dark-theme { + border-color: mat.get-color-from-palette($tb-dark-foreground, border); + background-color: mat.get-color-from-palette( + $tb-dark-background, + 'background' + ); + } + + .discrete-filters-area { + max-height: 100px; + overflow-y: auto; + } + + .no-matches { + // 12px is the width of the checkbox so the text should match the + // indentation of the selectable filters. + padding: 8px 12px; + } +} diff --git a/tensorboard/webapp/widgets/data_table/filter_dialog_component.ts b/tensorboard/webapp/widgets/data_table/filter_dialog_component.ts new file mode 100644 index 0000000000..aab4c42340 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/filter_dialog_component.ts @@ -0,0 +1,56 @@ +/* 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, EventEmitter, Input, Output} from '@angular/core'; +import { + DomainType, + DiscreteFilter, + IntervalFilter, + DiscreteFilterValue, +} from './types'; +import {RangeValues} from '../range_input/types'; + +@Component({ + selector: 'tb-data-table-filter', + templateUrl: 'filter_dialog_component.ng.html', + styleUrls: ['filter_dialog_component.css'], +}) +export class FilterDialog { + DomainType = DomainType; + + discreteValueFilter: string = ''; + + @Input() filter!: DiscreteFilter | IntervalFilter; + + @Output() discreteFilterChanged = new EventEmitter(); + + @Output() intervalFilterChanged = new EventEmitter(); + + @Output() includeUndefinedToggled = new EventEmitter(); + + getPossibleValues() { + const values: DiscreteFilterValue[] = + (this.filter as DiscreteFilter).possibleValues ?? []; + if (!this.discreteValueFilter) { + return values; + } + return values.filter((value) => + value.toString().match(this.discreteValueFilter) + ); + } + + discreteValueKeyUp(event: KeyboardEvent) { + this.discreteValueFilter = (event.target! as HTMLInputElement).value; + } +} diff --git a/tensorboard/webapp/widgets/data_table/filter_dialog_module.ts b/tensorboard/webapp/widgets/data_table/filter_dialog_module.ts new file mode 100644 index 0000000000..6cbc9a40f3 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/filter_dialog_module.ts @@ -0,0 +1,33 @@ +/* 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 {NgModule} from '@angular/core'; +import {FilterDialog} from './filter_dialog_component'; +import {CommonModule} from '@angular/common'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {RangeInputModule} from '../range_input/range_input_module'; +import {FilterInputModule} from '../filter_input/filter_input_module'; + +@NgModule({ + declarations: [FilterDialog], + imports: [ + CommonModule, + MatCheckboxModule, + FilterInputModule, + RangeInputModule, + ], + exports: [FilterDialog], +}) +export class FilterDialogModule {} diff --git a/tensorboard/webapp/widgets/data_table/filter_dialog_test.ts b/tensorboard/webapp/widgets/data_table/filter_dialog_test.ts new file mode 100644 index 0000000000..2930d90734 --- /dev/null +++ b/tensorboard/webapp/widgets/data_table/filter_dialog_test.ts @@ -0,0 +1,266 @@ +/* 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 {TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {DataTableModule} from './data_table_module'; +import {FilterDialog} from './filter_dialog_component'; +import { + DiscreteFilter, + DomainType, + IntervalFilter, + DiscreteFilterValue, +} from './types'; +import {By} from '@angular/platform-browser'; +import {RangeInputComponent} from '../range_input/range_input_component'; +import {RangeInputModule} from '../range_input/range_input_module'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatCheckboxHarness} from '@angular/material/checkbox/testing'; +import {RangeInputSource} from '../range_input/types'; + +describe('filter dialog', () => { + let rootLoader: HarnessLoader; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FilterDialog], + imports: [DataTableModule, RangeInputModule, MatCheckboxModule], + }).compileComponents(); + }); + + function createComponent(input: {filter: DiscreteFilter | IntervalFilter}) { + const fixture = TestBed.createComponent(FilterDialog); + fixture.componentInstance.filter = input.filter; + rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture); + fixture.detectChanges(); + return fixture; + } + + it('renders interval filters', () => { + const fixture = createComponent({ + filter: { + type: DomainType.INTERVAL, + includeUndefined: false, + minValue: 6, + maxValue: 20, + filterLowerValue: 7, + filterUpperValue: 18, + }, + }); + + const rangeInput = fixture.debugElement.query( + By.directive(RangeInputComponent) + ); + expect(rangeInput).toBeTruthy(); + const [lower, upper, ...rest] = rangeInput.queryAll(By.css('input')); + // There should only be two input fields. + expect(rest.length).toEqual(0); + expect(lower.nativeElement.value).toEqual('7'); + expect(upper.nativeElement.value).toEqual('18'); + }); + + it('dispatches event when an interval filter value is changed', async () => { + const fixture = createComponent({ + filter: { + type: DomainType.INTERVAL, + includeUndefined: false, + minValue: 6, + maxValue: 20, + filterLowerValue: 7, + filterUpperValue: 18, + }, + }); + + const intervalFilterChangedSpy = spyOn( + fixture.componentInstance.intervalFilterChanged, + 'emit' + ); + + const rangeInput = fixture.debugElement.query( + By.directive(RangeInputComponent) + ); + rangeInput.componentInstance.rangeValuesChanged.emit({ + lowerValue: 7, + upperValue: 17, + source: RangeInputSource.TEXT, + }); + expect(intervalFilterChangedSpy).toHaveBeenCalledOnceWith({ + lowerValue: 7, + upperValue: 17, + source: RangeInputSource.TEXT, + }); + }); + + it('dispatches an event when include undefined is changed while viewing an interval filter', async () => { + const fixture = createComponent({ + filter: { + type: DomainType.INTERVAL, + includeUndefined: false, + minValue: 6, + maxValue: 20, + filterLowerValue: 7, + filterUpperValue: 18, + }, + }); + const includeUndefinedSpy = spyOn( + fixture.componentInstance.includeUndefinedToggled, + 'emit' + ); + + const includeUndefinedCheckbox = await rootLoader.getHarness( + MatCheckboxHarness.with({label: 'Include Undefined'}) + ); + await includeUndefinedCheckbox.check(); + expect(includeUndefinedSpy).toHaveBeenCalled(); + }); + + it('renders discrete values', async () => { + createComponent({ + filter: { + type: DomainType.DISCRETE, + includeUndefined: false, + possibleValues: [2, 4, 6, 8], + filterValues: [2, 4, 6], + }, + }); + const checkboxes = await rootLoader.getAllHarnesses(MatCheckboxHarness); + + const checkboxLabels = await Promise.all( + checkboxes.map((checkbox) => checkbox.getLabelText()) + ); + expect(checkboxLabels).toEqual(['2', '4', '6', '8', 'Include Undefined']); + }); + + it('dispatches event when an discrete filter value is changed', async () => { + const possibleValues = [2, 4, 6, 8]; + const fixture = createComponent({ + filter: { + type: DomainType.DISCRETE, + includeUndefined: false, + possibleValues, + filterValues: [2, 4, 6], + }, + }); + const filterValues: DiscreteFilterValue[] = []; + spyOn(fixture.componentInstance.discreteFilterChanged, 'emit').and.callFake( + (value: DiscreteFilterValue) => filterValues.push(value) + ); + + for (const value of possibleValues) { + const checkbox = await rootLoader.getHarness( + MatCheckboxHarness.with({label: `${value}`}) + ); + await checkbox.uncheck(); + } + expect(filterValues).toEqual([2, 4, 6]); + + // Unchecking an unchecked box should not trigger an event. + for (const value of possibleValues) { + const checkbox = await rootLoader.getHarness( + MatCheckboxHarness.with({label: `${value}`}) + ); + await checkbox.uncheck(); + } + expect(filterValues).toEqual([2, 4, 6]); + + for (const value of possibleValues) { + const checkbox = await rootLoader.getHarness( + MatCheckboxHarness.with({label: `${value}`}) + ); + await checkbox.check(); + } + expect(filterValues).toEqual([2, 4, 6, 2, 4, 6, 8]); + + // Checking a checked box should not trigger an event. + for (const value of possibleValues) { + const checkbox = await rootLoader.getHarness( + MatCheckboxHarness.with({label: `${value}`}) + ); + await checkbox.check(); + } + expect(filterValues).toEqual([2, 4, 6, 2, 4, 6, 8]); + }); + + it('dispatches an event when include undefined is changed while viewing a discrete filter', async () => { + const fixture = createComponent({ + filter: { + type: DomainType.DISCRETE, + includeUndefined: false, + possibleValues: [2, 4, 6, 8], + filterValues: [2, 4, 6], + }, + }); + const includeUndefinedSpy = spyOn( + fixture.componentInstance.includeUndefinedToggled, + 'emit' + ); + + const includeUndefinedCheckbox = await rootLoader.getHarness( + MatCheckboxHarness.with({label: 'Include Undefined'}) + ); + await includeUndefinedCheckbox.check(); + expect(includeUndefinedSpy).toHaveBeenCalled(); + }); + + it('filters discrete values', async () => { + const fixture = createComponent({ + filter: { + type: DomainType.DISCRETE, + includeUndefined: false, + possibleValues: ['foo', 'bar', 'baz', 'qaz'], + filterValues: ['foo', 'bar', 'baz', 'qaz'], + }, + }); + expect(await getCheckboxLabels()).toEqual([ + 'foo', + 'bar', + 'baz', + 'qaz', + 'Include Undefined', + ]); // 4 options + the include undefined checkbox + + fixture.componentInstance.discreteValueKeyUp( + // A mock of a keyboard input event. + { + target: { + value: 'ba', + }, + } as any + ); + fixture.detectChanges(); + expect(await getCheckboxLabels()).toEqual([ + 'bar', + 'baz', + 'Include Undefined', + ]); // 2 options + the include undefined checkbox + + fixture.componentInstance.discreteValueKeyUp( + // A mock of a keyboard input event. + { + target: { + value: 'nothing matches me', + }, + } as any + ); + fixture.detectChanges(); + expect(await getCheckboxLabels()).toEqual(['Include Undefined']); // 0 options + the include undefined checkbox + expect(fixture.nativeElement.innerHTML).toContain('No Matching Values'); + + async function getCheckboxLabels() { + const checkboxes = await rootLoader.getAllHarnesses(MatCheckboxHarness); + return Promise.all(checkboxes.map((checkbox) => checkbox.getLabelText())); + } + }); +}); diff --git a/tensorboard/webapp/widgets/data_table/types.ts b/tensorboard/webapp/widgets/data_table/types.ts index 6e1a50d3cc..eca8c0e51a 100644 --- a/tensorboard/webapp/widgets/data_table/types.ts +++ b/tensorboard/webapp/widgets/data_table/types.ts @@ -12,7 +12,6 @@ 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. ==============================================================================*/ - /** * This enum defines the columns available in the data table. The * ScalarCardComponent must know which piece of data is associated with each @@ -23,6 +22,7 @@ export enum ColumnHeaderType { RELATIVE_TIME = 'RELATIVE_TIME', RUN = 'RUN', STEP = 'STEP', + EXPERIMENT = 'EXPERIMENT', TIME = 'TIME', VALUE = 'VALUE', SMOOTHED = 'SMOOTHED', @@ -42,6 +42,33 @@ export enum ColumnHeaderType { CUSTOM = 'CUSTOM', } +export enum DomainType { + DISCRETE, + INTERVAL, +} + +export type DiscreteFilterValues = string[] | number[] | boolean[]; + +export type DiscreteFilterValue = DiscreteFilterValues[number]; + +export interface DiscreteFilter { + type: DomainType.DISCRETE; + includeUndefined: boolean; + possibleValues: DiscreteFilterValues; + // Subset of `possibleValues` + filterValues: DiscreteFilterValues; +} + +export interface IntervalFilter { + type: DomainType.INTERVAL; + includeUndefined: boolean; + minValue: number; + maxValue: number; + // Filter values have to be in between min and max values (inclusive). + filterLowerValue: number; + filterUpperValue: number; +} + export interface ColumnHeader { type: ColumnHeaderType; name: string; @@ -52,6 +79,7 @@ export interface ColumnHeader { removable?: boolean; sortable?: boolean; movable?: boolean; + filterable?: boolean; } export enum SortingOrder {