From 7a7500c5b56213832031a06fc1cfcd475996e906 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:02:18 +0000 Subject: [PATCH 1/2] Initial plan From 3288b81c6fe1154bc62e5cc8f5df8914c57ea620 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:12:56 +0000 Subject: [PATCH 2/2] Add reusable tb-list widget with Material list, selection, and disabled support Co-authored-by: cdavalos7 <31576209+cdavalos7@users.noreply.github.com> --- tensorboard/webapp/angular/BUILD | 9 ++ tensorboard/webapp/widgets/list/BUILD | 42 ++++++ .../webapp/widgets/list/list_component.scss | 33 +++++ .../webapp/widgets/list/list_component.ts | 56 +++++++ .../widgets/list/list_component_test.ts | 139 ++++++++++++++++++ .../webapp/widgets/list/list_module.ts | 28 ++++ 6 files changed, 307 insertions(+) create mode 100644 tensorboard/webapp/widgets/list/BUILD create mode 100644 tensorboard/webapp/widgets/list/list_component.scss create mode 100644 tensorboard/webapp/widgets/list/list_component.ts create mode 100644 tensorboard/webapp/widgets/list/list_component_test.ts create mode 100644 tensorboard/webapp/widgets/list/list_module.ts diff --git a/tensorboard/webapp/angular/BUILD b/tensorboard/webapp/angular/BUILD index af6894dce3b..ce0f0fef577 100644 --- a/tensorboard/webapp/angular/BUILD +++ b/tensorboard/webapp/angular/BUILD @@ -204,6 +204,15 @@ tf_ts_library( ], ) +# This is a dummy rule used as a @angular/material/list dependency. +tf_ts_library( + name = "expect_angular_material_list", + srcs = [], + deps = [ + "@npm//@angular/material", + ], +) + # This is a dummy rule used as a @angular/material/menu dependency. tf_ts_library( name = "expect_angular_material_menu", diff --git a/tensorboard/webapp/widgets/list/BUILD b/tensorboard/webapp/widgets/list/BUILD new file mode 100644 index 00000000000..f1a24217397 --- /dev/null +++ b/tensorboard/webapp/widgets/list/BUILD @@ -0,0 +1,42 @@ +load("//tensorboard/defs:defs.bzl", "tf_ng_module", "tf_sass_binary", "tf_ts_library") + +package(default_visibility = ["//tensorboard:internal"]) + +licenses(["notice"]) + +tf_sass_binary( + name = "list_styles", + src = "list_component.scss", +) + +tf_ng_module( + name = "list", + srcs = [ + "list_component.ts", + "list_module.ts", + ], + assets = [ + ":list_styles", + ], + deps = [ + "//tensorboard/webapp/angular:expect_angular_material_list", + "@npm//@angular/common", + "@npm//@angular/core", + ], +) + +tf_ts_library( + name = "list_tests", + testonly = True, + srcs = ["list_component_test.ts"], + deps = [ + ":list", + "//tensorboard/webapp/angular:expect_angular_core_testing", + "//tensorboard/webapp/angular:expect_angular_material_list", + "//tensorboard/webapp/angular:expect_angular_platform_browser_animations", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@angular/platform-browser", + "@npm//@types/jasmine", + ], +) diff --git a/tensorboard/webapp/widgets/list/list_component.scss b/tensorboard/webapp/widgets/list/list_component.scss new file mode 100644 index 00000000000..5c0aa6e5e23 --- /dev/null +++ b/tensorboard/webapp/widgets/list/list_component.scss @@ -0,0 +1,33 @@ +/* Copyright 2024 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. +==============================================================================*/ + +.list-item { + cursor: pointer; + + &.selected { + background-color: rgba(0, 0, 0, 0.08); + font-weight: 500; + } + + &.disabled { + cursor: default; + opacity: 0.5; + pointer-events: none; + } + + &:hover:not(.disabled) { + background-color: rgba(0, 0, 0, 0.04); + } +} diff --git a/tensorboard/webapp/widgets/list/list_component.ts b/tensorboard/webapp/widgets/list/list_component.ts new file mode 100644 index 00000000000..dedc35baf6c --- /dev/null +++ b/tensorboard/webapp/widgets/list/list_component.ts @@ -0,0 +1,56 @@ +/* Copyright 2024 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'; + +export interface ListItem { + value: any; + label: string; + disabled?: boolean; +} + +/** + * A generic list component that displays items and supports single selection. + */ +@Component({ + standalone: false, + selector: 'tb-list', + template: ` + + + {{ item.label }} + + + `, + styleUrls: [`list_component.css`], +}) +export class ListComponent { + @Input() items: ListItem[] = []; + @Input() selectedValue: any = null; + + @Output() selectedValueChange = new EventEmitter(); + + onItemClick(item: ListItem): void { + if (!item.disabled) { + this.selectedValueChange.emit(item.value); + } + } +} diff --git a/tensorboard/webapp/widgets/list/list_component_test.ts b/tensorboard/webapp/widgets/list/list_component_test.ts new file mode 100644 index 00000000000..b325a681d9a --- /dev/null +++ b/tensorboard/webapp/widgets/list/list_component_test.ts @@ -0,0 +1,139 @@ +/* Copyright 2024 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 {CommonModule} from '@angular/common'; +import {Component, Input} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatListModule} from '@angular/material/list'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {ListComponent, ListItem} from './list_component'; + +@Component({ + standalone: false, + selector: 'testing-component', + template: ` + + `, +}) +class TestableComponent { + @Input() items: ListItem[] = []; + @Input() selectedValue: any = null; + @Input() onSelectedValueChange!: (value: any) => void; +} + +describe('tb-list', () => { + let selectionChangeSpy: jasmine.Spy; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CommonModule, MatListModule, NoopAnimationsModule], + declarations: [ListComponent, TestableComponent], + }).compileComponents(); + }); + + function createFixture(input: { + items?: ListItem[]; + selectedValue?: any; + }): ComponentFixture { + const fixture = TestBed.createComponent(TestableComponent); + fixture.componentInstance.items = input.items ?? []; + fixture.componentInstance.selectedValue = input.selectedValue ?? null; + selectionChangeSpy = jasmine.createSpy(); + fixture.componentInstance.onSelectedValueChange = selectionChangeSpy; + return fixture; + } + + it('renders a list of items', () => { + const fixture = createFixture({ + items: [ + {value: 'a', label: 'Item A'}, + {value: 'b', label: 'Item B'}, + ], + }); + fixture.detectChanges(); + + const listItems = fixture.debugElement.queryAll(By.css('.list-item')); + expect(listItems.length).toBe(2); + expect(listItems[0].nativeElement.textContent).toContain('Item A'); + expect(listItems[1].nativeElement.textContent).toContain('Item B'); + }); + + it('marks the selected item with the selected class', () => { + const fixture = createFixture({ + items: [ + {value: 'a', label: 'Item A'}, + {value: 'b', label: 'Item B'}, + ], + selectedValue: 'b', + }); + fixture.detectChanges(); + + const listItems = fixture.debugElement.queryAll(By.css('.list-item')); + expect(listItems[0].nativeElement.classList).not.toContain('selected'); + expect(listItems[1].nativeElement.classList).toContain('selected'); + }); + + it('emits selectedValueChange when an item is clicked', () => { + const fixture = createFixture({ + items: [{value: 'a', label: 'Item A'}], + }); + fixture.detectChanges(); + + const listItem = fixture.debugElement.query(By.css('.list-item')); + listItem.nativeElement.click(); + fixture.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledOnceWith('a'); + }); + + it('does not emit selectedValueChange when a disabled item is clicked', () => { + const fixture = createFixture({ + items: [{value: 'a', label: 'Item A', disabled: true}], + }); + fixture.detectChanges(); + + const listItem = fixture.debugElement.query(By.css('.list-item')); + listItem.nativeElement.click(); + fixture.detectChanges(); + + expect(selectionChangeSpy).not.toHaveBeenCalled(); + }); + + it('adds the disabled class to disabled items', () => { + const fixture = createFixture({ + items: [ + {value: 'a', label: 'Item A', disabled: true}, + {value: 'b', label: 'Item B'}, + ], + }); + fixture.detectChanges(); + + const listItems = fixture.debugElement.queryAll(By.css('.list-item')); + expect(listItems[0].nativeElement.classList).toContain('disabled'); + expect(listItems[1].nativeElement.classList).not.toContain('disabled'); + }); + + it('renders an empty list when no items are provided', () => { + const fixture = createFixture({items: []}); + fixture.detectChanges(); + + const listItems = fixture.debugElement.queryAll(By.css('.list-item')); + expect(listItems.length).toBe(0); + }); +}); diff --git a/tensorboard/webapp/widgets/list/list_module.ts b/tensorboard/webapp/widgets/list/list_module.ts new file mode 100644 index 00000000000..1d915ba1022 --- /dev/null +++ b/tensorboard/webapp/widgets/list/list_module.ts @@ -0,0 +1,28 @@ +/* Copyright 2024 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 {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatListModule} from '@angular/material/list'; +import {ListComponent} from './list_component'; + +/** + * Provides a ListComponent for rendering a selectable list of items. + */ +@NgModule({ + declarations: [ListComponent], + exports: [ListComponent], + imports: [CommonModule, MatListModule], +}) +export class ListModule {}