diff --git a/tensorboard/webapp/BUILD b/tensorboard/webapp/BUILD index cbd29b5968..749240747e 100644 --- a/tensorboard/webapp/BUILD +++ b/tensorboard/webapp/BUILD @@ -275,6 +275,7 @@ tf_ng_web_test_suite( "//tensorboard/webapp/metrics:test_lib", "//tensorboard/webapp/metrics:utils_test", "//tensorboard/webapp/metrics/data_source:metrics_data_source_test", + "//tensorboard/webapp/metrics/data_source:saved_pins_data_source_test", "//tensorboard/webapp/metrics/effects:effects_test", "//tensorboard/webapp/metrics/store:store_test", "//tensorboard/webapp/metrics/views:views_test", diff --git a/tensorboard/webapp/feature_flag/store/feature_flag_metadata.ts b/tensorboard/webapp/feature_flag/store/feature_flag_metadata.ts index a348320a10..63643582bb 100644 --- a/tensorboard/webapp/feature_flag/store/feature_flag_metadata.ts +++ b/tensorboard/webapp/feature_flag/store/feature_flag_metadata.ts @@ -120,6 +120,11 @@ export const FeatureFlagMetadataMap: FeatureFlagMetadataMapType = queryParamOverride: 'enableSuggestedCards', parseValue: parseBoolean, }, + enableGlobalPins: { + defaultValue: false, + queryParamOverride: 'enableGlobalPins', + parseValue: parseBoolean, + }, }; /** diff --git a/tensorboard/webapp/feature_flag/store/feature_flag_selectors.ts b/tensorboard/webapp/feature_flag/store/feature_flag_selectors.ts index c772d691ab..1816bed3aa 100644 --- a/tensorboard/webapp/feature_flag/store/feature_flag_selectors.ts +++ b/tensorboard/webapp/feature_flag/store/feature_flag_selectors.ts @@ -153,3 +153,10 @@ export const getIsScalarColumnContextMenusEnabled = createSelector( return flags.enableScalarColumnContextMenus; } ); + +export const getEnableGlobalPins = createSelector( + getFeatureFlags, + (flags: FeatureFlags): boolean => { + return flags.enableGlobalPins; + } +); diff --git a/tensorboard/webapp/feature_flag/types.ts b/tensorboard/webapp/feature_flag/types.ts index 6304e2b988..74d24e1534 100644 --- a/tensorboard/webapp/feature_flag/types.ts +++ b/tensorboard/webapp/feature_flag/types.ts @@ -50,4 +50,6 @@ export interface FeatureFlags { // Adds a new section at the top of the time series metrics view // containing suggested cards based on the users previous interactions. enableSuggestedCards: boolean; + // Persists pinned scalar cards across multiple experiments. + enableGlobalPins: boolean; } diff --git a/tensorboard/webapp/metrics/data_source/BUILD b/tensorboard/webapp/metrics/data_source/BUILD index 441ee5a39b..56dd7b164e 100644 --- a/tensorboard/webapp/metrics/data_source/BUILD +++ b/tensorboard/webapp/metrics/data_source/BUILD @@ -13,6 +13,7 @@ tf_ng_module( ], deps = [ ":backend_types", + ":saved_pins_data_source", ":types", "//tensorboard/webapp/feature_flag", "//tensorboard/webapp/feature_flag/store", @@ -37,6 +38,18 @@ tf_ng_module( ], ) +tf_ng_module( + name = "saved_pins_data_source", + srcs = [ + "saved_pins_data_source.ts", + "saved_pins_data_source_module.ts", + ], + deps = [ + ":types", + "@npm//@angular/core", + ], +) + tf_ts_library( name = "types", srcs = [ @@ -96,3 +109,16 @@ tf_ts_library( "@npm//@types/jasmine", ], ) + +tf_ts_library( + name = "saved_pins_data_source_test", + testonly = True, + srcs = [ + "saved_pins_data_source_test.ts", + ], + deps = [ + ":saved_pins_data_source", + "//tensorboard/webapp/angular:expect_angular_core_testing", + "@npm//@types/jasmine", + ], +) diff --git a/tensorboard/webapp/metrics/data_source/index.ts b/tensorboard/webapp/metrics/data_source/index.ts index b09db8f686..405c3d1c72 100644 --- a/tensorboard/webapp/metrics/data_source/index.ts +++ b/tensorboard/webapp/metrics/data_source/index.ts @@ -13,5 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ export * from './metrics_data_source'; +export * from './saved_pins_data_source'; export * from './metrics_data_source_module'; +export * from './saved_pins_data_source_module'; export * from './types'; diff --git a/tensorboard/webapp/metrics/data_source/saved_pins_data_source.ts b/tensorboard/webapp/metrics/data_source/saved_pins_data_source.ts new file mode 100644 index 0000000000..da02bf4b11 --- /dev/null +++ b/tensorboard/webapp/metrics/data_source/saved_pins_data_source.ts @@ -0,0 +1,48 @@ +/* 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 {Injectable} from '@angular/core'; +import {Tag} from './types'; + +const SAVED_SCALAR_PINS_KEY = 'tb-saved-scalar-pins'; + +@Injectable() +export class SavedPinsDataSource { + saveScalarPin(tag: Tag): void { + const existingPins = this.getSavedScalarPins(); + if (!existingPins.includes(tag)) { + existingPins.push(tag); + } + window.localStorage.setItem( + SAVED_SCALAR_PINS_KEY, + JSON.stringify(existingPins) + ); + } + + removeScalarPin(tag: Tag): void { + const existingPins = this.getSavedScalarPins(); + window.localStorage.setItem( + SAVED_SCALAR_PINS_KEY, + JSON.stringify(existingPins.filter((pin) => pin !== tag)) + ); + } + + getSavedScalarPins(): Tag[] { + const savedPins = window.localStorage.getItem(SAVED_SCALAR_PINS_KEY); + if (savedPins) { + return JSON.parse(savedPins) as Tag[]; + } + return []; + } +} diff --git a/tensorboard/webapp/metrics/data_source/saved_pins_data_source_module.ts b/tensorboard/webapp/metrics/data_source/saved_pins_data_source_module.ts new file mode 100644 index 0000000000..9bde26b6a6 --- /dev/null +++ b/tensorboard/webapp/metrics/data_source/saved_pins_data_source_module.ts @@ -0,0 +1,21 @@ +/* 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 {NgModule} from '@angular/core'; +import {SavedPinsDataSource} from './saved_pins_data_source'; + +@NgModule({ + providers: [SavedPinsDataSource], +}) +export class SavedPinsDataSourceModule {} diff --git a/tensorboard/webapp/metrics/data_source/saved_pins_data_source_test.ts b/tensorboard/webapp/metrics/data_source/saved_pins_data_source_test.ts new file mode 100644 index 0000000000..325acc32d2 --- /dev/null +++ b/tensorboard/webapp/metrics/data_source/saved_pins_data_source_test.ts @@ -0,0 +1,117 @@ +/* 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 {TestBed} from '@angular/core/testing'; +import {SavedPinsDataSource} from './saved_pins_data_source'; + +const SAVED_SCALAR_PINS_KEY = 'tb-saved-scalar-pins'; + +describe('SavedPinsDataSource Test', () => { + let mockStorage: Record; + let dataSource: SavedPinsDataSource; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [SavedPinsDataSource], + }); + + dataSource = TestBed.inject(SavedPinsDataSource); + + mockStorage = {}; + spyOn(window.localStorage, 'setItem').and.callFake( + (key: string, value: string) => { + if (key !== SAVED_SCALAR_PINS_KEY) { + throw new Error('incorrect key used'); + } + + mockStorage[key] = value; + } + ); + + spyOn(window.localStorage, 'getItem').and.callFake((key: string) => { + if (key !== SAVED_SCALAR_PINS_KEY) { + throw new Error('incorrect key used'); + } + + return mockStorage[key]; + }); + }); + + describe('getSavedScalarPins', () => { + it('gets the saved scalar pins', () => { + window.localStorage.setItem( + SAVED_SCALAR_PINS_KEY, + JSON.stringify(['new_tag']) + ); + + const result = dataSource.getSavedScalarPins(); + + expect(result).toEqual(['new_tag']); + }); + + it('returns empty list if there is no saved pins', () => { + const result = dataSource.getSavedScalarPins(); + + expect(result).toEqual([]); + }); + }); + + describe('saveScalarPin', () => { + it('stores the provided tag in the local storage', () => { + dataSource.saveScalarPin('tag1'); + + expect(dataSource.getSavedScalarPins()).toEqual(['tag1']); + }); + + it('adds the provided tag to the existing list', () => { + window.localStorage.setItem( + SAVED_SCALAR_PINS_KEY, + JSON.stringify(['tag1']) + ); + + dataSource.saveScalarPin('tag2'); + + expect(dataSource.getSavedScalarPins()).toEqual(['tag1', 'tag2']); + }); + + it('does not addd the provided tag if it already exists', () => { + window.localStorage.setItem( + SAVED_SCALAR_PINS_KEY, + JSON.stringify(['tag1', 'tag2']) + ); + + dataSource.saveScalarPin('tag2'); + + expect(dataSource.getSavedScalarPins()).toEqual(['tag1', 'tag2']); + }); + }); + + describe('removeScalarPin', () => { + it('removes the given tag if it exists', () => { + dataSource.saveScalarPin('tag3'); + + dataSource.removeScalarPin('tag3'); + + expect(dataSource.getSavedScalarPins().length).toEqual(0); + }); + + it('does not remove anything if the given tag does not exist', () => { + dataSource.saveScalarPin('tag1'); + + dataSource.removeScalarPin('tag3'); + + expect(dataSource.getSavedScalarPins()).toEqual(['tag1']); + }); + }); +}); diff --git a/tensorboard/webapp/metrics/data_source/types.ts b/tensorboard/webapp/metrics/data_source/types.ts index b85b6f89ea..9ca725dd81 100644 --- a/tensorboard/webapp/metrics/data_source/types.ts +++ b/tensorboard/webapp/metrics/data_source/types.ts @@ -183,3 +183,5 @@ export function isFailedTimeSeriesResponse( ): response is TimeSeriesFailedResponse { return response.hasOwnProperty('error'); } + +export type Tag = string; diff --git a/tensorboard/webapp/metrics/effects/BUILD b/tensorboard/webapp/metrics/effects/BUILD index b0979fc828..1fe50ad839 100644 --- a/tensorboard/webapp/metrics/effects/BUILD +++ b/tensorboard/webapp/metrics/effects/BUILD @@ -45,6 +45,7 @@ tf_ts_library( "//tensorboard/webapp/metrics/actions", "//tensorboard/webapp/metrics/data_source", "//tensorboard/webapp/metrics/store", + "//tensorboard/webapp/testing:utils", "//tensorboard/webapp/types", "//tensorboard/webapp/util:dom", "//tensorboard/webapp/webapp_data_source:http_client_testing", diff --git a/tensorboard/webapp/metrics/effects/index.ts b/tensorboard/webapp/metrics/effects/index.ts index d2f5684ace..317cc96d79 100644 --- a/tensorboard/webapp/metrics/effects/index.ts +++ b/tensorboard/webapp/metrics/effects/index.ts @@ -42,13 +42,14 @@ import { TagMetadata, TimeSeriesRequest, TimeSeriesResponse, + SavedPinsDataSource, } from '../data_source/index'; import { getCardLoadState, getCardMetadata, getMetricsTagMetadataLoadState, } from '../store'; -import {CardId, CardMetadata} from '../types'; +import {CardId, CardMetadata, PluginType} from '../types'; export type CardFetchInfo = CardMetadata & { id: CardId; @@ -73,7 +74,8 @@ export class MetricsEffects implements OnInitEffects { constructor( private readonly actions$: Actions, private readonly store: Store, - private readonly dataSource: MetricsDataSource + private readonly metricsDataSource: MetricsDataSource, + private readonly savedPinsDataSource: SavedPinsDataSource ) {} /** @export */ @@ -141,7 +143,7 @@ export class MetricsEffects implements OnInitEffects { this.store.dispatch(actions.metricsTagMetadataRequested()); }), switchMap(([, , experimentIds]) => { - return this.dataSource.fetchTagMetadata(experimentIds!).pipe( + return this.metricsDataSource.fetchTagMetadata(experimentIds!).pipe( tap((tagMetadata: TagMetadata) => { this.store.dispatch(actions.metricsTagMetadataLoaded({tagMetadata})); }), @@ -174,7 +176,7 @@ export class MetricsEffects implements OnInitEffects { } private fetchTimeSeries(request: TimeSeriesRequest) { - return this.dataSource.fetchTimeSeries([request]).pipe( + return this.metricsDataSource.fetchTimeSeries([request]).pipe( tap((responses: TimeSeriesResponse[]) => { const errors = responses.filter(isFailedTimeSeriesResponse); if (errors.length) { @@ -261,6 +263,35 @@ export class MetricsEffects implements OnInitEffects { }) ); + private readonly addOrRemovePin$ = this.actions$.pipe( + ofType(actions.cardPinStateToggled), + withLatestFrom( + this.getVisibleCardFetchInfos(), + this.store.select(selectors.getEnableGlobalPins) + ), + map( + ([ + {cardId, canCreateNewPins, wasPinned}, + fetchInfos, + enableGlobalPins, + ]) => { + if (!enableGlobalPins) { + return; + } + const card = fetchInfos.find((value) => value.id === cardId); + // Saving only scalar pinned cards. + if (!card || card.plugin !== PluginType.SCALARS) { + return; + } + if (wasPinned) { + this.savedPinsDataSource.removeScalarPin(card.tag); + } else if (canCreateNewPins) { + this.savedPinsDataSource.saveScalarPin(card.tag); + } + } + ) + ); + /** * In general, this effect dispatch the following actions: * @@ -292,7 +323,12 @@ export class MetricsEffects implements OnInitEffects { /** * Subscribes to: card visibility, reloads. */ - this.loadTimeSeries$ + this.loadTimeSeries$, + + /** + * Subscribes to: cardPinStateToggled. + */ + this.addOrRemovePin$ ); }, {dispatch: false} diff --git a/tensorboard/webapp/metrics/effects/metrics_effects_test.ts b/tensorboard/webapp/metrics/effects/metrics_effects_test.ts index 2851c19a7d..85204ed278 100644 --- a/tensorboard/webapp/metrics/effects/metrics_effects_test.ts +++ b/tensorboard/webapp/metrics/effects/metrics_effects_test.ts @@ -38,6 +38,7 @@ import { TagMetadata, TimeSeriesRequest, TimeSeriesResponse, + SavedPinsDataSource, } from '../data_source'; import {getMetricsTagMetadataLoadState} from '../store'; import { @@ -46,12 +47,15 @@ import { buildMetricsState, createScalarStepData, provideTestingMetricsDataSource, + provideTestingSavedPinsDataSource, } from '../testing'; import {CardId, TooltipSort} from '../types'; import {CardFetchInfo, MetricsEffects, TEST_ONLY} from './index'; +import {buildMockState} from '../../testing/utils'; describe('metrics effects', () => { - let dataSource: MetricsDataSource; + let metricsDataSource: MetricsDataSource; + let savedPinsDataSource: SavedPinsDataSource; let effects: MetricsEffects; let store: MockStore; let actions$: Subject; @@ -66,11 +70,14 @@ describe('metrics effects', () => { providers: [ provideMockActions(actions$), provideTestingMetricsDataSource(), + provideTestingSavedPinsDataSource(), MetricsEffects, provideMockStore({ initialState: { - ...appStateFromMetricsState(buildMetricsState()), - ...coreTesting.createState(coreTesting.createCoreState()), + ...buildMockState({ + ...appStateFromMetricsState(buildMetricsState()), + ...coreTesting.createState(coreTesting.createCoreState()), + }), }, }), ], @@ -81,7 +88,8 @@ describe('metrics effects', () => { actualActions.push(action); }); effects = TestBed.inject(MetricsEffects); - dataSource = TestBed.inject(MetricsDataSource); + metricsDataSource = TestBed.inject(MetricsDataSource); + savedPinsDataSource = TestBed.inject(SavedPinsDataSource); store.overrideSelector(selectors.getExperimentIdsFromRoute, null); store.overrideSelector(selectors.getMetricsIgnoreOutliers, false); store.overrideSelector(selectors.getMetricsScalarSmoothing, 0.3); @@ -107,7 +115,7 @@ describe('metrics effects', () => { beforeEach(() => { fetchTagMetadataSubject = new Subject(); fetchTagMetadataSpy = spyOn( - dataSource, + metricsDataSource, 'fetchTagMetadata' ).and.returnValue(fetchTagMetadataSubject); }); @@ -307,10 +315,10 @@ describe('metrics effects', () => { beforeEach(() => { fetchTagMetadataSpy = spyOn( - dataSource, + metricsDataSource, 'fetchTagMetadata' ).and.returnValue(of(buildDataSourceTagMetadata())); - fetchTimeSeriesSpy = spyOn(dataSource, 'fetchTimeSeries'); + fetchTimeSeriesSpy = spyOn(metricsDataSource, 'fetchTimeSeries'); selectSpy = spyOn(store, 'select').and.callThrough(); }); @@ -530,7 +538,7 @@ describe('metrics effects', () => { it('does not fetch when nothing is visible', () => { fetchTimeSeriesSpy = spyOn( - dataSource, + metricsDataSource, 'fetchTimeSeries' ).and.returnValue(of(sampleBackendResponses)); store.overrideSelector(selectors.getExperimentIdsFromRoute, ['exp1']); @@ -553,7 +561,7 @@ describe('metrics effects', () => { it('fetches only once when hiding then showing a card', () => { fetchTimeSeriesSpy = spyOn( - dataSource, + metricsDataSource, 'fetchTimeSeries' ).and.returnValue(of(sampleBackendResponses)); store.overrideSelector(selectors.getExperimentIdsFromRoute, ['exp1']); @@ -608,7 +616,7 @@ describe('metrics effects', () => { it('does not fetch when a loaded card exits and re-enters', () => { fetchTimeSeriesSpy = spyOn( - dataSource, + metricsDataSource, 'fetchTimeSeries' ).and.returnValue(of(sampleBackendResponses)); store.overrideSelector(selectors.getExperimentIdsFromRoute, ['exp1']); @@ -704,7 +712,7 @@ describe('metrics effects', () => { sample: 5, }, ]; - fetchTimeSeriesSpy = spyOn(dataSource, 'fetchTimeSeries'); + fetchTimeSeriesSpy = spyOn(metricsDataSource, 'fetchTimeSeries'); fetchTimeSeriesSpy .withArgs([expectedRequests[0]]) .and.returnValue(of([sampleBackendResponses[0]])); @@ -758,7 +766,7 @@ describe('metrics effects', () => { loadState, }) ); - fetchTimeSeriesSpy = spyOn(dataSource, 'fetchTimeSeries'); + fetchTimeSeriesSpy = spyOn(metricsDataSource, 'fetchTimeSeries'); store.overrideSelector( selectors.getVisibleCardIdSet, @@ -777,5 +785,123 @@ describe('metrics effects', () => { }); } }); + + describe('addOrRemovePin', () => { + let saveScalarPinSpy: jasmine.Spy; + let removeScalarPinSpy: jasmine.Spy; + + beforeEach(() => { + saveScalarPinSpy = spyOn(savedPinsDataSource, 'saveScalarPin'); + removeScalarPinSpy = spyOn(savedPinsDataSource, 'removeScalarPin'); + + store.overrideSelector(selectors.getEnableGlobalPins, true); + store.overrideSelector(TEST_ONLY.getCardFetchInfo, { + id: 'card1', + plugin: PluginType.SCALARS, + tag: 'tagA', + runId: null, + loadState: DataLoadState.LOADED, + }); + store.overrideSelector( + selectors.getVisibleCardIdSet, + new Set(['card1']) + ); + store.refreshState(); + }); + + it('removes scalar pin if the given card was pinned', () => { + actions$.next( + actions.cardPinStateToggled({ + cardId: 'card1', + wasPinned: true, + canCreateNewPins: true, + }) + ); + + expect(removeScalarPinSpy).toHaveBeenCalledWith('tagA'); + expect(saveScalarPinSpy).not.toHaveBeenCalled(); + }); + + it('pins the card if the given card was not pinned and canCreateNewPins is true', () => { + actions$.next( + actions.cardPinStateToggled({ + cardId: 'card1', + wasPinned: false, + canCreateNewPins: true, + }) + ); + + expect(saveScalarPinSpy).toHaveBeenCalledWith('tagA'); + expect(removeScalarPinSpy).not.toHaveBeenCalled(); + }); + + it('does not pin the card if the given card was not pinned and canCreateNewPins is false', () => { + actions$.next( + actions.cardPinStateToggled({ + cardId: 'card1', + wasPinned: false, + canCreateNewPins: false, + }) + ); + + expect(saveScalarPinSpy).not.toHaveBeenCalled(); + expect(removeScalarPinSpy).not.toHaveBeenCalled(); + }); + + it('does not pin the card if the plugin type is not a scalar', () => { + store.overrideSelector(TEST_ONLY.getCardFetchInfo, { + id: 'card2', + plugin: PluginType.HISTOGRAMS, + tag: 'tagA', + runId: null, + loadState: DataLoadState.LOADED, + }); + store.overrideSelector( + selectors.getVisibleCardIdSet, + new Set(['card2']) + ); + store.refreshState(); + + actions$.next( + actions.cardPinStateToggled({ + cardId: 'card2', + wasPinned: false, + canCreateNewPins: true, + }) + ); + + expect(saveScalarPinSpy).not.toHaveBeenCalled(); + expect(removeScalarPinSpy).not.toHaveBeenCalled(); + }); + + it('does not pin the card if there is no matching card', () => { + actions$.next( + actions.cardPinStateToggled({ + cardId: 'card3', + wasPinned: false, + canCreateNewPins: true, + }) + ); + + expect(saveScalarPinSpy).not.toHaveBeenCalled(); + expect(removeScalarPinSpy).not.toHaveBeenCalled(); + }); + + it('does not pin the card if getEnableGlobalPins is false', () => { + store.overrideSelector(selectors.getEnableGlobalPins, false); + store.refreshState(); + + actions$.next( + actions.cardPinStateToggled({ + cardId: 'card1', + wasPinned: false, + canCreateNewPins: true, + }) + ); + + expect(saveScalarPinSpy).not.toHaveBeenCalled(); + expect(removeScalarPinSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/tensorboard/webapp/metrics/metrics_module.ts b/tensorboard/webapp/metrics/metrics_module.ts index 2e9cedc6aa..8e85e15490 100644 --- a/tensorboard/webapp/metrics/metrics_module.ts +++ b/tensorboard/webapp/metrics/metrics_module.ts @@ -22,7 +22,11 @@ import {CoreModule} from '../core/core_module'; import {PersistentSettingsConfigModule} from '../persistent_settings/persistent_settings_config_module'; import {PluginRegistryModule} from '../plugins/plugin_registry_module'; import * as actions from './actions'; -import {MetricsDataSourceModule, METRICS_PLUGIN_ID} from './data_source'; +import { + MetricsDataSourceModule, + METRICS_PLUGIN_ID, + SavedPinsDataSourceModule, +} from './data_source'; import {MetricsEffects} from './effects'; import { getMetricsCardMinWidth, @@ -145,6 +149,7 @@ export function getRangeSelectionHeadersFactory() { reducers, METRICS_STORE_CONFIG_TOKEN ), + SavedPinsDataSourceModule, EffectsModule.forFeature([MetricsEffects]), AlertActionModule.registerAlertActions(alertActionProvider), PersistentSettingsConfigModule.defineGlobalSetting( diff --git a/tensorboard/webapp/metrics/testing.ts b/tensorboard/webapp/metrics/testing.ts index 4dc9757d89..a941997693 100644 --- a/tensorboard/webapp/metrics/testing.ts +++ b/tensorboard/webapp/metrics/testing.ts @@ -27,6 +27,8 @@ import { TagMetadata as DataSourceTagMetadata, TimeSeriesRequest, TimeSeriesResponse, + SavedPinsDataSource, + Tag, } from './data_source'; import * as selectors from './store/metrics_selectors'; import { @@ -393,3 +395,21 @@ export function buildStepIndexMetadata( ...override, }; } + +@Injectable() +export class TestingSavedPinsDataSource { + saveScalarPin(tag: Tag) {} + + removeScalarPin(tag: Tag) {} + + getSavedScalarPins() { + return []; + } +} + +export function provideTestingSavedPinsDataSource() { + return [ + TestingSavedPinsDataSource, + {provide: SavedPinsDataSource, useExisting: TestingSavedPinsDataSource}, + ]; +}