diff --git a/gradle/changelog/local_storage_context.yaml b/gradle/changelog/local_storage_context.yaml new file mode 100644 index 0000000000..74b22aac42 --- /dev/null +++ b/gradle/changelog/local_storage_context.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: The useLocalStorage hook in @scm-manager/ui-api now correctly causes a re-render on write diff --git a/scm-ui/ui-api/src/localStorage.ts b/scm-ui/ui-api/src/localStorage.ts deleted file mode 100644 index 7126cacb1a..0000000000 --- a/scm-ui/ui-api/src/localStorage.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2020-present Cloudogu GmbH and Contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import { useEffect, useState } from "react"; - -export function useLocalStorage( - key: string, - initialValue: T -): [value: T, setValue: (value: T | ((previousConfig: T) => T)) => void] { - const [value, setValue] = useState(() => { - try { - const item = localStorage.getItem(key); - return item ? JSON.parse(item) : initialValue; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return initialValue; - } - }); - - useEffect(() => localStorage.setItem(key, JSON.stringify(value)), [key, value]); - - return [value, setValue]; -} diff --git a/scm-ui/ui-api/src/localStorage.tsx b/scm-ui/ui-api/src/localStorage.tsx new file mode 100644 index 0000000000..d4dc8d1361 --- /dev/null +++ b/scm-ui/ui-api/src/localStorage.tsx @@ -0,0 +1,99 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { createContext, FC, useCallback, useContext, useMemo, useState } from "react"; + +type LocalStorage = { + getItem: (key: string, initialValue: T) => T; + setItem: (key: string, value: 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, initialValue: T): T => { + let initialLoadResult: T | undefined; + if (!(key in localStorageCache)) { + 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); + } + return initialLoadResult ?? (localStorageCache[key] as T); + }, + [localStorageCache, setItem] + ); + + return ( + ({ getItem, setItem }), [getItem, 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 } = 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] + ); + return useMemo(() => [value, setValue], [setValue, value]); +} diff --git a/scm-ui/ui-webapp/src/index.tsx b/scm-ui/ui-webapp/src/index.tsx index 2d91cb3741..5c6c7d7ecb 100644 --- a/scm-ui/ui-webapp/src/index.tsx +++ b/scm-ui/ui-webapp/src/index.tsx @@ -35,10 +35,8 @@ import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink"; import "./tokenExpired"; -import { ApiProvider } from "@scm-manager/ui-api"; -import { ShortcutDocsContextProvider } from "@scm-manager/ui-shortcuts"; - -// Makes sure that the global `define` function is registered and all provided modules are included in the final bundle at all times +import { ApiProvider, LocalStorageProvider } from "@scm-manager/ui-api"; +import { ShortcutDocsContextProvider } from "@scm-manager/ui-shortcuts"; // Makes sure that the global `define` function is registered and all provided modules are included in the final bundle at all times import "./_modules/provided-modules"; binder.bind("changeset.description.tokens", ChangesetShortLink); @@ -51,13 +49,15 @@ if (!root) { ReactDOM.render( - - - - - - - + + + + + + + + + , root