Skip to content

Commit 4f4801c

Browse files
authored
[Global pins] Saving pins to localStorage (#6819)
## Motivation for features / changes [Global pins] To extend pinned card info available globally, this PR saves pinned info to localStorage ## Technical description of changes * Introduced `savedPinsDataSource` to store pinned card tag information in local storage. * Since the focus in on saving scalar cards, only the tag name is persisted. * Implemented methods `saveScalarPin`, `removeScalarPin`, and `getSavedScalarPins`. * Added `addOrRemovePin$` effect in the `metrics/effects/index.ts` * When the `actions. cardPinStateToggled` is triggered, find the card info in the `visibeCardFetchInfos` , and the scalar pin info is saved or removed accordingly. ## Screenshots of UI changes (or N/A) N/A ## Detailed steps to verify changes work correctly (as executed by you) * Unit tests pass ## Alternate designs / implementations considered (or N/A) N/A
1 parent 81e1393 commit 4f4801c

15 files changed

+437
-18
lines changed

tensorboard/webapp/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ tf_ng_web_test_suite(
275275
"//tensorboard/webapp/metrics:test_lib",
276276
"//tensorboard/webapp/metrics:utils_test",
277277
"//tensorboard/webapp/metrics/data_source:metrics_data_source_test",
278+
"//tensorboard/webapp/metrics/data_source:saved_pins_data_source_test",
278279
"//tensorboard/webapp/metrics/effects:effects_test",
279280
"//tensorboard/webapp/metrics/store:store_test",
280281
"//tensorboard/webapp/metrics/views:views_test",

tensorboard/webapp/feature_flag/store/feature_flag_metadata.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ export const FeatureFlagMetadataMap: FeatureFlagMetadataMapType<FeatureFlags> =
120120
queryParamOverride: 'enableSuggestedCards',
121121
parseValue: parseBoolean,
122122
},
123+
enableGlobalPins: {
124+
defaultValue: false,
125+
queryParamOverride: 'enableGlobalPins',
126+
parseValue: parseBoolean,
127+
},
123128
};
124129

