Skip to content

[Global pins] Saving pins to localStorage #6819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tensorboard/webapp/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions tensorboard/webapp/metrics/data_source/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ tf_ng_module(
],
deps = [
":backend_types",
":saved_pins_data_source",
":types",
"//tensorboard/webapp/feature_flag",
"//tensorboard/webapp/feature_flag/store",
Expand All @@ -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 = [
Expand Down Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you haven't already, please don't forget to use copybara_presubmit to import the changes into a local google3 repo and run TAP on it.

(I have no reason to believe that the import will fail but I just want to make sure you've done that step).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I almost forgot to use copybara_presubmit. I checked that TAP passed.

"@npm//@types/jasmine",
],
)
2 changes: 2 additions & 0 deletions tensorboard/webapp/metrics/data_source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
45 changes: 45 additions & 0 deletions tensorboard/webapp/metrics/data_source/saved_pins_data_source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* 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);
}
localStorage.setItem(SAVED_SCALAR_PINS_KEY, JSON.stringify(existingPins));
}

removeScalarPin(tag: Tag): void {
const existingPins = this.getSavedScalarPins();
localStorage.setItem(
SAVED_SCALAR_PINS_KEY,
JSON.stringify(existingPins.filter((pin) => pin !== tag))
);
}

getSavedScalarPins(): Tag[] {
const savedPins = localStorage.getItem(SAVED_SCALAR_PINS_KEY);
if (savedPins) {
return JSON.parse(savedPins) as Tag[];
}
return [];
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
117 changes: 117 additions & 0 deletions tensorboard/webapp/metrics/data_source/saved_pins_data_source_test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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().length).toEqual(1);
expect(dataSource.getSavedScalarPins()).toEqual(['tag1']);
});

it('adds the provided tag to the existing list', () => {
localStorage.setItem(SAVED_SCALAR_PINS_KEY, JSON.stringify(['tag1']));

dataSource.saveScalarPin('tag2');

expect(dataSource.getSavedScalarPins().length).toEqual(2);
expect(dataSource.getSavedScalarPins()).toEqual(['tag1', 'tag2']);
});

it('does not addd the provided tag if it already exists', () => {
localStorage.setItem(
SAVED_SCALAR_PINS_KEY,
JSON.stringify(['tag1', 'tag2'])
);

dataSource.saveScalarPin('tag2');

expect(dataSource.getSavedScalarPins().length).toEqual(2);
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().length).toEqual(1);
});
});
});
2 changes: 2 additions & 0 deletions tensorboard/webapp/metrics/data_source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,5 @@ export function isFailedTimeSeriesResponse(
): response is TimeSeriesFailedResponse {
return response.hasOwnProperty('error');
}

export type Tag = string;
34 changes: 29 additions & 5 deletions tensorboard/webapp/metrics/effects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -73,7 +74,8 @@ export class MetricsEffects implements OnInitEffects {
constructor(
private readonly actions$: Actions,
private readonly store: Store<State>,
private readonly dataSource: MetricsDataSource
private readonly metricsDataSource: MetricsDataSource,
private readonly savedPinsDataSource: SavedPinsDataSource
) {}

/** @export */
Expand Down Expand Up @@ -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}));
}),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -261,6 +263,23 @@ export class MetricsEffects implements OnInitEffects {
})
);

private readonly loadSavedPins$ = this.actions$.pipe(
ofType(actions.cardPinStateToggled),
withLatestFrom(this.getVisibleCardFetchInfos()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(For a followup PR):

Can we check getShouldPersistSettings() before deciding whether to write state?

export const getShouldPersistSettings = createSelector(

The idea is:

  • Internally we have a share feature (in tbcorp).
  • If you load a shared tensorboard, we don't believe any decisions you make should be persisted in local storage and affect your regular usage.
  • getShouldPersistSettings captures this (it returns true by default but returns false if you load a shared dashboard)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, if a shared TensorBoard is used, we should not store user activities in local storage. To determine this, we need to check if getShouldPersistSettings is true. If so, we can store in the local storage; otherwise, we should not. Is this correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is exactly what I would recommend.

map(([{cardId, canCreateNewPins, wasPinned}, fetchInfos]) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we protect this with a feature flag until you have the entire feature built and tested end-to-end?

I'm worried you will build out the rest of the feature and test it and find some reason to make changes to the storage layer. However, people will already have started writing their pins to local storage (even if they don't see the rest of this feature) and perhaps what they store will be incompatible with the final version.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM! Added a new feature flag

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:
*
Expand Down Expand Up @@ -292,7 +311,12 @@ export class MetricsEffects implements OnInitEffects {
/**
* Subscribes to: card visibility, reloads.
*/
this.loadTimeSeries$
this.loadTimeSeries$,

/**
* Subscribes to: cardPinStateToggled.
*/
this.loadSavedPins$
);
},
{dispatch: false}
Expand Down
Loading
Loading