diff --git a/gradle/changelog/local_storage.yaml b/gradle/changelog/local_storage.yaml new file mode 100644 index 0000000000..4c4fa9c199 --- /dev/null +++ b/gradle/changelog/local_storage.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: First access to local storage returning the default value instead of the actual value diff --git a/scm-ui/ui-api/src/localStorage.ts b/scm-ui/ui-api/src/localStorage.ts new file mode 100644 index 0000000000..42c48d7c5d --- /dev/null +++ b/scm-ui/ui-api/src/localStorage.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { useEffect, useMemo, useState } from "react"; + +type LocalStorageSetter = (value: T | ((previousValue: T) => T)) => void; + +const determineInitialValue = (key: string, initialValue: T) => { + try { + const itemFromStorage = localStorage.getItem(key); + return itemFromStorage ? JSON.parse(itemFromStorage) : initialValue; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + return initialValue; + } +}; + +/** + * Provides an api to access the browser's local storage for a given key. + * + * @param key The local storage key + * @param initialValue Value to be used if the local storage does not yet have the given key defined + */ +export function useLocalStorage(key: string, initialValue: T): [value: T, setValue: LocalStorageSetter] { + const initialValueOrValueFromStorage = useMemo(() => determineInitialValue(key, initialValue), [key, initialValue]); + const [item, setItem] = useState(initialValueOrValueFromStorage); + + useEffect(() => { + const listener = (event: StorageEvent) => { + if (event.key === key) { + setItem(determineInitialValue(key, initialValue)); + } + }; + window.addEventListener("storage", listener); + return () => window.removeEventListener("storage", listener); + }, [key, initialValue]); + + const setValue: LocalStorageSetter = (newValue) => { + const computedNewValue = newValue instanceof Function ? newValue(item) : newValue; + setItem(computedNewValue); + const json = JSON.stringify(computedNewValue); + localStorage.setItem(key, json); + // storage event is no triggered in same tab + window.dispatchEvent( + new StorageEvent("storage", { key, oldValue: item, newValue: json, storageArea: localStorage }) + ); + }; + + return [item, setValue]; +} diff --git a/scm-ui/ui-api/src/localStorage.tsx b/scm-ui/ui-api/src/localStorage.tsx deleted file mode 100644 index fd171ab312..0000000000 --- a/scm-ui/ui-api/src/localStorage.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (c) 2020 - present Cloudogu GmbH - * - * This program is free software: you can redistribute it and/or modify it under - * the terms of the GNU Affero General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more - * details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from "react"; - -type LocalStorage = { - getItem: (key: string, fallback: T) => T; - setItem: (key: string, value: T) => void; - preload: (key: string, initialValue: T) => void; -}; - -const LocalStorageContext = createContext(null as unknown as LocalStorage); - -/** - * Cache provider for local storage which enables listening to changes and triggering re-renders when writing. - * - * Only required once as a wrapper for the whole application. - * - * @see useLocalStorage - */ -export const LocalStorageProvider: FC = ({ children }) => { - const [localStorageCache, setLocalStorageCache] = useState>({}); - - const setItem = useCallback((key: string, value: T) => { - localStorage.setItem(key, JSON.stringify(value)); - setLocalStorageCache((prevState) => ({ - ...prevState, - [key]: value, - })); - }, []); - - const getItem = useCallback( - (key: string, fallback: T): T => (key in localStorageCache ? (localStorageCache[key] as T) : fallback), - [localStorageCache] - ); - - const preload = useCallback( - (key: string, initialValue: T) => { - if (!(key in localStorageCache)) { - let initialLoadResult: T | undefined; - try { - const item = localStorage.getItem(key); - initialLoadResult = item ? JSON.parse(item) : initialValue; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - initialLoadResult = initialValue; - } - setItem(key, initialLoadResult); - } - }, - [localStorageCache, setItem] - ); - - return ( - ({ getItem, setItem, preload }), [getItem, preload, setItem])}> - {children} - - ); -}; - -/** - * Provides an api to access the browser's local storage for a given key. - * - * @param key The local storage key - * @param initialValue Value to be used if the local storage does not yet have the given key defined - */ -export function useLocalStorage( - key: string, - initialValue: T -): [value: T, setValue: (value: T | ((previousConfig: T) => T)) => void] { - const { getItem, setItem, preload } = useContext(LocalStorageContext); - const value = useMemo(() => getItem(key, initialValue), [getItem, initialValue, key]); - const setValue = useCallback( - (newValue: T | ((previousConfig: T) => T)) => - // @ts-ignore T could be a function type, although this does not make sense because function types are not serializable to json - setItem(key, typeof newValue === "function" ? newValue(value) : newValue), - // eslint does not understand generics in certain circumstances - // eslint-disable-next-line react-hooks/exhaustive-deps - [key, setItem, value] - ); - useEffect(() => preload(key, initialValue), [initialValue, key, preload]); - return useMemo(() => [value, setValue], [setValue, value]); -} diff --git a/scm-ui/ui-components/.storybook/preview.js b/scm-ui/ui-components/.storybook/preview.js index a289c75402..b629e5250d 100644 --- a/scm-ui/ui-components/.storybook/preview.js +++ b/scm-ui/ui-components/.storybook/preview.js @@ -17,9 +17,9 @@ import i18next from "i18next"; import { initReactI18next } from "react-i18next"; import { withI18next } from "storybook-addon-i18next"; -import React, {useEffect} from "react"; +import React, { useEffect } from "react"; import withApiProvider from "./withApiProvider"; -import { withThemes } from 'storybook-addon-themes/react'; +import { withThemes } from "storybook-addon-themes/react"; let i18n = i18next; @@ -27,7 +27,7 @@ let i18n = i18next; // and not for storyshots if (!process.env.JEST_WORKER_ID) { const Backend = require("i18next-fetch-backend"); - i18n = i18n.use(Backend.default); + i18n = i18n.use(Backend); } i18n.use(initReactI18next).init({ @@ -58,10 +58,10 @@ export const decorators = [ }, }), withApiProvider, - withThemes + withThemes, ]; -const Decorator = ({children, themeName}) => { +const Decorator = ({ children, themeName }) => { useEffect(() => { const link = document.querySelector("#ui-theme"); if (link && link["data-theme"] !== themeName) { @@ -69,7 +69,7 @@ const Decorator = ({children, themeName}) => { link["data-theme"] = themeName; } }, [themeName]); - return <>{children} + return <>{children}; }; export const parameters = { diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index aeae1f4c4c..46308f5a47 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -77755,16 +77755,9 @@ exports[`Storyshots Secondary Navigation Active when match 1`] = `

- - - Hitchhiker

    { }; storiesOf("Footer", module) - .addDecorator((story) => {story()}) .addDecorator((story) => {story()}) .add("Default", () => { return