diff --git a/tensorboard/webapp/metrics/data_source/BUILD b/tensorboard/webapp/metrics/data_source/BUILD index b65f74c320..441ee5a39b 100644 --- a/tensorboard/webapp/metrics/data_source/BUILD +++ b/tensorboard/webapp/metrics/data_source/BUILD @@ -25,6 +25,18 @@ tf_ng_module( ], ) +tf_ng_module( + name = "card_interactions_data_source", + srcs = [ + "card_interactions_data_source.ts", + "card_interactions_data_source_module.ts", + ], + deps = [ + "//tensorboard/webapp/metrics/store:types", + "@npm//@angular/core", + ], +) + tf_ts_library( name = "types", srcs = [ @@ -70,3 +82,17 @@ tf_ts_library( "@npm//rxjs", ], ) + +tf_ts_library( + name = "card_interactions_data_source_test", + testonly = True, + srcs = [ + "card_interactions_data_source_test.ts", + ], + deps = [ + ":card_interactions_data_source", + "//tensorboard/webapp/angular:expect_angular_core_testing", + "//tensorboard/webapp/metrics:internal_types", + "@npm//@types/jasmine", + ], +) diff --git a/tensorboard/webapp/metrics/data_source/card_interactions_data_source.ts b/tensorboard/webapp/metrics/data_source/card_interactions_data_source.ts new file mode 100644 index 0000000000..63ffd953ed --- /dev/null +++ b/tensorboard/webapp/metrics/data_source/card_interactions_data_source.ts @@ -0,0 +1,57 @@ +/* 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 {Injectable} from '@angular/core'; +import {CardInteractions} from '../store/metrics_types'; + +const CARD_INTERACTIONS_KEY = 'tb-card-interactions'; + +const MAX_RECORDS: Record = { + pins: 10, + clicks: 10, + tagFilters: 10, +}; + +@Injectable() +export class CardInteractionsDataSource { + saveCardInteractions(cardInteractions: CardInteractions) { + const trimmedInteractions: CardInteractions = { + pins: cardInteractions.pins.slice( + cardInteractions.pins.length - MAX_RECORDS.pins + ), + clicks: cardInteractions.clicks.slice( + cardInteractions.clicks.length - MAX_RECORDS.clicks + ), + tagFilters: cardInteractions.tagFilters.slice( + cardInteractions.tagFilters.length - MAX_RECORDS.tagFilters + ), + }; + localStorage.setItem( + CARD_INTERACTIONS_KEY, + JSON.stringify(trimmedInteractions) + ); + } + + getCardInteractions(): CardInteractions { + const existingInteractions = localStorage.getItem(CARD_INTERACTIONS_KEY); + if (existingInteractions) { + return JSON.parse(existingInteractions) as CardInteractions; + } + return { + tagFilters: [], + pins: [], + clicks: [], + }; + } +} diff --git a/tensorboard/webapp/metrics/data_source/card_interactions_data_source_module.ts b/tensorboard/webapp/metrics/data_source/card_interactions_data_source_module.ts new file mode 100644 index 0000000000..7f903e1395 --- /dev/null +++ b/tensorboard/webapp/metrics/data_source/card_interactions_data_source_module.ts @@ -0,0 +1,21 @@ +/* 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 {CardInteractionsDataSource} from './card_interactions_data_source'; + +@NgModule({ + providers: [CardInteractionsDataSource], +}) +export class MetricsCardInteractionsDataSourceModule {} diff --git a/tensorboard/webapp/metrics/data_source/card_interactions_data_source_test.ts b/tensorboard/webapp/metrics/data_source/card_interactions_data_source_test.ts new file mode 100644 index 0000000000..5966f9dc62 --- /dev/null +++ b/tensorboard/webapp/metrics/data_source/card_interactions_data_source_test.ts @@ -0,0 +1,125 @@ +/* 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 {CardInteractionsDataSource} from './card_interactions_data_source'; +import {PluginType} from '../internal_types'; + +describe('CardInteractionsDataSource Test', () => { + let mockStorage: Record; + let dataSource: CardInteractionsDataSource; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [CardInteractionsDataSource], + }); + + dataSource = TestBed.inject(CardInteractionsDataSource); + + mockStorage = {}; + spyOn(window.localStorage, 'setItem').and.callFake( + (key: string, value: string) => { + if (key !== 'tb-card-interactions') { + throw new Error('incorrect key used'); + } + + mockStorage[key] = value; + } + ); + + spyOn(window.localStorage, 'getItem').and.callFake((key: string) => { + if (key !== 'tb-card-interactions') { + throw new Error('incorrect key used'); + } + + return mockStorage[key]; + }); + }); + + describe('saveCardInteractions', () => { + it('only saves 10 pins', () => { + dataSource.saveCardInteractions({ + clicks: [], + tagFilters: [], + pins: Array.from({length: 12}).map((_, index) => ({ + cardId: `card-${index}`, + runId: null, + tag: 'foo', + plugin: PluginType.SCALARS, + })), + }); + + expect(dataSource.getCardInteractions().pins.length).toEqual(10); + }); + + it('only saves 10 clicks', () => { + dataSource.saveCardInteractions({ + pins: [], + tagFilters: [], + clicks: Array.from({length: 12}).map((_, index) => ({ + cardId: `card-${index}`, + runId: null, + tag: 'foo', + plugin: PluginType.SCALARS, + })), + }); + + expect(dataSource.getCardInteractions().clicks.length).toEqual(10); + }); + + it('only saves 10 tagFilgers', () => { + dataSource.saveCardInteractions({ + clicks: [], + tagFilters: Array.from({length: 12}).map((_, index) => + index.toString() + ), + pins: [], + }); + + expect(dataSource.getCardInteractions().tagFilters.length).toEqual(10); + }); + }); + + describe('getCardInteractions', () => { + it('returns all default state when key is not set', () => { + expect(dataSource.getCardInteractions()).toEqual({ + tagFilters: [], + pins: [], + clicks: [], + }); + }); + + it('returns previously written value', () => { + dataSource.saveCardInteractions({ + tagFilters: ['foo'], + clicks: [ + {cardId: '1', runId: null, tag: 'foo', plugin: PluginType.SCALARS}, + ], + pins: [ + {cardId: '2', runId: null, tag: 'bar', plugin: PluginType.SCALARS}, + ], + }); + + expect(dataSource.getCardInteractions()).toEqual({ + tagFilters: ['foo'], + clicks: [ + {cardId: '1', runId: null, tag: 'foo', plugin: PluginType.SCALARS}, + ], + pins: [ + {cardId: '2', runId: null, tag: 'bar', plugin: PluginType.SCALARS}, + ], + }); + }); + }); +}); diff --git a/tensorboard/webapp/metrics/store/metrics_reducers.ts b/tensorboard/webapp/metrics/store/metrics_reducers.ts index 45b5564233..9805339166 100644 --- a/tensorboard/webapp/metrics/store/metrics_reducers.ts +++ b/tensorboard/webapp/metrics/store/metrics_reducers.ts @@ -453,6 +453,16 @@ const {initialState, reducers: namespaceContextedReducer} = settings: METRICS_SETTINGS_DEFAULT, settingOverrides: {}, visibleCardMap: new Map(), + previousCardInteractions: { + tagFilters: [], + pins: [], + clicks: [], + }, + newCardInteractions: { + tagFilters: [], + pins: [], + clicks: [], + }, }, /** onNavigated */ diff --git a/tensorboard/webapp/metrics/store/metrics_types.ts b/tensorboard/webapp/metrics/store/metrics_types.ts index b3364cc196..c89368db7b 100644 --- a/tensorboard/webapp/metrics/store/metrics_types.ts +++ b/tensorboard/webapp/metrics/store/metrics_types.ts @@ -27,6 +27,7 @@ import { } from '../data_source'; import { CardId, + CardIdWithMetadata, CardMetadata, CardUniqueInfo, HistogramMode, @@ -166,6 +167,12 @@ export type CardStepIndexMap = Record< CardStepIndexMetaData | null >; +export type CardInteractions = { + tagFilters: string[]; + pins: CardIdWithMetadata[]; + clicks: CardIdWithMetadata[]; +}; + export type CardToPinnedCard = Map; export type PinnedCardToCard = Map; @@ -254,6 +261,8 @@ export interface MetricsNonNamespacedState { * Map from ElementId to CardId. Only contains all visible cards. */ visibleCardMap: Map; + previousCardInteractions: CardInteractions; + newCardInteractions: CardInteractions; } export type MetricsState = NamespaceContextedState< diff --git a/tensorboard/webapp/metrics/testing.ts b/tensorboard/webapp/metrics/testing.ts index fbb8a94f76..42d4d5be70 100644 --- a/tensorboard/webapp/metrics/testing.ts +++ b/tensorboard/webapp/metrics/testing.ts @@ -112,6 +112,16 @@ function buildBlankState(): MetricsState { isSettingsPaneOpen: false, isSlideoutMenuOpen: false, tableEditorSelectedTab: DataTableMode.SINGLE, + previousCardInteractions: { + tagFilters: [], + pins: [], + clicks: [], + }, + newCardInteractions: { + tagFilters: [], + pins: [], + clicks: [], + }, }; }