From d87da101268760512df0fc901f302971eb5e8d49 Mon Sep 17 00:00:00 2001 From: Stephan Lee Date: Tue, 10 Aug 2021 19:02:41 -0700 Subject: [PATCH] core: make sidebar resizable (#5219) This change introduces new layout component that you can use to have consistent layout in a plugin. This layout component is responsible for interactions and remembering the last set width. --- tensorboard/webapp/BUILD | 1 + tensorboard/webapp/core/BUILD | 4 + .../webapp/core/actions/core_actions.ts | 5 + tensorboard/webapp/core/core_module.ts | 29 +- tensorboard/webapp/core/index.ts | 17 ++ tensorboard/webapp/core/store/BUILD | 2 + .../webapp/core/store/core_reducers.ts | 22 ++ .../webapp/core/store/core_reducers_test.ts | 81 ++++++ .../webapp/core/store/core_selectors.ts | 7 + .../webapp/core/store/core_selectors_test.ts | 15 ++ tensorboard/webapp/core/store/core_types.ts | 3 + tensorboard/webapp/core/testing/index.ts | 1 + tensorboard/webapp/core/views/BUILD | 33 ++- .../webapp/core/views/layout_container.scss | 66 +++++ .../webapp/core/views/layout_container.ts | 121 +++++++++ .../webapp/core/views/layout_module.ts | 30 +++ tensorboard/webapp/core/views/layout_test.ts | 248 ++++++++++++++++++ tensorboard/webapp/metrics/views/BUILD | 1 + .../webapp/metrics/views/metrics_container.ts | 8 +- .../metrics/views/metrics_views_module.ts | 10 +- .../persistent_settings_data_source.ts | 8 + .../persistent_settings/_data_source/types.ts | 2 + tensorboard/webapp/testing/mat_icon_module.ts | 1 + tensorboard/webapp/util/BUILD | 7 + tensorboard/webapp/util/dom.ts | 23 ++ .../widgets/line_chart_v2/sub_view/BUILD | 1 + .../sub_view/line_chart_interactive_view.ts | 2 +- third_party/js.bzl | 6 + 28 files changed, 741 insertions(+), 13 deletions(-) create mode 100644 tensorboard/webapp/core/index.ts create mode 100644 tensorboard/webapp/core/views/layout_container.scss create mode 100644 tensorboard/webapp/core/views/layout_container.ts create mode 100644 tensorboard/webapp/core/views/layout_module.ts create mode 100644 tensorboard/webapp/core/views/layout_test.ts create mode 100644 tensorboard/webapp/util/dom.ts diff --git a/tensorboard/webapp/BUILD b/tensorboard/webapp/BUILD index 382b0fe30c9..7cac3d539a4 100644 --- a/tensorboard/webapp/BUILD +++ b/tensorboard/webapp/BUILD @@ -367,6 +367,7 @@ tf_svg_bundle( "@com_google_material_design_icon//:content_copy_24px.svg", "@com_google_material_design_icon//:dark_mode_24px.svg", "@com_google_material_design_icon//:done_24px.svg", + "@com_google_material_design_icon//:drag_indicator_24px.svg", "@com_google_material_design_icon//:edit_24px.svg", "@com_google_material_design_icon//:error_24px.svg", "@com_google_material_design_icon//:expand_less_24px.svg", diff --git a/tensorboard/webapp/core/BUILD b/tensorboard/webapp/core/BUILD index 5bc22130d22..a5b5507b4a7 100644 --- a/tensorboard/webapp/core/BUILD +++ b/tensorboard/webapp/core/BUILD @@ -6,12 +6,16 @@ tf_ng_module( name = "core", srcs = [ "core_module.ts", + "index.ts", ], deps = [ + ":state", "//tensorboard/webapp/core/actions", "//tensorboard/webapp/core/effects", "//tensorboard/webapp/core/store", + "//tensorboard/webapp/core/views:layout", "//tensorboard/webapp/deeplink", + "//tensorboard/webapp/persistent_settings", "//tensorboard/webapp/webapp_data_source", "@npm//@angular/core", "@npm//@ngrx/effects", diff --git a/tensorboard/webapp/core/actions/core_actions.ts b/tensorboard/webapp/core/actions/core_actions.ts index 76efe04d06f..15ae6dfa347 100644 --- a/tensorboard/webapp/core/actions/core_actions.ts +++ b/tensorboard/webapp/core/actions/core_actions.ts @@ -93,3 +93,8 @@ export const fetchRunSucceeded = createAction( '[Core] Run Fetch Successful', props<{runs: Run[]}>() ); + +export const sideBarWidthChanged = createAction( + '[Core] Side Bar Width Changed', + props<{widthInPercent: number}>() +); diff --git a/tensorboard/webapp/core/core_module.ts b/tensorboard/webapp/core/core_module.ts index 2d973754bcf..a455ae86051 100644 --- a/tensorboard/webapp/core/core_module.ts +++ b/tensorboard/webapp/core/core_module.ts @@ -13,25 +13,44 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ import {NgModule} from '@angular/core'; -import {StoreModule} from '@ngrx/store'; import {EffectsModule} from '@ngrx/effects'; +import {createSelector, StoreModule} from '@ngrx/store'; +import {DeepLinkerInterface} from '../deeplink'; +import { + PersistableSettings, + PersistentSettingsConfigModule, +} from '../persistent_settings'; import {TBServerDataSourceModule} from '../webapp_data_source/tb_server_data_source_module'; - -import {reducers} from './store'; import {CoreEffects} from './effects'; -import {CORE_FEATURE_KEY} from './store/core_types'; +import {State} from './state'; +import {getSideBarWidthInPercent, reducers} from './store'; import { CORE_STORE_CONFIG_TOKEN, getConfig, } from './store/core_initial_state_provider'; -import {DeepLinkerInterface} from '../deeplink'; +import {CORE_FEATURE_KEY} from './store/core_types'; + +/** @typehack */ import * as _typeHackNgrxStore from '@ngrx/store'; + +export function getSideBarWidthSetting() { + return createSelector( + getSideBarWidthInPercent, + (sideBarWidthInPercent) => { + return {sideBarWidthInPercent}; + } + ); +} @NgModule({ imports: [ EffectsModule.forFeature([CoreEffects]), StoreModule.forFeature(CORE_FEATURE_KEY, reducers, CORE_STORE_CONFIG_TOKEN), TBServerDataSourceModule, + PersistentSettingsConfigModule.defineGlobalSetting< + State, + PersistableSettings + >(getSideBarWidthSetting), ], providers: [ { diff --git a/tensorboard/webapp/core/index.ts b/tensorboard/webapp/core/index.ts new file mode 100644 index 00000000000..e3382994b01 --- /dev/null +++ b/tensorboard/webapp/core/index.ts @@ -0,0 +1,17 @@ +/* Copyright 2021 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. +==============================================================================*/ + +export {sideBarWidthChanged} from './actions'; +export {LayoutModule} from './views/layout_module'; diff --git a/tensorboard/webapp/core/store/BUILD b/tensorboard/webapp/core/store/BUILD index 5b862b2e30f..6411aef2120 100644 --- a/tensorboard/webapp/core/store/BUILD +++ b/tensorboard/webapp/core/store/BUILD @@ -15,6 +15,7 @@ tf_ng_module( "//tensorboard/webapp/core:types", "//tensorboard/webapp/core/actions", "//tensorboard/webapp/deeplink", + "//tensorboard/webapp/persistent_settings", "//tensorboard/webapp/types", "@npm//@angular/core", "@npm//@ngrx/store", @@ -36,6 +37,7 @@ tf_ts_library( "//tensorboard/webapp/core/actions", "//tensorboard/webapp/core/testing", "//tensorboard/webapp/deeplink", + "//tensorboard/webapp/persistent_settings", "//tensorboard/webapp/types", "@npm//@types/jasmine", ], diff --git a/tensorboard/webapp/core/store/core_reducers.ts b/tensorboard/webapp/core/store/core_reducers.ts index a60c8797e3d..b079746c385 100644 --- a/tensorboard/webapp/core/store/core_reducers.ts +++ b/tensorboard/webapp/core/store/core_reducers.ts @@ -13,6 +13,8 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ import {Action, createReducer, on} from '@ngrx/store'; + +import {globalSettingsLoaded} from '../../persistent_settings'; import {DataLoadState} from '../../types/data'; import * as actions from '../actions'; import {CoreState, initialState} from './core_types'; @@ -159,6 +161,26 @@ const reducer = createReducer( }), on(actions.polymerInteropRunSelectionChanged, (state, {nextSelection}) => { return {...state, polymerInteropRunSelection: new Set(nextSelection)}; + }), + on(actions.sideBarWidthChanged, (state, {widthInPercent}) => { + return { + ...state, + sideBarWidthInPercent: Math.min(Math.max(0, widthInPercent), 100), + }; + }), + on(globalSettingsLoaded, (state, {partialSettings}) => { + const nextState = {...state}; + + const sideBarWidthInPercent = partialSettings.sideBarWidthInPercent; + if ( + typeof sideBarWidthInPercent === 'number' && + sideBarWidthInPercent >= 0 && + sideBarWidthInPercent <= 100 + ) { + nextState.sideBarWidthInPercent = sideBarWidthInPercent; + } + + return nextState; }) ); diff --git a/tensorboard/webapp/core/store/core_reducers_test.ts b/tensorboard/webapp/core/store/core_reducers_test.ts index 89428750c05..a635cbb8b37 100644 --- a/tensorboard/webapp/core/store/core_reducers_test.ts +++ b/tensorboard/webapp/core/store/core_reducers_test.ts @@ -22,6 +22,7 @@ import { } from '../testing'; import {DataLoadState} from '../../types/data'; import {PluginsListFailureCode} from '../types'; +import {globalSettingsLoaded} from '../../persistent_settings'; function createPluginsListing() { return { @@ -558,4 +559,84 @@ describe('core reducer', () => { }); }); }); + + describe('#sideBarWidthChanged', () => { + it('sets sideBarWidthInPercent', () => { + const state = createCoreState({ + sideBarWidthInPercent: 0, + }); + const nextState = reducers( + state, + actions.sideBarWidthChanged({widthInPercent: 30}) + ); + + expect(nextState.sideBarWidthInPercent).toBe(30); + }); + + it('clips the value so it is between 0 and 100, inclusive', () => { + const state1 = createCoreState({ + sideBarWidthInPercent: 5, + }); + const state2 = reducers( + state1, + actions.sideBarWidthChanged({widthInPercent: -10}) + ); + expect(state2.sideBarWidthInPercent).toBe(0); + + const state3 = reducers( + state2, + actions.sideBarWidthChanged({widthInPercent: 100}) + ); + expect(state3.sideBarWidthInPercent).toBe(100); + }); + }); + + describe('#globalSettingsLoaded', () => { + it('loads sideBarWidthInPercent from settings when present', () => { + const state = createCoreState({ + sideBarWidthInPercent: 0, + }); + const nextState = reducers( + state, + globalSettingsLoaded({partialSettings: {sideBarWidthInPercent: 40}}) + ); + + expect(nextState.sideBarWidthInPercent).toBe(40); + }); + + it('ignores partial settings without the sidebar width', () => { + const state = createCoreState({ + sideBarWidthInPercent: 0, + }); + const nextState = reducers( + state, + globalSettingsLoaded({partialSettings: {}}) + ); + + expect(nextState.sideBarWidthInPercent).toBe(0); + }); + + it('loads when value is in between 0-100, inclusive', () => { + const state1 = createCoreState({ + sideBarWidthInPercent: 0, + }); + const state2 = reducers( + state1, + globalSettingsLoaded({partialSettings: {sideBarWidthInPercent: 101}}) + ); + expect(state2.sideBarWidthInPercent).toBe(0); + + const state3 = reducers( + state2, + globalSettingsLoaded({partialSettings: {sideBarWidthInPercent: -1}}) + ); + expect(state3.sideBarWidthInPercent).toBe(0); + + const state4 = reducers( + state3, + globalSettingsLoaded({partialSettings: {sideBarWidthInPercent: NaN}}) + ); + expect(state4.sideBarWidthInPercent).toBe(0); + }); + }); }); diff --git a/tensorboard/webapp/core/store/core_selectors.ts b/tensorboard/webapp/core/store/core_selectors.ts index c98c92a9e45..9c252436823 100644 --- a/tensorboard/webapp/core/store/core_selectors.ts +++ b/tensorboard/webapp/core/store/core_selectors.ts @@ -79,3 +79,10 @@ export const getEnvironment = createSelector( return state.environment; } ); + +export const getSideBarWidthInPercent = createSelector( + selectCoreState, + (state: CoreState): number => { + return state.sideBarWidthInPercent; + } +); diff --git a/tensorboard/webapp/core/store/core_selectors_test.ts b/tensorboard/webapp/core/store/core_selectors_test.ts index 2424e4ececb..f87f47c09ae 100644 --- a/tensorboard/webapp/core/store/core_selectors_test.ts +++ b/tensorboard/webapp/core/store/core_selectors_test.ts @@ -54,4 +54,19 @@ describe('core selectors', () => { expect(selectors.getAppLastLoadedTimeInMs(state)).toBe(1); }); }); + + describe('#getSideBarWidthInPercent', () => { + beforeEach(() => { + selectors.getSideBarWidthInPercent.release(); + }); + + it('returns sidebar width information', () => { + const state = createState( + createCoreState({ + sideBarWidthInPercent: 15, + }) + ); + expect(selectors.getSideBarWidthInPercent(state)).toBe(15); + }); + }); }); diff --git a/tensorboard/webapp/core/store/core_types.ts b/tensorboard/webapp/core/store/core_types.ts index 4cb9cbee4d1..8501282b25e 100644 --- a/tensorboard/webapp/core/store/core_types.ts +++ b/tensorboard/webapp/core/store/core_types.ts @@ -37,6 +37,8 @@ export interface CoreState { // For now, we want them here for Polymer interop states reasons, too. polymerInteropRuns: Run[]; polymerInteropRunSelection: Set; + // Number between 0 and 100. + sideBarWidthInPercent: number; } /* @@ -97,4 +99,5 @@ export const initialState: CoreState = { }, polymerInteropRuns: [], polymerInteropRunSelection: new Set(), + sideBarWidthInPercent: 30, }; diff --git a/tensorboard/webapp/core/testing/index.ts b/tensorboard/webapp/core/testing/index.ts index 8456432a3d2..fb5dd603374 100644 --- a/tensorboard/webapp/core/testing/index.ts +++ b/tensorboard/webapp/core/testing/index.ts @@ -71,6 +71,7 @@ export function createCoreState(override?: Partial): CoreState { }, polymerInteropRuns: [], polymerInteropRunSelection: new Set(), + sideBarWidthInPercent: 0, ...override, }; } diff --git a/tensorboard/webapp/core/views/BUILD b/tensorboard/webapp/core/views/BUILD index d6f8da2d4c1..438582652c8 100644 --- a/tensorboard/webapp/core/views/BUILD +++ b/tensorboard/webapp/core/views/BUILD @@ -1,7 +1,34 @@ -load("//tensorboard/defs:defs.bzl", "tf_ng_module", "tf_ts_library") +load("//tensorboard/defs:defs.bzl", "tf_ng_module", "tf_sass_binary", "tf_ts_library") package(default_visibility = ["//tensorboard:internal"]) +tf_sass_binary( + name = "layout_styles", + src = "layout_container.scss", +) + +tf_ng_module( + name = "layout", + srcs = [ + "layout_container.ts", + "layout_module.ts", + ], + assets = [":layout_styles"], + deps = [ + "//tensorboard/webapp/angular:expect_angular_material_button", + "//tensorboard/webapp/angular:expect_angular_material_icon", + "//tensorboard/webapp/core:state", + "//tensorboard/webapp/core:types", + "//tensorboard/webapp/core/actions", + "//tensorboard/webapp/core/store", + "//tensorboard/webapp/util:dom", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@ngrx/store", + "@npm//rxjs", + ], +) + tf_ng_module( name = "hash_storage", srcs = [ @@ -61,11 +88,13 @@ tf_ts_library( srcs = [ "dark_mode_supporter_test.ts", "hash_storage_test.ts", + "layout_test.ts", "page_title_test.ts", ], deps = [ ":dark_mode_supporter", ":hash_storage", + ":layout", ":page_title", "//tensorboard/webapp:app_state", "//tensorboard/webapp:selectors", @@ -80,6 +109,8 @@ tf_ts_library( "//tensorboard/webapp/core/testing", "//tensorboard/webapp/deeplink", "//tensorboard/webapp/experiments/store:testing", + "//tensorboard/webapp/testing:mat_icon", + "//tensorboard/webapp/util:dom", "@npm//@angular/common", "@npm//@angular/compiler", "@npm//@angular/core", diff --git a/tensorboard/webapp/core/views/layout_container.scss b/tensorboard/webapp/core/views/layout_container.scss new file mode 100644 index 00000000000..3cd792ff35b --- /dev/null +++ b/tensorboard/webapp/core/views/layout_container.scss @@ -0,0 +1,66 @@ +/* Copyright 2021 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 'tensorboard/webapp/theme/tb_theme'; + +:host { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + position: relative; +} + +.sidebar { + max-width: 80vw; +} + +.resizer, +.expand { + @include tb-theme-foreground-prop(border-color, border); + box-sizing: border-box; + flex: 0 0 20px; + justify-self: stretch; + width: 20px; +} + +.resizer { + align-items: center; + border-style: solid; + border-width: 0 1px; + cursor: ew-resize; + display: flex; + justify-self: stretch; + + .mat-icon { + width: 100%; + } +} + +.expand { + align-items: center; + background: transparent; + border-style: solid; + border-width: 0 1px 0 0; + color: inherit; + cursor: pointer; + display: flex; + justify-self: stretch; + padding: 0; + + mat-icon { + transform: rotate(-90deg); + transform-origin: center; + } +} diff --git a/tensorboard/webapp/core/views/layout_container.ts b/tensorboard/webapp/core/views/layout_container.ts new file mode 100644 index 00000000000..bb031c21a9f --- /dev/null +++ b/tensorboard/webapp/core/views/layout_container.ts @@ -0,0 +1,121 @@ +/* Copyright 2021 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, + ElementRef, + OnDestroy, +} from '@angular/core'; +import {Store} from '@ngrx/store'; +import {fromEvent, Observable, Subject} from 'rxjs'; +import {filter, takeUntil} from 'rxjs/operators'; + +import {sideBarWidthChanged} from '../actions'; +import {State} from '../state'; +import {getSideBarWidthInPercent} from '../store/core_selectors'; +import {MouseEventButtons} from '../../util/dom'; + +@Component({ + selector: 'tb-dashboard-layout', + template: ` + + +
+ +
+ + `, + styleUrls: ['layout_container.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LayoutContainer implements OnDestroy { + readonly width$: Observable = this.store.select( + getSideBarWidthInPercent + ); + private readonly ngUnsubscribe = new Subject(); + private resizing: boolean = false; + + readonly MINIMUM_SIDEBAR_WIDTH_IN_PX = 75; + + constructor(private readonly store: Store, hostElRef: ElementRef) { + fromEvent(hostElRef.nativeElement, 'mousemove') + .pipe( + takeUntil(this.ngUnsubscribe), + filter(() => this.resizing) + ) + .subscribe((event) => { + // If mouse ever leaves the browser and comes back, there are chances + // that the LEFT button is no longer being held down. This makes sure + // we don't have a funky UX where sidebar resizes without user + // mousedowning. + if ( + (event.buttons & MouseEventButtons.LEFT) !== + MouseEventButtons.LEFT + ) { + this.resizing = false; + return; + } + // Prevents mousemove from selecting text underneath. + event.preventDefault(); + const {width} = hostElRef.nativeElement.getBoundingClientRect(); + // Collapse the sidebar when it is too small. + const widthInPercent = + event.clientX <= this.MINIMUM_SIDEBAR_WIDTH_IN_PX + ? 0 + : (event.clientX / width) * 100; + this.store.dispatch(sideBarWidthChanged({widthInPercent})); + }); + + fromEvent(hostElRef.nativeElement, 'mouseup', {passive: true}) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe(() => { + this.resizing = false; + }); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } + + resizeGrabbed() { + this.resizing = true; + } + + expandSidebar() { + this.store.dispatch( + sideBarWidthChanged({ + widthInPercent: 20, + }) + ); + } +} diff --git a/tensorboard/webapp/core/views/layout_module.ts b/tensorboard/webapp/core/views/layout_module.ts new file mode 100644 index 00000000000..fa48db1cb54 --- /dev/null +++ b/tensorboard/webapp/core/views/layout_module.ts @@ -0,0 +1,30 @@ +/* Copyright 2021 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 {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; + +import {LayoutContainer} from './layout_container'; + +/** + * Provides component that is responsible for layout of Angular dashboards. + */ +@NgModule({ + declarations: [LayoutContainer], + exports: [LayoutContainer], + imports: [CommonModule, MatIconModule, MatButtonModule], +}) +export class LayoutModule {} diff --git a/tensorboard/webapp/core/views/layout_test.ts b/tensorboard/webapp/core/views/layout_test.ts new file mode 100644 index 00000000000..2a473020a81 --- /dev/null +++ b/tensorboard/webapp/core/views/layout_test.ts @@ -0,0 +1,248 @@ +/* Copyright 2021 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} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {Action, Store} from '@ngrx/store'; +import {MockStore, provideMockStore} from '@ngrx/store/testing'; + +import {MatIconTestingModule} from '../../testing/mat_icon_module'; +import {MouseEventButtons} from '../../util/dom'; +import {sideBarWidthChanged} from '../actions'; +import {State} from '../state'; +import {getSideBarWidthInPercent} from '../store/core_selectors'; +import {LayoutContainer} from './layout_container'; + +@Component({ + selector: 'sidebar', + template: `sidebar content`, +}) +class Sidebar {} + +@Component({ + selector: 'main', + template: `main content`, +}) +class Main {} + +@Component({ + selector: 'testable-component', + template: ` + + +
+
+ `, + styles: [ + ` + :host, + tb-dashboard-layout { + height: 1000px; + position: fixed; + width: 1000px; + } + `, + ], +}) +class TestableComponent {} + +describe('layout test', () => { + let store: MockStore; + let dispatchedActions: Action[] = []; + + const byCss = { + EXPANDER: By.css('.expand'), + RESIZER: By.css('.resizer'), + SIDEBAR_CONTAINER: By.css('nav'), + LAYOUT: By.directive(LayoutContainer), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, MatIconTestingModule], + declarations: [TestableComponent, Main, Sidebar, LayoutContainer], + providers: [provideMockStore()], + }).compileComponents(); + + dispatchedActions = []; + store = TestBed.inject>(Store) as MockStore; + spyOn(store, 'dispatch').and.callFake((action: Action) => { + dispatchedActions.push(action); + }); + store.overrideSelector(getSideBarWidthInPercent, 10); + }); + + it('renders sidebar and main content', () => { + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + expect(fixture.debugElement.nativeElement.textContent).toContain( + 'sidebar content' + ); + expect(fixture.debugElement.nativeElement.textContent).toContain( + 'main content' + ); + }); + + it('does not render sidebar when the width is 0', () => { + store.overrideSelector(getSideBarWidthInPercent, 0); + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + expect(fixture.debugElement.query(byCss.SIDEBAR_CONTAINER)).toBeNull(); + }); + + it('renders expander when sidebar is collapsed', () => { + store.overrideSelector(getSideBarWidthInPercent, 0); + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + expect(fixture.debugElement.query(byCss.EXPANDER)).not.toBeNull(); + + store.overrideSelector(getSideBarWidthInPercent, 10); + store.refreshState(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(byCss.EXPANDER)).toBeNull(); + }); + + it('sets width style on the sidebar', () => { + store.overrideSelector(getSideBarWidthInPercent, 13); + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + const navEl = fixture.debugElement.query(byCss.SIDEBAR_CONTAINER); + expect(navEl.styles['width']).toBe('13%'); + + store.overrideSelector(getSideBarWidthInPercent, 70); + store.refreshState(); + fixture.detectChanges(); + expect(navEl.styles['width']).toBe('70%'); + }); + + describe('interactions', () => { + function triggerMouseMove( + fixture: ComponentFixture, + clientX: number, + buttons = MouseEventButtons.LEFT + ) { + fixture.debugElement.query(byCss.LAYOUT).nativeElement.dispatchEvent( + new MouseEvent('mousemove', { + buttons, + clientX, + }) + ); + } + + function triggerMouseUp(fixture: ComponentFixture) { + fixture.debugElement + .query(byCss.LAYOUT) + .nativeElement.dispatchEvent(new MouseEvent('mouseup')); + } + + function mouseDownOnResizer(fixture: ComponentFixture) { + fixture.debugElement + .query(byCss.RESIZER) + .triggerEventHandler('mousedown', {}); + } + + it('dispatches action when moving mouse while pressing LEFT down', () => { + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + mouseDownOnResizer(fixture); + triggerMouseMove(fixture, 1000); + + triggerMouseMove(fixture, 200); + triggerMouseMove(fixture, 100); + + expect(dispatchedActions).toEqual([ + sideBarWidthChanged({widthInPercent: 100}), + sideBarWidthChanged({widthInPercent: 20}), + sideBarWidthChanged({widthInPercent: 10}), + ]); + }); + + it('does not react when movemove-ing without mousedown on resizer first', () => { + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + triggerMouseMove(fixture, 1000); + triggerMouseMove(fixture, 100); + expect(dispatchedActions).toEqual([]); + }); + + it('ignores mousemove when not pressing on LEFT mouse key', () => { + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + mouseDownOnResizer(fixture); + triggerMouseMove(fixture, 1000); + + triggerMouseMove(fixture, 200, MouseEventButtons.MIDDLE); + // Need to re-click on the resizer. + triggerMouseMove(fixture, 100, MouseEventButtons.LEFT); + + mouseDownOnResizer(fixture); + triggerMouseMove(fixture, 700); + + expect(dispatchedActions).toEqual([ + sideBarWidthChanged({widthInPercent: 100}), + sideBarWidthChanged({widthInPercent: 70}), + ]); + }); + + it('stops resizing when mouseuped', () => { + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + mouseDownOnResizer(fixture); + triggerMouseMove(fixture, 1000); + triggerMouseUp(fixture); + triggerMouseMove(fixture, 700); + + expect(dispatchedActions).toEqual([ + sideBarWidthChanged({widthInPercent: 100}), + ]); + }); + + it('collapses the sidebar when mousemoved to 75px or smaller', () => { + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + mouseDownOnResizer(fixture); + triggerMouseMove(fixture, 75); + triggerMouseMove(fixture, -100); + + expect(dispatchedActions).toEqual([ + sideBarWidthChanged({widthInPercent: 0}), + sideBarWidthChanged({widthInPercent: 0}), + ]); + }); + + it('dispatches action to width 20% when clicking on expander', () => { + store.overrideSelector(getSideBarWidthInPercent, 0); + const fixture = TestBed.createComponent(TestableComponent); + fixture.detectChanges(); + + fixture.debugElement.query(byCss.EXPANDER).nativeElement.click(); + + expect(dispatchedActions).toEqual([ + sideBarWidthChanged({widthInPercent: 20}), + ]); + }); + }); +}); diff --git a/tensorboard/webapp/metrics/views/BUILD b/tensorboard/webapp/metrics/views/BUILD index 0ebd6d03601..d12274d0036 100644 --- a/tensorboard/webapp/metrics/views/BUILD +++ b/tensorboard/webapp/metrics/views/BUILD @@ -24,6 +24,7 @@ tf_ng_module( ":metrics_container_styles", ], deps = [ + "//tensorboard/webapp/core", "//tensorboard/webapp/metrics/views/main_view", "//tensorboard/webapp/metrics/views/right_pane", "//tensorboard/webapp/runs/views/runs_selector", diff --git a/tensorboard/webapp/metrics/views/metrics_container.ts b/tensorboard/webapp/metrics/views/metrics_container.ts index 210fc208ee8..86b246e1b31 100644 --- a/tensorboard/webapp/metrics/views/metrics_container.ts +++ b/tensorboard/webapp/metrics/views/metrics_container.ts @@ -17,10 +17,10 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ selector: 'metrics-dashboard', template: ` - - + + + + `, styleUrls: ['metrics_container.css'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/tensorboard/webapp/metrics/views/metrics_views_module.ts b/tensorboard/webapp/metrics/views/metrics_views_module.ts index 65e5ac31cff..df69c4da334 100644 --- a/tensorboard/webapp/metrics/views/metrics_views_module.ts +++ b/tensorboard/webapp/metrics/views/metrics_views_module.ts @@ -15,8 +15,8 @@ limitations under the License. import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; +import {LayoutModule} from '../../core'; import {RunsSelectorModule} from '../../runs/views/runs_selector/runs_selector_module'; - import {MainViewModule} from './main_view/main_view_module'; import {MetricsDashboardContainer} from './metrics_container'; import {RightPaneModule} from './right_pane/right_pane_module'; @@ -24,6 +24,12 @@ import {RightPaneModule} from './right_pane/right_pane_module'; @NgModule({ declarations: [MetricsDashboardContainer], exports: [MetricsDashboardContainer], - imports: [CommonModule, RightPaneModule, RunsSelectorModule, MainViewModule], + imports: [ + CommonModule, + LayoutModule, + MainViewModule, + RightPaneModule, + RunsSelectorModule, + ], }) export class MetricsViewsModule {} diff --git a/tensorboard/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts b/tensorboard/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts index d4a1a07f315..ebb833b2c2c 100644 --- a/tensorboard/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts +++ b/tensorboard/webapp/persistent_settings/_data_source/persistent_settings_data_source.ts @@ -56,6 +56,7 @@ export class OSSSettingsConverter extends SettingsConverter< paginationSize: settings.pageSize, theme: settings.themeOverride, notificationLastReadTimeInMs: settings.notificationLastReadTimeInMs, + sideBarWidthInPercent: settings.sideBarWidthInPercent, }; return serializableSettings; } @@ -120,6 +121,13 @@ export class OSSSettingsConverter extends SettingsConverter< backendSettings.notificationLastReadTimeInMs; } + if ( + backendSettings.hasOwnProperty('sideBarWidthInPercent') && + typeof backendSettings.sideBarWidthInPercent === 'number' + ) { + settings.sideBarWidthInPercent = backendSettings.sideBarWidthInPercent; + } + return settings; } } diff --git a/tensorboard/webapp/persistent_settings/_data_source/types.ts b/tensorboard/webapp/persistent_settings/_data_source/types.ts index 97ddc0fb78b..b16a8cdf86f 100644 --- a/tensorboard/webapp/persistent_settings/_data_source/types.ts +++ b/tensorboard/webapp/persistent_settings/_data_source/types.ts @@ -35,6 +35,7 @@ export declare interface BackendSettings { paginationSize?: number; theme?: ThemeValue; notificationLastReadTimeInMs?: number; + sideBarWidthInPercent?: number; } /** @@ -51,4 +52,5 @@ export interface PersistableSettings { pageSize?: number; themeOverride?: ThemeValue; notificationLastReadTimeInMs?: number; + sideBarWidthInPercent?: number; } diff --git a/tensorboard/webapp/testing/mat_icon_module.ts b/tensorboard/webapp/testing/mat_icon_module.ts index 46d25cfc5ce..c145b01f76b 100644 --- a/tensorboard/webapp/testing/mat_icon_module.ts +++ b/tensorboard/webapp/testing/mat_icon_module.ts @@ -30,6 +30,7 @@ const KNOWN_SVG_ICON = new Set([ 'content_copy_24px', 'dark_mode_24px', 'done_24px', + 'drag_indicator_24px', 'edit_24px', 'error_24px', 'expand_less_24px', diff --git a/tensorboard/webapp/util/BUILD b/tensorboard/webapp/util/BUILD index 1aac3a90494..7c97da9b102 100644 --- a/tensorboard/webapp/util/BUILD +++ b/tensorboard/webapp/util/BUILD @@ -47,6 +47,13 @@ tf_ts_library( ], ) +tf_ts_library( + name = "dom", + srcs = [ + "dom.ts", + ], +) + tf_ts_library( name = "ui_selectors", srcs = [ diff --git a/tensorboard/webapp/util/dom.ts b/tensorboard/webapp/util/dom.ts new file mode 100644 index 00000000000..0f5ef75061c --- /dev/null +++ b/tensorboard/webapp/util/dom.ts @@ -0,0 +1,23 @@ +/* Copyright 2020 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. +==============================================================================*/ + +// From: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons +export enum MouseEventButtons { + LEFT = 0b1, + RIGHT = 0b10, + MIDDLE = 0b100, + FOURTH = 0b1000, // often 'back' button, but can differ by mouse controller. + FIFTH = 0b100000, // often 'forward' button, but can differ by mouse controller. +} diff --git a/tensorboard/webapp/widgets/line_chart_v2/sub_view/BUILD b/tensorboard/webapp/widgets/line_chart_v2/sub_view/BUILD index 896202b37ec..f6c8d2abac7 100644 --- a/tensorboard/webapp/widgets/line_chart_v2/sub_view/BUILD +++ b/tensorboard/webapp/widgets/line_chart_v2/sub_view/BUILD @@ -37,6 +37,7 @@ tf_ng_module( "//tensorboard/webapp/angular:expect_angular_material_input", "//tensorboard/webapp/angular:expect_angular_material_menu", "//tensorboard/webapp/third_party:d3", + "//tensorboard/webapp/util:dom", "//tensorboard/webapp/widgets/line_chart_v2/lib:public_types", "//tensorboard/webapp/widgets/line_chart_v2/lib:scale", "@npm//@angular/common", diff --git a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts index ddbdbd87619..78cd6b719d6 100644 --- a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts +++ b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts @@ -36,6 +36,7 @@ import { import {fromEvent, of, Subject, timer} from 'rxjs'; import {filter, map, switchMap, takeUntil, tap} from 'rxjs/operators'; +import {MouseEventButtons} from '../../../util/dom'; import { DataSeries, DataSeriesMetadata, @@ -47,7 +48,6 @@ import { Scale, } from '../lib/public_types'; import {getScaleRangeFromDomDim} from './chart_view_utils'; -import {MouseEventButtons} from './internal_types'; import { findClosestIndex, getProposedViewExtentOnZoom, diff --git a/third_party/js.bzl b/third_party/js.bzl index 787bf5ef1f1..036052855e5 100644 --- a/third_party/js.bzl +++ b/third_party/js.bzl @@ -194,6 +194,10 @@ def tensorboard_js_workspace(): "http://mirror.tensorflow.org/raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/image/brightness_6/materialicons/24px.svg", "https://raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/image/brightness_6/materialicons/24px.svg", ], + "5737806d54eae03d5cc02f2dbf7753ecb800fb8fba6ce93e5b1d1c3a9ed5b87b": [ + "http://mirror.tensorflow.org/raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/action/drag_indicator/materialicons/24px.svg", + "https://raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/action/drag_indicator/materialicons/24px.svg", + ], }, rename = { "ic_arrow_downward_24px.svg": "arrow_downward_24px.svg", @@ -255,5 +259,7 @@ def tensorboard_js_workspace(): "https://raw.githubusercontent.com/google/material-design-icons/ab12f16d050ecb1886b606f08825d24b30acafea/src/device/dark_mode/materialicons/24px.svg": "dark_mode_24px.svg", "http://mirror.tensorflow.org/raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/image/brightness_6/materialicons/24px.svg": "brightness_6_24px.svg", "https://raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/image/brightness_6/materialicons/24px.svg": "brightness_6_24px.svg", + "http://mirror.tensorflow.org/raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/action/drag_indicator/materialicons/24px.svg": "drag_indicator_24px.svg", + "https://raw.githubusercontent.com/google/material-design-icons/d3d4aca5a7cf50bc68bbd401cefa708e364194e8/src/action/drag_indicator/materialicons/24px.svg": "drag_indicator_24px.svg", }, )