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 4e861b4190..0809280416 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
@@ -302,7 +302,9 @@ export class ScalarCardDataTable {
}
}
-function makeValueSortable(value: number | string | null | undefined) {
+function makeValueSortable(
+ value: number | string | boolean | null | undefined
+) {
if (
Number.isNaN(value) ||
value === 'NaN' ||
diff --git a/tensorboard/webapp/runs/views/runs_table/runs_data_table.ng.html b/tensorboard/webapp/runs/views/runs_table/runs_data_table.ng.html
index a1adbebef2..fc47374151 100644
--- a/tensorboard/webapp/runs/views/runs_table/runs_data_table.ng.html
+++ b/tensorboard/webapp/runs/views/runs_table/runs_data_table.ng.html
@@ -29,8 +29,16 @@
[header]="header"
[sortingInfo]="sortingInfo"
[hparamsEnabled]="true"
- [controlsEnabled]="header.type !== ColumnHeaderType.COLOR"
- >
+
+
+
+
@@ -42,12 +50,17 @@
[header]="header"
[datum]="dataRow[header.name]"
>
-
-
-
+
+
+
+
+
+
+
+
diff --git a/tensorboard/webapp/runs/views/runs_table/runs_data_table.ts b/tensorboard/webapp/runs/views/runs_table/runs_data_table.ts
index 00707140a1..bbfcc953f3 100644
--- a/tensorboard/webapp/runs/views/runs_table/runs_data_table.ts
+++ b/tensorboard/webapp/runs/views/runs_table/runs_data_table.ts
@@ -41,15 +41,38 @@ export class RunsDataTable {
@Output() sortDataBy = new EventEmitter();
@Output() orderColumns = new EventEmitter();
+ @Output() onSelectionToggle = new EventEmitter();
+ @Output() onAllSelectionToggle = new EventEmitter();
getHeaders() {
- return this.headers.concat([
+ return [
{
- name: 'color',
+ name: 'selected',
displayName: '',
- type: ColumnHeaderType.COLOR,
+ type: ColumnHeaderType.CUSTOM,
enabled: true,
},
- ]);
+ ].concat(
+ this.headers.concat([
+ {
+ name: 'color',
+ displayName: '',
+ type: ColumnHeaderType.COLOR,
+ enabled: true,
+ },
+ ])
+ );
+ }
+
+ getRunIds() {
+ return this.data.map((row) => row.id);
+ }
+
+ allRowsSelected() {
+ return this.data.every((row) => row.selected);
+ }
+
+ someRowsSelected() {
+ return this.data.some((row) => row.selected);
}
}
diff --git a/tensorboard/webapp/runs/views/runs_table/runs_data_table_test.ts b/tensorboard/webapp/runs/views/runs_table/runs_data_table_test.ts
index a35b3c4163..2791a318f3 100644
--- a/tensorboard/webapp/runs/views/runs_table/runs_data_table_test.ts
+++ b/tensorboard/webapp/runs/views/runs_table/runs_data_table_test.ts
@@ -18,6 +18,7 @@ import {TestBed} from '@angular/core/testing';
import {RunsDataTable} from './runs_data_table';
import {DataTableModule} from '../../../widgets/data_table/data_table_module';
import {MatIconTestingModule} from '../../../testing/mat_icon_module';
+import {MatCheckboxModule} from '@angular/material/checkbox';
import {
SortingOrder,
SortingInfo,
@@ -39,6 +40,8 @@ import {ContentCellComponent} from '../../../widgets/data_table/content_cell_com
[sortingInfo]="sortingInfo"
(sortDataBy)="sortDataBy($event)"
(orderColumns)="orderColumns($event)"
+ (onSelectionToggle)="onSelectionToggle($event)"
+ (onAllSelectionToggle)="onAllSelectionToggle($event)"
>
`,
})
@@ -49,9 +52,14 @@ class TestableComponent {
@Input() headers!: ColumnHeader[];
@Input() data!: TableData[];
@Input() sortingInfo!: SortingInfo;
+
+ @Input() onSelectionToggle!: (runId: string) => void;
+ @Input() onAllSelectionToggle!: (runIds: string[]) => void;
}
describe('runs_data_table', () => {
+ let onSelectionToggleSpy: jasmine.Spy;
+ let onAllSelectionToggleSpy: jasmine.Spy;
function createComponent(input: {
data?: TableData[];
headers?: ColumnHeader[];
@@ -88,13 +96,19 @@ describe('runs_data_table', () => {
order: SortingOrder.ASCENDING,
};
+ onSelectionToggleSpy = jasmine.createSpy();
+ fixture.componentInstance.onSelectionToggle = onSelectionToggleSpy;
+
+ onAllSelectionToggleSpy = jasmine.createSpy();
+ fixture.componentInstance.onAllSelectionToggle = onAllSelectionToggleSpy;
+
fixture.detectChanges();
return fixture;
}
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [DataTableModule, MatIconTestingModule],
+ imports: [DataTableModule, MatIconTestingModule, MatCheckboxModule],
declarations: [TestableComponent, RunsDataTable],
}).compileComponents();
});
@@ -106,20 +120,21 @@ describe('runs_data_table', () => {
).toBeTruthy();
});
- it('projects enabled headers plus color column', () => {
+ it('projects enabled headers plus color and selected column', () => {
const fixture = createComponent({});
const dataTable = fixture.debugElement.query(
By.directive(DataTableComponent)
);
const headers = dataTable.queryAll(By.directive(HeaderCellComponent));
- expect(headers.length).toBe(3);
- expect(headers[0].componentInstance.header.name).toEqual('run');
- expect(headers[1].componentInstance.header.name).toEqual('other_header');
- expect(headers[2].componentInstance.header.name).toEqual('color');
+ expect(headers.length).toBe(4);
+ expect(headers[0].componentInstance.header.name).toEqual('selected');
+ expect(headers[1].componentInstance.header.name).toEqual('run');
+ expect(headers[2].componentInstance.header.name).toEqual('other_header');
+ expect(headers[3].componentInstance.header.name).toEqual('color');
});
- it('projects content for each enabled header and color column', () => {
+ it('projects content for each enabled header, selected, and color column', () => {
const fixture = createComponent({
data: [{id: 'runid', run: 'run name', color: 'red', other_header: 'foo'}],
});
@@ -128,10 +143,11 @@ describe('runs_data_table', () => {
);
const cells = dataTable.queryAll(By.directive(ContentCellComponent));
- expect(cells.length).toBe(3);
- expect(cells[0].componentInstance.header.name).toEqual('run');
- expect(cells[1].componentInstance.header.name).toEqual('other_header');
- expect(cells[2].componentInstance.header.name).toEqual('color');
+ expect(cells.length).toBe(4);
+ expect(cells[0].componentInstance.header.name).toEqual('selected');
+ expect(cells[1].componentInstance.header.name).toEqual('run');
+ expect(cells[2].componentInstance.header.name).toEqual('other_header');
+ expect(cells[3].componentInstance.header.name).toEqual('color');
});
it('disables controls for color header', () => {
@@ -148,4 +164,80 @@ describe('runs_data_table', () => {
expect(colorHeader.componentInstance.controlsEnabled).toBe(false);
});
+
+ it('disables controls for selected header', () => {
+ const fixture = createComponent({});
+
+ const dataTable = fixture.debugElement.query(
+ By.directive(DataTableComponent)
+ );
+ const headers = dataTable.queryAll(By.directive(HeaderCellComponent));
+
+ const selectedHeader = headers.find(
+ (h) => h.componentInstance.header.name === 'selected'
+ )!;
+
+ expect(selectedHeader.componentInstance.controlsEnabled).toBe(false);
+ });
+
+ it('adds checkbox to selected column', () => {
+ const fixture = createComponent({});
+
+ const dataTable = fixture.debugElement.query(
+ By.directive(DataTableComponent)
+ );
+ const headers = dataTable.queryAll(By.directive(HeaderCellComponent));
+ const cells = dataTable.queryAll(By.directive(ContentCellComponent));
+
+ const selectedHeader = headers.find(
+ (h) => h.componentInstance.header.name === 'selected'
+ )!;
+
+ const selectedContentCells = cells.filter((cell) => {
+ return cell.componentInstance.header.name === 'selected';
+ });
+
+ expect(selectedHeader.query(By.css('mat-checkbox'))).toBeTruthy();
+ selectedContentCells.forEach((cell) => {
+ expect(cell.query(By.css('mat-checkbox'))).toBeTruthy();
+ });
+ });
+
+ it('emits onAllSelectionToggle event when selected header checkbox is clicked', () => {
+ const fixture = createComponent({});
+
+ const dataTable = fixture.debugElement.query(
+ By.directive(DataTableComponent)
+ );
+ const headers = dataTable.queryAll(By.directive(HeaderCellComponent));
+
+ const selectedHeader = headers.find(
+ (h) => h.componentInstance.header.name === 'selected'
+ )!;
+
+ const selectedCheckbox = selectedHeader.query(By.css('mat-checkbox'));
+
+ selectedCheckbox.nativeElement.dispatchEvent(new MouseEvent('click'));
+
+ expect(onAllSelectionToggleSpy).toHaveBeenCalledWith(['runid']);
+ });
+
+ it('emits onSelectionToggle event when selected content checkbox is clicked', () => {
+ const fixture = createComponent({});
+
+ const dataTable = fixture.debugElement.query(
+ By.directive(DataTableComponent)
+ );
+ const cells = dataTable.queryAll(By.directive(ContentCellComponent));
+
+ const selectedContentCells = cells.filter((cell) => {
+ return cell.componentInstance.header.name === 'selected';
+ });
+
+ const firstCheckbox = selectedContentCells[0].query(By.css('mat-checkbox'));
+
+ firstCheckbox.nativeElement.dispatchEvent(new Event('change'));
+
+ expect(onSelectionToggleSpy).toHaveBeenCalledWith('runid');
+ });
});
diff --git a/tensorboard/webapp/runs/views/runs_table/runs_table_container.ts b/tensorboard/webapp/runs/views/runs_table/runs_table_container.ts
index bcb58f745f..185ee83694 100644
--- a/tensorboard/webapp/runs/views/runs_table/runs_table_container.ts
+++ b/tensorboard/webapp/runs/views/runs_table/runs_table_container.ts
@@ -221,7 +221,7 @@ function matchFilter(
[regexFilter]="regexFilter$ | async"
[sortOption]="sortOption$ | async"
[usePagination]="usePagination"
- (onSelectionToggle)="onRunSelectionToggle($event)"
+ (onSelectionToggle)="onRunItemSelectionToggle($event)"
(onSelectionDblClick)="onRunSelectionDblClick($event)"
(onPageSelectionToggle)="onPageSelectionToggle($event)"
(onPaginationChange)="onPaginationChange($event)"
@@ -239,6 +239,8 @@ function matchFilter(
[sortingInfo]="sortingInfo$ | async"
(sortDataBy)="sortDataBy($event)"
(orderColumns)="orderColumns($event)"
+ (onSelectionToggle)="onRunSelectionToggle($event)"
+ (onAllSelectionToggle)="onAllSelectionToggle($event)"
>
`,
host: {
@@ -579,15 +581,18 @@ export class RunsTableContainer implements OnInit, OnDestroy {
return combineLatest([
this.store.select(getRuns, {experimentId}),
this.store.select(getRunColorMap),
+ this.store.select(getCurrentRouteRunSelection),
this.runsColumns$,
this.runToHParamValues$,
]).pipe(
- map(([runs, colorMap, runsColumns, runToHParamValues]) => {
+ map(([runs, colorMap, selectionMap, runsColumns, runToHParamValues]) => {
return runs.map((run) => {
const tableData: TableData = {
id: run.id,
color: colorMap[run.id],
+ selected: Boolean(selectionMap?.get(run.id)),
};
+
runsColumns.forEach((column) => {
switch (column.type) {
case ColumnHeaderType.RUN:
@@ -646,7 +651,7 @@ export class RunsTableContainer implements OnInit, OnDestroy {
);
}
- onRunSelectionToggle(item: RunTableItem) {
+ onRunItemSelectionToggle(item: RunTableItem) {
this.store.dispatch(
runSelectionToggled({
runId: item.run.id,
@@ -654,6 +659,14 @@ export class RunsTableContainer implements OnInit, OnDestroy {
);
}
+ onRunSelectionToggle(id: string) {
+ this.store.dispatch(
+ runSelectionToggled({
+ runId: id,
+ })
+ );
+ }
+
onRunSelectionDblClick(item: RunTableItem) {
// Note that a user's double click will trigger both 'change' and 'dblclick'
// events so onRunSelectionToggle() will also be called and we will fire
@@ -674,6 +687,14 @@ export class RunsTableContainer implements OnInit, OnDestroy {
);
}
+ onAllSelectionToggle(runIds: string[]) {
+ this.store.dispatch(
+ runPageSelectionToggled({
+ runIds,
+ })
+ );
+ }
+
// When `usePagination` is false, page selection affects the single page,
// containing all items.
onPageSelectionToggle(event: {items: RunTableItem[]}) {
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 5285a91692..d2728fe65f 100644
--- a/tensorboard/webapp/runs/views/runs_table/runs_table_test.ts
+++ b/tensorboard/webapp/runs/views/runs_table/runs_table_test.ts
@@ -3194,7 +3194,7 @@ describe('runs_table', () => {
).toBeFalsy();
});
- it('passes run name and color to data table', () => {
+ it('passes run name, selected value, 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
@@ -3221,6 +3221,14 @@ describe('runs_table', () => {
book2: '#111',
});
+ store.overrideSelector(
+ getCurrentRouteRunSelection,
+ new Map([
+ ['book1', true],
+ ['book2', false],
+ ])
+ );
+
const fixture = createComponent(['book']);
fixture.detectChanges();
const runsDataTable = fixture.debugElement.query(
@@ -3228,11 +3236,42 @@ describe('runs_table', () => {
);
expect(runsDataTable.componentInstance.data).toEqual([
- {id: 'book1', color: '#000', run: "The Philosopher's Stone"},
- {id: 'book2', color: '#111', run: 'The Chamber Of Secrets'},
+ {
+ id: 'book1',
+ color: '#000',
+ run: "The Philosopher's Stone",
+ selected: true,
+ },
+ {
+ id: 'book2',
+ color: '#111',
+ run: 'The Chamber Of Secrets',
+ selected: false,
+ },
]);
});
+ it('passes selected value of false if run is not in selectionMap', () => {
+ // 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'})]));
+
+ store.overrideSelector(
+ getCurrentRouteRunSelection,
+ new Map([['otherbook', true]])
+ );
+
+ const fixture = createComponent(['book']);
+ fixture.detectChanges();
+ const runsDataTable = fixture.debugElement.query(
+ By.directive(RunsDataTable)
+ );
+
+ expect(runsDataTable.componentInstance.data[0].selected).toEqual(false);
+ });
+
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'});
@@ -3277,10 +3316,8 @@ describe('runs_table', () => {
By.directive(RunsDataTable)
);
- expect(runsDataTable.componentInstance.data).toEqual([
- {id: 'book1', color: '#000', batch_size: 1},
- {id: 'book2', color: '#111', batch_size: 2},
- ]);
+ expect(runsDataTable.componentInstance.data[0].batch_size).toEqual(1);
+ expect(runsDataTable.componentInstance.data[1].batch_size).toEqual(2);
});
});
});
diff --git a/tensorboard/webapp/widgets/data_table/data_table_component.scss b/tensorboard/webapp/widgets/data_table/data_table_component.scss
index 2303675c27..aa4d727d3c 100644
--- a/tensorboard/webapp/widgets/data_table/data_table_component.scss
+++ b/tensorboard/webapp/widgets/data_table/data_table_component.scss
@@ -27,10 +27,6 @@ $_accent: map-get(mat.get-color-config($tb-theme), accent);
display: table-row;
}
- .col {
- display: table-cell;
- }
-
.header {
background-color: mat.get-color-from-palette($tb-background, background);
position: sticky;
diff --git a/tensorboard/webapp/widgets/data_table/types.ts b/tensorboard/webapp/widgets/data_table/types.ts
index f17dcbb072..46b67b9103 100644
--- a/tensorboard/webapp/widgets/data_table/types.ts
+++ b/tensorboard/webapp/widgets/data_table/types.ts
@@ -39,6 +39,7 @@ export enum ColumnHeaderType {
MEAN = 'MEAN',
RAW_CHANGE = 'RAW_CHANGE',
HPARAM = 'HPARAM',
+ CUSTOM = 'CUSTOM',
}
export interface ColumnHeader {
@@ -68,7 +69,7 @@ export interface SortingInfo {
* DataTable. It will have a value for each required ColumnHeader for a given
* run.
*/
-export type TableData = Record & {
+export type TableData = Record & {
id: string;
};