From 94378326169e81e994b36f9b2b47249289c82fa9 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 29 May 2020 00:11:29 +0200 Subject: [PATCH 01/11] feat(core-api/UserSettings): Ported across the UserSettings API from internal and convert to ts --- .../src/apis/definitions/UserSettingsApi.ts | 78 +++++++++++++++ .../core-api/src/apis/definitions/index.ts | 1 + .../implementations/UserSettingsApi/index.ts | 97 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 packages/core-api/src/apis/definitions/UserSettingsApi.ts create mode 100644 packages/core-api/src/apis/implementations/UserSettingsApi/index.ts diff --git a/packages/core-api/src/apis/definitions/UserSettingsApi.ts b/packages/core-api/src/apis/definitions/UserSettingsApi.ts new file mode 100644 index 0000000000..62045e0e51 --- /dev/null +++ b/packages/core-api/src/apis/definitions/UserSettingsApi.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createApiRef } from '../ApiRef'; + +type setValue = (value: T | null) => void; +type removeValue = () => void; +export type UserSettingsApi = { + /** + * Get persistent data. + * + * TODO: Replace with something less volatile than LocalStorage + * + * @param {String} storeName Name of the store. + * @param {String?} key (Optional) Unique key associated with the data. + * If not key is specified,the whole store is returned. + * @param {String} key (Optional) Empty value to return if there is not data. + * @return {Object} data The data that should is stored. + */ + getFromStore( + storeName: string, + key: string, + defaultValue: T | null, + ): T | null; + /** + * Remove persistent data. + * + * TODO: Replace with something less volatile than LocalStorage + * + * @param {String} storeName Name of the store. + * @param {String} key Unique key associated with the data. + */ + removeFromStore(storeName: string, key: string): void; + + /** + * Save persistent data. + * + * TODO: Replace with something less volatile than LocalStorage + * + * @param {String} storeName Name of the store. + * @param {String} key Unique key associated with the data. + * @param {Object} data The data that should be stored. + */ + saveToStore(storeName: string, key: string, data: any): void; + + /** + * React hook that observes a single store value and provides functions for updating the value. + * + * @param {String} storeName Name of the store. + * @param {String} key Unique key associated with the data. + * @return {[value, setValue(value), removeValue()]} An array to be deconstructed, + * The first element is the value, the second is a function to call to update the value, + * and the third is a function to call to clear the value. + */ + useStoreValue( + storeName: string, + key: string, + ): [T | null, setValue, removeValue]; +}; + +export const userSettingsApiRef = createApiRef({ + id: 'core.user.settings', + description: + 'Provides the ability to modify settings that are personalised to the user', +}); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 2e008965cd..0acef2abb4 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -27,3 +27,4 @@ export * from './AppThemeApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; +export * from './UserSettingsApi'; diff --git a/packages/core-api/src/apis/implementations/UserSettingsApi/index.ts b/packages/core-api/src/apis/implementations/UserSettingsApi/index.ts new file mode 100644 index 0000000000..8d73202414 --- /dev/null +++ b/packages/core-api/src/apis/implementations/UserSettingsApi/index.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useState, useEffect } from 'react'; +import { UserSettingsApi } from '../../definitions'; + +export class UserSettings implements UserSettingsApi { + getFromStore( + storeName: string, + key: string = '', + defaultValue: T | null, + ): T | null { + let store; + try { + store = JSON.parse(localStorage.getItem(storeName)!) || {}; + } catch (e) { + window.console.error( + `Error when parsing JSON config from storage for: ${storeName}`, + ); + return defaultValue as T; + } + + if (key) { + return (store[key] ?? defaultValue) as T; + } + return store as T; + } + + saveToStore(storeName: string, key: string, data: any): void { + const store = JSON.parse(localStorage.getItem(storeName)!) || {}; + store[key] = data; + localStorage.setItem(storeName, JSON.stringify(store)); + } + + removeFromStore(storeName: string, key: string): void { + const store = JSON.parse(localStorage.getItem(storeName)!) || {}; + delete store[key]; + localStorage.setItem(storeName, JSON.stringify(store)); + } + + useStoreValue( + storeName: string, + key: string, + ): [T | null, (value: T | null) => void, () => void] { + // We hardcode the emptyValue to null, since allowing objects or arrays would make + // the useEffect params a lot more complex and it would easy to get stuck in a loop. + const [value, setValue] = useState(() => + this.getFromStore(storeName, key, null), + ); + + // Listen to storage change events from other tabs + useEffect(() => { + const onChange = (event: StorageEvent) => { + if (event.key === storeName) { + // Avoid sending unnecessary updates when only the reference changes because of JSON.parse + setValue((currentValue: T | null) => { + const newValue = this.getFromStore(storeName, key, null); + + // Fastest way to do a deep equals, since we know references will differ, + // the values are serializable, and it's ok to signal a change if key order changes. + if (JSON.stringify(currentValue) === JSON.stringify(newValue)) { + return currentValue; + } + return newValue; + }); + } + }; + + window.addEventListener('storage', onChange); + return () => window.removeEventListener('storage', onChange); + }, [storeName, key]); + + const saveValue = (newValue: T | null) => { + this.saveToStore(storeName, key, newValue); + setValue(this.getFromStore(storeName, key, null)); + }; + + const removeValue = () => { + this.removeFromStore(storeName, key); + setValue(null); + }; + + return [value, saveValue, removeValue]; + } +} From 335d6f36b76175bea50d79ac5cd87f1af9a732cf Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 29 May 2020 13:11:06 +0200 Subject: [PATCH 02/11] feat(core-api/Storage): Reworking the StorageApi to have a better interface --- .../{UserSettingsApi.ts => StorageApi.ts} | 57 ++++++----- .../core-api/src/apis/definitions/index.ts | 2 +- .../implementations/StorageApi/WebStorage.ts | 51 ++++++++++ .../apis/implementations/StorageApi/index.ts | 17 ++++ .../implementations/UserSettingsApi/index.ts | 97 ------------------- 5 files changed, 104 insertions(+), 120 deletions(-) rename packages/core-api/src/apis/definitions/{UserSettingsApi.ts => StorageApi.ts} (59%) create mode 100644 packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts create mode 100644 packages/core-api/src/apis/implementations/StorageApi/index.ts delete mode 100644 packages/core-api/src/apis/implementations/UserSettingsApi/index.ts diff --git a/packages/core-api/src/apis/definitions/UserSettingsApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts similarity index 59% rename from packages/core-api/src/apis/definitions/UserSettingsApi.ts rename to packages/core-api/src/apis/definitions/StorageApi.ts index 62045e0e51..0cb5ccc14d 100644 --- a/packages/core-api/src/apis/definitions/UserSettingsApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -16,14 +16,22 @@ import { createApiRef } from '../ApiRef'; -type setValue = (value: T | null) => void; -type removeValue = () => void; -export type UserSettingsApi = { +type UnsubscribeFromStore = () => void; +type SubscribeToStoreHandler = ({ + storeName, + key, + oldValue, + newValue, +}: { + storeName: string; + key: string; + oldValue?: T; + newValue?: T; +}) => void; + +export type StorageApi = { /** * Get persistent data. - * - * TODO: Replace with something less volatile than LocalStorage - * * @param {String} storeName Name of the store. * @param {String?} key (Optional) Unique key associated with the data. * If not key is specified,the whole store is returned. @@ -33,45 +41,50 @@ export type UserSettingsApi = { getFromStore( storeName: string, key: string, - defaultValue: T | null, - ): T | null; + defaultValue?: T, + ): Promise; + /** * Remove persistent data. * - * TODO: Replace with something less volatile than LocalStorage - * * @param {String} storeName Name of the store. * @param {String} key Unique key associated with the data. */ - removeFromStore(storeName: string, key: string): void; + removeFromStore(storeName: string, key: string): Promise; /** - * Save persistent data. - * - * TODO: Replace with something less volatile than LocalStorage + * Save persiswtent data. * * @param {String} storeName Name of the store. * @param {String} key Unique key associated with the data. * @param {Object} data The data that should be stored. */ - saveToStore(storeName: string, key: string, data: any): void; + saveToStore(storeName: string, key: string, data: any): Promise; /** - * React hook that observes a single store value and provides functions for updating the value. + * Callback for Changes in the store + * @callback subscribeHandler + * @param {String} storeName Name of the store. + * @param {String} key Unique key associated with the data. + * @param {Object} oldValue The old value that was in the store. + * @param {Object} newValue The new value that has been set in the store. + */ + /** + * Subscribe to Key changes in a store and get the new and old value * * @param {String} storeName Name of the store. * @param {String} key Unique key associated with the data. - * @return {[value, setValue(value), removeValue()]} An array to be deconstructed, - * The first element is the value, the second is a function to call to update the value, - * and the third is a function to call to clear the value. + * @param {subscribeHandler} handler Handler which is called with the old value and new value in the store. + * @returns {Function} Unsubscribe to changes in the store. */ - useStoreValue( + subscribeToChange( storeName: string, key: string, - ): [T | null, setValue, removeValue]; + handler: SubscribeToStoreHandler, + ): UnsubscribeFromStore; }; -export const userSettingsApiRef = createApiRef({ +export const storageApiRef = createApiRef({ id: 'core.user.settings', description: 'Provides the ability to modify settings that are personalised to the user', diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 0acef2abb4..475dba9189 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -27,4 +27,4 @@ export * from './AppThemeApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; -export * from './UserSettingsApi'; +export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts new file mode 100644 index 0000000000..c48a1ee931 --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { StorageApi } from '../../definitions'; + +export class WebStorage implements StorageApi { + async getFromStore( + storeName: string, + key: string = '', + defaultValue?: T, + ): Promise { + let store; + try { + store = JSON.parse(localStorage.getItem(storeName)!) || {}; + } catch (e) { + window.console.error( + `Error when parsing JSON config from storage for: ${storeName}`, + ); + return defaultValue; + } + + if (key) { + return store[key] ?? defaultValue; + } + return store; + } + + async saveToStore(storeName: string, key: string, data: any): Promise { + const store = JSON.parse(localStorage.getItem(storeName)!) || {}; + store[key] = data; + localStorage.setItem(storeName, JSON.stringify(store)); + } + + async removeFromStore(storeName: string, key: string): Promise { + const store = JSON.parse(localStorage.getItem(storeName)!) || {}; + delete store[key]; + localStorage.setItem(storeName, JSON.stringify(store)); + } +} diff --git a/packages/core-api/src/apis/implementations/StorageApi/index.ts b/packages/core-api/src/apis/implementations/StorageApi/index.ts new file mode 100644 index 0000000000..33b0094551 --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { WebStorage } from './WebStorage'; diff --git a/packages/core-api/src/apis/implementations/UserSettingsApi/index.ts b/packages/core-api/src/apis/implementations/UserSettingsApi/index.ts deleted file mode 100644 index 8d73202414..0000000000 --- a/packages/core-api/src/apis/implementations/UserSettingsApi/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { useState, useEffect } from 'react'; -import { UserSettingsApi } from '../../definitions'; - -export class UserSettings implements UserSettingsApi { - getFromStore( - storeName: string, - key: string = '', - defaultValue: T | null, - ): T | null { - let store; - try { - store = JSON.parse(localStorage.getItem(storeName)!) || {}; - } catch (e) { - window.console.error( - `Error when parsing JSON config from storage for: ${storeName}`, - ); - return defaultValue as T; - } - - if (key) { - return (store[key] ?? defaultValue) as T; - } - return store as T; - } - - saveToStore(storeName: string, key: string, data: any): void { - const store = JSON.parse(localStorage.getItem(storeName)!) || {}; - store[key] = data; - localStorage.setItem(storeName, JSON.stringify(store)); - } - - removeFromStore(storeName: string, key: string): void { - const store = JSON.parse(localStorage.getItem(storeName)!) || {}; - delete store[key]; - localStorage.setItem(storeName, JSON.stringify(store)); - } - - useStoreValue( - storeName: string, - key: string, - ): [T | null, (value: T | null) => void, () => void] { - // We hardcode the emptyValue to null, since allowing objects or arrays would make - // the useEffect params a lot more complex and it would easy to get stuck in a loop. - const [value, setValue] = useState(() => - this.getFromStore(storeName, key, null), - ); - - // Listen to storage change events from other tabs - useEffect(() => { - const onChange = (event: StorageEvent) => { - if (event.key === storeName) { - // Avoid sending unnecessary updates when only the reference changes because of JSON.parse - setValue((currentValue: T | null) => { - const newValue = this.getFromStore(storeName, key, null); - - // Fastest way to do a deep equals, since we know references will differ, - // the values are serializable, and it's ok to signal a change if key order changes. - if (JSON.stringify(currentValue) === JSON.stringify(newValue)) { - return currentValue; - } - return newValue; - }); - } - }; - - window.addEventListener('storage', onChange); - return () => window.removeEventListener('storage', onChange); - }, [storeName, key]); - - const saveValue = (newValue: T | null) => { - this.saveToStore(storeName, key, newValue); - setValue(this.getFromStore(storeName, key, null)); - }; - - const removeValue = () => { - this.removeFromStore(storeName, key); - setValue(null); - }; - - return [value, saveValue, removeValue]; - } -} From 4fd99fec251c86bd826744fc39bb7d1bc2583072 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 2 Jun 2020 17:05:49 +0200 Subject: [PATCH 03/11] feat(core-api/Storage): reworking the api for storage. get's should be sync --- .../src/apis/definitions/StorageApi.ts | 61 ++++--------------- 1 file changed, 12 insertions(+), 49 deletions(-) diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index 0cb5ccc14d..62a8fd6895 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -15,77 +15,40 @@ */ import { createApiRef } from '../ApiRef'; - -type UnsubscribeFromStore = () => void; -type SubscribeToStoreHandler = ({ - storeName, - key, - oldValue, - newValue, -}: { - storeName: string; - key: string; - oldValue?: T; - newValue?: T; -}) => void; +import { Observable } from '@backstage/core-api'; export type StorageApi = { /** * Get persistent data. - * @param {String} storeName Name of the store. - * @param {String?} key (Optional) Unique key associated with the data. - * If not key is specified,the whole store is returned. - * @param {String} key (Optional) Empty value to return if there is not data. + * + * @param {String} key Unique key associated with the data. * @return {Object} data The data that should is stored. */ - getFromStore( - storeName: string, - key: string, - defaultValue?: T, - ): Promise; + get(key: string): T | undefined; /** * Remove persistent data. * - * @param {String} storeName Name of the store. * @param {String} key Unique key associated with the data. */ - removeFromStore(storeName: string, key: string): Promise; + remove(key: string): Promise; /** - * Save persiswtent data. + * Save persistant data. * - * @param {String} storeName Name of the store. * @param {String} key Unique key associated with the data. - * @param {Object} data The data that should be stored. + * @return */ - saveToStore(storeName: string, key: string, data: any): Promise; + save(key: string, data: any): Promise; /** - * Callback for Changes in the store - * @callback subscribeHandler - * @param {String} storeName Name of the store. - * @param {String} key Unique key associated with the data. - * @param {Object} oldValue The old value that was in the store. - * @param {Object} newValue The new value that has been set in the store. - */ - /** - * Subscribe to Key changes in a store and get the new and old value * - * @param {String} storeName Name of the store. - * @param {String} key Unique key associated with the data. - * @param {subscribeHandler} handler Handler which is called with the old value and new value in the store. - * @returns {Function} Unsubscribe to changes in the store. + * @param {String} key Unique key associated with the data */ - subscribeToChange( - storeName: string, - key: string, - handler: SubscribeToStoreHandler, - ): UnsubscribeFromStore; + observe$(key: string): Observable; }; export const storageApiRef = createApiRef({ - id: 'core.user.settings', - description: - 'Provides the ability to modify settings that are personalised to the user', + id: 'core.storage', + description: 'Provides the ability to store data which is unique to the user', }); From 99a2387076fa845afd5a1201c78e0abe6d610d9f Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 2 Jun 2020 23:12:32 +0200 Subject: [PATCH 04/11] feat(core-api/Storage): Add the simple storage API for now with some simpler tests --- .../src/apis/definitions/StorageApi.ts | 8 +++- .../StorageApi/WebStorage.test.ts | 48 +++++++++++++++++++ .../implementations/StorageApi/WebStorage.ts | 42 ++++++++-------- 3 files changed, 74 insertions(+), 24 deletions(-) create mode 100644 packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index 62a8fd6895..c8e72a518f 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -17,6 +17,11 @@ import { createApiRef } from '../ApiRef'; import { Observable } from '@backstage/core-api'; +export type ObservableMessage = { + key: string; + newValue?: T; +}; + export type StorageApi = { /** * Get persistent data. @@ -37,9 +42,8 @@ export type StorageApi = { * Save persistant data. * * @param {String} key Unique key associated with the data. - * @return */ - save(key: string, data: any): Promise; + set(key: string, data: any): Promise; /** * diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts new file mode 100644 index 0000000000..6509ba6edb --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { WebStorage } from './WebStorage'; +describe('WebStorage Storage API', () => { + it('should return undefined for values which are unset', async () => { + const storage = new WebStorage(); + + expect(storage.get('myfakekey')).toBeUndefined(); + }); + + it('should allow the setting and getting of the simple data structures', async () => { + const storage = new WebStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + + expect(storage.get('myfakekey')).toBe('helloimastring'); + expect(storage.get('mysecondfakekey')).toBe(1234); + expect(storage.get('mythirdfakekey')).toBe(true); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = new WebStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.get('myfakekey')).toEqual(mockData); + }); +}); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index c48a1ee931..cbcab6349e 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -13,39 +13,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { StorageApi } from '../../definitions'; +import { StorageApi, ObservableMessage } from '../../definitions'; +import { Observable } from '../../../types'; +import ObservableImpl from 'zen-observable'; export class WebStorage implements StorageApi { - async getFromStore( - storeName: string, - key: string = '', - defaultValue?: T, - ): Promise { - let store; + private readonly observable = new ObservableImpl(() => {}); + get(key: string): T | undefined { try { - store = JSON.parse(localStorage.getItem(storeName)!) || {}; + const storage = JSON.parse(localStorage.getItem(key)!); + return storage ?? undefined; } catch (e) { window.console.error( - `Error when parsing JSON config from storage for: ${storeName}`, + `Error when parsing JSON config from storage for: ${key}`, + e, ); - return defaultValue; } - if (key) { - return store[key] ?? defaultValue; - } - return store; + return undefined; } - async saveToStore(storeName: string, key: string, data: any): Promise { - const store = JSON.parse(localStorage.getItem(storeName)!) || {}; - store[key] = data; - localStorage.setItem(storeName, JSON.stringify(store)); + async set(key: string, data: T): Promise { + localStorage.setItem(key, JSON.stringify(data, null, 2)); } - async removeFromStore(storeName: string, key: string): Promise { - const store = JSON.parse(localStorage.getItem(storeName)!) || {}; - delete store[key]; - localStorage.setItem(storeName, JSON.stringify(store)); + async remove(key: string): Promise { + localStorage.removeItem(key); + } + + observe$(key: string): Observable { + return this.observable.filter( + ({ key: messageKey }) => messageKey === key, + ) as Observable; } } From 38256679239b875ff88ce824fc70291730bd17d5 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 14:41:47 +0200 Subject: [PATCH 05/11] feat(core-api/Storage): Added a simple implementatation of subscriptions --- .../StorageApi/WebStorage.test.ts | 59 ++++++++++++++++++- .../implementations/StorageApi/WebStorage.ts | 22 ++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index 6509ba6edb..85343d58ff 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -27,7 +27,6 @@ describe('WebStorage Storage API', () => { await storage.set('myfakekey', 'helloimastring'); await storage.set('mysecondfakekey', 1234); await storage.set('mythirdfakekey', true); - expect(storage.get('myfakekey')).toBe('helloimastring'); expect(storage.get('mysecondfakekey')).toBe(1234); expect(storage.get('mythirdfakekey')).toBe(true); @@ -45,4 +44,62 @@ describe('WebStorage Storage API', () => { expect(storage.get('myfakekey')).toEqual(mockData); }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = new WebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = new WebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: undefined, + }); + }); }); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index cbcab6349e..432c08e3d1 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -18,7 +18,19 @@ import { Observable } from '../../../types'; import ObservableImpl from 'zen-observable'; export class WebStorage implements StorageApi { - private readonly observable = new ObservableImpl(() => {}); + subscribers: Set< + ZenObservable.SubscriptionObserver + > = new Set(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); + get(key: string): T | undefined { try { const storage = JSON.parse(localStorage.getItem(key)!); @@ -35,10 +47,18 @@ export class WebStorage implements StorageApi { async set(key: string, data: T): Promise { localStorage.setItem(key, JSON.stringify(data, null, 2)); + this.notifyChanges({ key, newValue: data }); } async remove(key: string): Promise { localStorage.removeItem(key); + this.notifyChanges({ key, newValue: undefined }); + } + + notifyChanges(message: ObservableMessage) { + for (const subscription of this.subscribers) { + subscription.next(message); + } } observe$(key: string): Observable { From f1ba79fec03fd209d1734e49cfbacac97537c8f7 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 14:51:53 +0200 Subject: [PATCH 06/11] chore(core-api/Storage): Fixing order of the functions --- .../src/apis/definitions/StorageApi.ts | 2 +- .../implementations/StorageApi/WebStorage.ts | 30 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index c8e72a518f..e0bd2c7ed3 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -15,7 +15,7 @@ */ import { createApiRef } from '../ApiRef'; -import { Observable } from '@backstage/core-api'; +import { Observable } from '../../types'; export type ObservableMessage = { key: string; diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index 432c08e3d1..a6a646d3dc 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -22,15 +22,6 @@ export class WebStorage implements StorageApi { ZenObservable.SubscriptionObserver > = new Set(); - private readonly observable = new ObservableImpl( - subscriber => { - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }, - ); - get(key: string): T | undefined { try { const storage = JSON.parse(localStorage.getItem(key)!); @@ -55,15 +46,24 @@ export class WebStorage implements StorageApi { this.notifyChanges({ key, newValue: undefined }); } - notifyChanges(message: ObservableMessage) { - for (const subscription of this.subscribers) { - subscription.next(message); - } - } - observe$(key: string): Observable { return this.observable.filter( ({ key: messageKey }) => messageKey === key, ) as Observable; } + + private notifyChanges(message: ObservableMessage) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); } From fb32b09226bc9bbfb0bc40f42be703b6c6126851 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 17:21:15 +0200 Subject: [PATCH 07/11] feat(core-api/Storage): Be able to namespace with forBucket function --- .../src/apis/definitions/StorageApi.ts | 11 ++++++++-- .../StorageApi/WebStorage.test.ts | 15 +++++++++++++ .../implementations/StorageApi/WebStorage.ts | 22 ++++++++++++++----- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index e0bd2c7ed3..90c1961588 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -22,7 +22,14 @@ export type ObservableMessage = { newValue?: T; }; -export type StorageApi = { +export interface StorageApi { + /** + * The names + * @param {String} name Namespace for the storage to be stored under, + * will inherit previous namespaces too + */ + forBucket(name: string): StorageApi; + /** * Get persistent data. * @@ -50,7 +57,7 @@ export type StorageApi = { * @param {String} key Unique key associated with the data */ observe$(key: string): Observable; -}; +} export const storageApiRef = createApiRef({ id: 'core.storage', diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index 85343d58ff..f28cb9e41b 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -102,4 +102,19 @@ describe('WebStorage Storage API', () => { newValue: undefined, }); }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = new WebStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); + expect(firstStorage.get(keyName)).toBe('boop'); + expect(secondStorage.get(keyName)).toBe('deerp'); + }); }); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index a6a646d3dc..a7150b3647 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -18,13 +18,11 @@ import { Observable } from '../../../types'; import ObservableImpl from 'zen-observable'; export class WebStorage implements StorageApi { - subscribers: Set< - ZenObservable.SubscriptionObserver - > = new Set(); + constructor(private readonly namespace: string = '') {} get(key: string): T | undefined { try { - const storage = JSON.parse(localStorage.getItem(key)!); + const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!); return storage ?? undefined; } catch (e) { window.console.error( @@ -36,13 +34,17 @@ export class WebStorage implements StorageApi { return undefined; } + forBucket(name: string): WebStorage { + return new WebStorage(`${this.namespace}/${name}`); + } + async set(key: string, data: T): Promise { - localStorage.setItem(key, JSON.stringify(data, null, 2)); + localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); this.notifyChanges({ key, newValue: data }); } async remove(key: string): Promise { - localStorage.removeItem(key); + localStorage.removeItem(this.getKeyName(key)); this.notifyChanges({ key, newValue: undefined }); } @@ -52,12 +54,20 @@ export class WebStorage implements StorageApi { ) as Observable; } + private getKeyName(key: string) { + return `${this.namespace}/${key}`; + } + private notifyChanges(message: ObservableMessage) { for (const subscription of this.subscribers) { subscription.next(message); } } + private subscribers: Set< + ZenObservable.SubscriptionObserver + > = new Set(); + private readonly observable = new ObservableImpl( subscriber => { this.subscribers.add(subscriber); From f32e987d1546b1409dc28bd09f8ed0e2f3327253 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 17:59:26 +0200 Subject: [PATCH 08/11] chore(core-api/Storage): Fixing some code review comments, so we don't have to cast some stuff in Typescript --- packages/core-api/src/apis/definitions/StorageApi.ts | 12 ++++++------ .../apis/implementations/StorageApi/WebStorage.ts | 8 +++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index 90c1961588..7d7533f5b4 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -17,21 +17,21 @@ import { createApiRef } from '../ApiRef'; import { Observable } from '../../types'; -export type ObservableMessage = { +export type ObservableMessage = { key: string; newValue?: T; }; export interface StorageApi { /** - * The names + * Create a bucket to store data in. * @param {String} name Namespace for the storage to be stored under, * will inherit previous namespaces too */ forBucket(name: string): StorageApi; /** - * Get persistent data. + * Get the current value for persistent data, use observe$ to be notified of updates. * * @param {String} key Unique key associated with the data. * @return {Object} data The data that should is stored. @@ -46,17 +46,17 @@ export interface StorageApi { remove(key: string): Promise; /** - * Save persistant data. + * Save persistant data, and emit messages to anyone that is using observe$ for this key * * @param {String} key Unique key associated with the data. */ set(key: string, data: any): Promise; /** - * + * Observe changes on a particular key in the bucket * @param {String} key Unique key associated with the data */ - observe$(key: string): Observable; + observe$(key: string): Observable>; } export const storageApiRef = createApiRef({ diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index a7150b3647..c9665253de 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -48,17 +48,15 @@ export class WebStorage implements StorageApi { this.notifyChanges({ key, newValue: undefined }); } - observe$(key: string): Observable { - return this.observable.filter( - ({ key: messageKey }) => messageKey === key, - ) as Observable; + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); } private getKeyName(key: string) { return `${this.namespace}/${key}`; } - private notifyChanges(message: ObservableMessage) { + private notifyChanges(message: ObservableMessage) { for (const subscription of this.subscribers) { subscription.next(message); } From 42d58955c25d88d34ba72951b100a73dabba31d8 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 19:49:59 +0200 Subject: [PATCH 09/11] feat(core-api/Storage): Encode the key names to avoid clashes --- .../implementations/StorageApi/WebStorage.test.ts | 13 +++++++++++++ .../apis/implementations/StorageApi/WebStorage.ts | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index f28cb9e41b..de6d55c7a4 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -117,4 +117,17 @@ describe('WebStorage Storage API', () => { expect(firstStorage.get(keyName)).toBe('boop'); expect(secondStorage.get(keyName)).toBe('deerp'); }); + + it('should not clash with other namesapces when creating buckets', async () => { + const rootStorage = new WebStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage.forBucket('profile/something/deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.get('deep/test2')).toBe(undefined); + }); }); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index c9665253de..bd7c144421 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -53,7 +53,7 @@ export class WebStorage implements StorageApi { } private getKeyName(key: string) { - return `${this.namespace}/${key}`; + return `${this.namespace}/${encodeURIComponent(key)}`; } private notifyChanges(message: ObservableMessage) { @@ -62,9 +62,9 @@ export class WebStorage implements StorageApi { } } - private subscribers: Set< + private subscribers = new Set< ZenObservable.SubscriptionObserver - > = new Set(); + >(); private readonly observable = new ObservableImpl( subscriber => { From e37ad442e2e4301b15be6330a4b2936f72716143 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 20:23:41 +0200 Subject: [PATCH 10/11] chore(core-api/Storage): fixing some more code review comments. and encoding the namespace better --- .../core-api/src/apis/definitions/StorageApi.ts | 4 ++-- .../StorageApi/WebStorage.test.ts | 5 ++++- .../implementations/StorageApi/WebStorage.ts | 16 ++++++++++------ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index 7d7533f5b4..a84807d314 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -17,7 +17,7 @@ import { createApiRef } from '../ApiRef'; import { Observable } from '../../types'; -export type ObservableMessage = { +export type StorageValueChange = { key: string; newValue?: T; }; @@ -56,7 +56,7 @@ export interface StorageApi { * Observe changes on a particular key in the bucket * @param {String} key Unique key associated with the data */ - observe$(key: string): Observable>; + observe$(key: string): Observable>; } export const storageApiRef = createApiRef({ diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index de6d55c7a4..e68981d157 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -122,7 +122,10 @@ describe('WebStorage Storage API', () => { const rootStorage = new WebStorage(); // when getting key test2 it will translate to /profile/something/deep/test2 - const firstStorage = rootStorage.forBucket('profile/something/deep'); + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); // when getting key deep/test2 it will translate to /profile/something/deep/test2 const secondStorage = rootStorage.forBucket('profile/something'); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index bd7c144421..bb9eb9d7ce 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -13,12 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { StorageApi, ObservableMessage } from '../../definitions'; +import { StorageApi, StorageValueChange } from '../../definitions'; import { Observable } from '../../../types'; import ObservableImpl from 'zen-observable'; export class WebStorage implements StorageApi { - constructor(private readonly namespace: string = '') {} + private readonly namespace: string; + + constructor(namespace: string = '') { + this.namespace = namespace ? encodeURIComponent(namespace) : namespace; + } get(key: string): T | undefined { try { @@ -48,7 +52,7 @@ export class WebStorage implements StorageApi { this.notifyChanges({ key, newValue: undefined }); } - observe$(key: string): Observable> { + observe$(key: string): Observable> { return this.observable.filter(({ key: messageKey }) => messageKey === key); } @@ -56,17 +60,17 @@ export class WebStorage implements StorageApi { return `${this.namespace}/${encodeURIComponent(key)}`; } - private notifyChanges(message: ObservableMessage) { + private notifyChanges(message: StorageValueChange) { for (const subscription of this.subscribers) { subscription.next(message); } } private subscribers = new Set< - ZenObservable.SubscriptionObserver + ZenObservable.SubscriptionObserver >(); - private readonly observable = new ObservableImpl( + private readonly observable = new ObservableImpl( subscriber => { this.subscribers.add(subscriber); return () => { From 0e5d0ea7e16c00c15a147664ca8e6be22dec02c0 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 3 Jun 2020 21:25:51 +0200 Subject: [PATCH 11/11] feat(core-api/Storage): Made the implementation a litle nicer now so that the error API is passed through to the storage API --- packages/app/src/apis.ts | 8 +++- .../src/apis/definitions/StorageApi.ts | 6 +++ .../StorageApi/WebStorage.test.ts | 44 +++++++++++++++---- .../implementations/StorageApi/WebStorage.ts | 23 ++++++---- .../src/apis/implementations/index.ts | 1 + 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 6a23de1edd..ec0088e219 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -30,6 +30,8 @@ import { OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + storageApiRef, + WebStorage, } from '@backstage/core'; import { @@ -45,8 +47,12 @@ import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; const builder = ApiRegistry.builder(); const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); +const errorApi = builder.add( + errorApiRef, + new ErrorAlerter(alertApi, new ErrorApiForwarder()), +); -builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); +builder.add(storageApiRef, WebStorage.create({ errorApi })); builder.add(circleCIApiRef, new CircleCIApi()); builder.add(featureFlagsApiRef, new FeatureFlags()); diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index a84807d314..920ee56811 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -16,12 +16,18 @@ import { createApiRef } from '../ApiRef'; import { Observable } from '../../types'; +import { ErrorApi } from './ErrorApi'; export type StorageValueChange = { key: string; newValue?: T; }; +export type CreateStorageApiOptions = { + errorApi: ErrorApi; + namespace?: string; +}; + export interface StorageApi { /** * Create a bucket to store data in. diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index e68981d157..a96813347e 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -14,15 +14,25 @@ * limitations under the License. */ import { WebStorage } from './WebStorage'; +import { CreateStorageApiOptions, StorageApi } from '../../definitions'; describe('WebStorage Storage API', () => { + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + const createWebStorage = ( + args?: Partial, + ): StorageApi => { + return WebStorage.create({ + errorApi: mockErrorApi, + ...args, + }); + }; it('should return undefined for values which are unset', async () => { - const storage = new WebStorage(); + const storage = createWebStorage(); expect(storage.get('myfakekey')).toBeUndefined(); }); it('should allow the setting and getting of the simple data structures', async () => { - const storage = new WebStorage(); + const storage = createWebStorage(); await storage.set('myfakekey', 'helloimastring'); await storage.set('mysecondfakekey', 1234); @@ -33,7 +43,7 @@ describe('WebStorage Storage API', () => { }); it('should allow setting of complex datastructures', async () => { - const storage = new WebStorage(); + const storage = createWebStorage(); const mockData = { something: 'here', @@ -46,14 +56,14 @@ describe('WebStorage Storage API', () => { }); it('should subscribe to key changes when setting a new value', async () => { - const storage = new WebStorage(); + const storage = createWebStorage(); const wrongKeyNextHandler = jest.fn(); const selectedKeyNextHandler = jest.fn(); const mockData = { hello: 'im a great new value' }; await new Promise(resolve => { - storage.observe$('correctKey').subscribe({ + storage.observe$('correctKey').subscribe({ next: (...args) => { selectedKeyNextHandler(...args); resolve(); @@ -74,7 +84,7 @@ describe('WebStorage Storage API', () => { }); it('should subscribe to key changes when deleting a value', async () => { - const storage = new WebStorage(); + const storage = createWebStorage(); const wrongKeyNextHandler = jest.fn(); const selectedKeyNextHandler = jest.fn(); @@ -104,7 +114,7 @@ describe('WebStorage Storage API', () => { }); it('should be able to create different buckets for different uses', async () => { - const rootStorage = new WebStorage(); + const rootStorage = createWebStorage(); const firstStorage = rootStorage.forBucket('userSettings'); const secondStorage = rootStorage.forBucket('profileSettings'); @@ -119,7 +129,7 @@ describe('WebStorage Storage API', () => { }); it('should not clash with other namesapces when creating buckets', async () => { - const rootStorage = new WebStorage(); + const rootStorage = createWebStorage(); // when getting key test2 it will translate to /profile/something/deep/test2 const firstStorage = rootStorage @@ -133,4 +143,22 @@ describe('WebStorage Storage API', () => { expect(secondStorage.get('deep/test2')).toBe(undefined); }); + + it('should call the error api when the json can not be parsed in local storage', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing', + }); + + localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}'); + + const value = rootStorage.get('key'); + + expect(value).toBe(undefined); + expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); + expect(mockErrorApi.post).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error when parsing JSON config from storage for: key', + }), + ); + }); }); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts index bb9eb9d7ce..8af8f760cf 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -13,15 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { StorageApi, StorageValueChange } from '../../definitions'; +import { + StorageApi, + StorageValueChange, + ErrorApi, + CreateStorageApiOptions, +} from '../../definitions'; import { Observable } from '../../../types'; import ObservableImpl from 'zen-observable'; export class WebStorage implements StorageApi { - private readonly namespace: string; + constructor( + private readonly namespace: string, + private readonly errorApi: ErrorApi, + ) {} - constructor(namespace: string = '') { - this.namespace = namespace ? encodeURIComponent(namespace) : namespace; + static create(options: CreateStorageApiOptions): WebStorage { + return new WebStorage(options.namespace ?? '', options.errorApi); } get(key: string): T | undefined { @@ -29,9 +37,8 @@ export class WebStorage implements StorageApi { const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!); return storage ?? undefined; } catch (e) { - window.console.error( - `Error when parsing JSON config from storage for: ${key}`, - e, + this.errorApi.post( + new Error(`Error when parsing JSON config from storage for: ${key}`), ); } @@ -39,7 +46,7 @@ export class WebStorage implements StorageApi { } forBucket(name: string): WebStorage { - return new WebStorage(`${this.namespace}/${name}`); + return new WebStorage(`${this.namespace}/${name}`, this.errorApi); } async set(key: string, data: T): Promise { diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index b5cc250ae4..e6d23fee21 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -25,3 +25,4 @@ export * from './AppThemeApi'; export * from './ConfigApi'; export * from './ErrorApi'; export * from './OAuthRequestApi'; +export * from './StorageApi';