125130
/**

tensorboard/webapp/feature_flag/store/feature_flag_selectors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,10 @@ export const getIsScalarColumnContextMenusEnabled = createSelector(
153153
return flags.enableScalarColumnContextMenus;
154154
}
155155
);
156+
157+
export const getEnableGlobalPins = createSelector(
158+
getFeatureFlags,
159+
(flags: FeatureFlags): boolean => {
160+
return flags.enableGlobalPins;
161+
}
162+
);

tensorboard/webapp/feature_flag/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,6 @@ export interface FeatureFlags {
5050
// Adds a new section at the top of the time series metrics view
5151
// containing suggested cards based on the users previous interactions.
5252
enableSuggestedCards: boolean;
53+
// Persists pinned scalar cards across multiple experiments.
54+
enableGlobalPins: boolean;
5355
}

tensorboard/webapp/metrics/data_source/BUILD

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ tf_ng_module(
1313
],
1414
deps = [
1515
":backend_types",
16+
":saved_pins_data_source",
1617
":types",
1718
"//tensorboard/webapp/feature_flag",
1819
"//tensorboard/webapp/feature_flag/store",
@@ -37,6 +38,18 @@ tf_ng_module(
3738
],
3839
)
3940

41+
tf_ng_module(
42+
name = "saved_pins_data_source",
43+
srcs = [
44+
"saved_pins_data_source.ts",
45+
"saved_pins_data_source_module.ts",
46+
],
47+
deps = [
48+
":types",
49+
"@npm//@angular/core",
50+
],
51+
)
52+
4053
tf_ts_library(
4154
name = "types",
4255
srcs = [
@@ -96,3 +109,16 @@ tf_ts_library(
96109
"@npm//@types/jasmine",
97110
],
98111
)
112+
113+
tf_ts_library(
114+
name = "saved_pins_data_source_test",
115+
testonly = True,
116+
srcs = [
117+
"saved_pins_data_source_test.ts",
118+
],
119+
deps = [
120+
":saved_pins_data_source",
121+
"//tensorboard/webapp/angular:expect_angular_core_testing",
122+
"@npm//@types/jasmine",
123+
],
124+
)

tensorboard/webapp/metrics/data_source/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ See the License for the specific language governing permissions and
1313
limitations under the License.
1414
==============================================================================*/
1515
export * from './metrics_data_source';
16+
export * from './saved_pins_data_source';
1617
export * from './metrics_data_source_module';
18+
export * from './saved_pins_data_source_module';
1719
export * from './types';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {Injectable} from '@angular/core';
16+
import {Tag} from './types';
17+
18+
const SAVED_SCALAR_PINS_KEY = 'tb-saved-scalar-pins';
19+
20+
@Injectable()
21+
export class SavedPinsDataSource {
22+
saveScalarPin(tag: Tag): void {
23+
const existingPins = this.getSavedScalarPins();
24+
if (!existingPins.includes(tag)) {
25+
existingPins.push(tag);
26+
}
27+
window.localStorage.setItem(
28+
SAVED_SCALAR_PINS_KEY,
29+
JSON.stringify(existingPins)
30+
);
31+
}
32+
33+
removeScalarPin(tag: Tag): void {
34+
const existingPins = this.getSavedScalarPins();
35+
window.localStorage.setItem(
36+
SAVED_SCALAR_PINS_KEY,
37+
JSON.stringify(existingPins.filter((pin) => pin !== tag))
38+
);
39+
}
40+
41+
getSavedScalarPins(): Tag[] {
42+
const savedPins = window.localStorage.getItem(SAVED_SCALAR_PINS_KEY);
43+
if (savedPins) {
44+
return JSON.parse(savedPins) as Tag[];
45+
}
46+
return [];
47+
}
48+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {NgModule} from '@angular/core';
16+
import {SavedPinsDataSource} from './saved_pins_data_source';
17+
18+
@NgModule({
19+
providers: [SavedPinsDataSource],
20+
})
21+
export class SavedPinsDataSourceModule {}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {TestBed} from '@angular/core/testing';
16+
import {SavedPinsDataSource} from './saved_pins_data_source';
17+
18+
const SAVED_SCALAR_PINS_KEY = 'tb-saved-scalar-pins';
19+
20+
describe('SavedPinsDataSource Test', () => {
21+
let mockStorage: Record<string, string>;
22+
let dataSource: SavedPinsDataSource;
23+
24+
beforeEach(async () => {
25+
await TestBed.configureTestingModule({
26+
providers: [SavedPinsDataSource],
27+
});
28+
29+
dataSource = TestBed.inject(SavedPinsDataSource);
30+
31+
mockStorage = {};
32+
spyOn(window.localStorage, 'setItem').and.callFake(
33+
(key: string, value: string) => {
34+
if (key !== SAVED_SCALAR_PINS_KEY) {
35+
throw new Error('incorrect key used');
36+
}
37+
38+
mockStorage[key] = value;
39+
}
40+
);
41+
42+
spyOn(window.localStorage, 'getItem').and.callFake((key: string) => {
43+
if (key !== SAVED_SCALAR_PINS_KEY) {
44+
throw new Error('incorrect key used');
45+
}
46+
47+
return mockStorage[key];
48+
});
49+
});
50+
51+
describe('getSavedScalarPins', () => {
52+
it('gets the saved scalar pins', () => {
53+
window.localStorage.setItem(
54+
SAVED_SCALAR_PINS_KEY,
55+
JSON.stringify(['new_tag'])
56+
);
57+
58+
const result = dataSource.getSavedScalarPins();
59+
60+
expect(result).toEqual(['new_tag']);
61+
});
62+
63+
it('returns empty list if there is no saved pins', () => {
64+
const result = dataSource.getSavedScalarPins();
65+
66+
expect(result).toEqual([]);
67+
});
68+
});
69+
70+
describe('saveScalarPin', () => {
71+
it('stores the provided tag in the local storage', () => {
72+
dataSource.saveScalarPin('tag1');
73+
74+
expect(dataSource.getSavedScalarPins()).toEqual(['tag1']);
75+
});
76+
77+
it('adds the provided tag to the existing list', () => {
78+
window.localStorage.setItem(
79+
SAVED_SCALAR_PINS_KEY,
80+
JSON.stringify(['tag1'])
81+
);
82+
83+
dataSource.saveScalarPin('tag2');
84+
85+
expect(dataSource.getSavedScalarPins()).toEqual(['tag1', 'tag2']);
86+
});
87+
88+
it('does not addd the provided tag if it already exists', () => {
89+
window.localStorage.setItem(
90+
SAVED_SCALAR_PINS_KEY,
91+
JSON.stringify(['tag1', 'tag2'])
92+
);
93+
94+
dataSource.saveScalarPin('tag2');
95+
96+
expect(dataSource.getSavedScalarPins()).toEqual(['tag1', 'tag2']);
97+
});
98+
});
99+
100+
describe('removeScalarPin', () => {
101+
it('removes the given tag if it exists', () => {
102+
dataSource.saveScalarPin('tag3');
103+
104+
dataSource.removeScalarPin('tag3');
105+
106+
expect(dataSource.getSavedScalarPins().length).toEqual(0);
107+
});
108+
109+
it('does not remove anything if the given tag does not exist', () => {
110+
dataSource.saveScalarPin('tag1');
111+
112+
dataSource.removeScalarPin('tag3');
113+
114+
expect(dataSource.getSavedScalarPins()).toEqual(['tag1']);
115+
});
116+
});
117+
});

tensorboard/webapp/metrics/data_source/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,5 @@ export function isFailedTimeSeriesResponse(
183183
): response is TimeSeriesFailedResponse {
184184
return response.hasOwnProperty('error');
185185
}
186+
187+
export type Tag = string;

0 commit comments

Comments
 (0)