diff --git a/apps/admin-ui/package.json b/apps/admin-ui/package.json index 1e5f808f6f..9b3c2efdf1 100644 --- a/apps/admin-ui/package.json +++ b/apps/admin-ui/package.json @@ -82,7 +82,6 @@ "react-hook-form": "^7.42.1", "react-i18next": "^12.1.5", "react-router-dom": "6.8.0", - "react-use-localstorage": "^3.5.3", "reactflow": "^11.5.1", "use-react-router-breadcrumbs": "^4.0.1" }, diff --git a/apps/admin-ui/src/components/help-enabler/HelpHeader.tsx b/apps/admin-ui/src/components/help-enabler/HelpHeader.tsx index 19d37e831a..fa1ba8c8ba 100644 --- a/apps/admin-ui/src/components/help-enabler/HelpHeader.tsx +++ b/apps/admin-ui/src/components/help-enabler/HelpHeader.tsx @@ -11,11 +11,11 @@ import { import { ExternalLinkAltIcon, HelpIcon } from "@patternfly/react-icons"; import { PropsWithChildren, useState } from "react"; import { useTranslation } from "react-i18next"; -import useLocalStorage from "react-use-localstorage"; import helpUrls from "../../help-urls"; import { createNamedContext } from "../../utils/createNamedContext"; import useRequiredContext from "../../utils/useRequiredContext"; +import { useStoredState } from "../../utils/useStoredState"; import "./help-header.css"; @@ -32,13 +32,14 @@ export const HelpContext = createNamedContext( export const useHelp = () => useRequiredContext(HelpContext); export const Help = ({ children }: PropsWithChildren) => { - const [enabled, setHelp] = useLocalStorage("helpEnabled", "true"); + const [enabled, setHelp] = useStoredState(localStorage, "helpEnabled", true); function toggleHelp() { - setHelp(enabled === "true" ? "false" : "true"); + setHelp(!enabled); } + return ( - + {children} ); diff --git a/apps/admin-ui/src/components/table-toolbar/KeycloakDataTable.tsx b/apps/admin-ui/src/components/table-toolbar/KeycloakDataTable.tsx index 0d141343c0..9168331d0a 100644 --- a/apps/admin-ui/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/apps/admin-ui/src/components/table-toolbar/KeycloakDataTable.tsx @@ -1,13 +1,5 @@ -import { - ComponentClass, - isValidElement, - ReactNode, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; +import { ButtonVariant } from "@patternfly/react-core"; +import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon"; import { IAction, IActions, @@ -20,15 +12,23 @@ import { TableProps, TableVariant, } from "@patternfly/react-table"; -import { get, cloneDeep, differenceBy } from "lodash-es"; -import useLocalStorage from "react-use-localstorage"; +import { cloneDeep, differenceBy, get } from "lodash-es"; +import { + ComponentClass, + isValidElement, + ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; -import { PaginatingTableToolbar } from "./PaginatingTableToolbar"; -import { ListEmptyState } from "../list-empty-state/ListEmptyState"; -import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; import { useFetch } from "../../context/auth/AdminClient"; -import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon"; -import { ButtonVariant } from "@patternfly/react-core"; +import { useStoredState } from "../../utils/useStoredState"; +import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; +import { ListEmptyState } from "../list-empty-state/ListEmptyState"; +import { PaginatingTableToolbar } from "./PaginatingTableToolbar"; type TitleCell = { title: JSX.Element }; type Cell = keyof T | JSX.Element | TitleCell; @@ -206,11 +206,13 @@ export function KeycloakDataTable({ const [unPaginatedData, setUnPaginatedData] = useState(); const [loading, setLoading] = useState(false); - const [defaultPageSize, setDefaultPageSize] = useLocalStorage( + const [defaultPageSize, setDefaultPageSize] = useStoredState( + localStorage, "pageSize", - "10" + 10 ); - const [max, setMax] = useState(parseInt(defaultPageSize)); + + const [max, setMax] = useState(defaultPageSize); const [first, setFirst] = useState(0); const [search, setSearch] = useState(""); const prevSearch = useRef(); @@ -410,7 +412,7 @@ export function KeycloakDataTable({ onPerPageSelect={(first, max) => { setFirst(first); setMax(max); - setDefaultPageSize(`${max}`); + setDefaultPageSize(max); }} inputGroupName={ searchPlaceholderKey ? `${ariaLabelKey}input` : undefined diff --git a/apps/admin-ui/src/utils/useStorageItem.ts b/apps/admin-ui/src/utils/useStorageItem.ts new file mode 100644 index 0000000000..1e22fc89ea --- /dev/null +++ b/apps/admin-ui/src/utils/useStorageItem.ts @@ -0,0 +1,57 @@ +import { Dispatch, useCallback, useEffect, useState } from "react"; + +/** + * A hook that allows you to get a specific item stored by the [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API). + * Automatically updates the value when modified in the context of another document (such as an open tab) trough the [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event. + * + * @param storageArea The storage area to target, must implement the [`Storage`](https://developer.mozilla.org/en-US/docs/Web/API/Storage) interface (such as [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)). + * @param keyName The key of the item to get from storage, same as passed to [`Storage.getItem()`](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem) + * @param The default value to fall back to in case no stored value was retrieved. + */ +export function useStorageItem( + storageArea: Storage, + keyName: string, + defaultValue: string +): [string, Dispatch] { + const [value, setInnerValue] = useState( + () => storageArea.getItem(keyName) ?? defaultValue + ); + + const setValue = useCallback((newValue: string) => { + setInnerValue(newValue); + + // If the new value the same as the default value we can remove the item from storage. + if (newValue === defaultValue) { + storageArea.removeItem(keyName); + } else { + storageArea.setItem(keyName, newValue); + } + }, []); + + useEffect(() => { + // If the key name, storage area or default value has changed, we want to update the value. + // React will only set state if it actually changed, so no need to worry about re-renders. + setInnerValue(storageArea.getItem(keyName) ?? defaultValue); + + // Subscribe to storage events so we can update the value when it is changed within the context of another document. + window.addEventListener("storage", handleStorage); + + function handleStorage(event: StorageEvent) { + // If the affected storage area is different we can ignore this event. + // For example, if we're using session storage we're not interested in changes from local storage. + if (event.storageArea !== storageArea) { + return; + } + + // If the event key is null then it means all storage was cleared. + // Therefore we're interested in keys that are, or that match the key name. + if (event.key === null || event.key === keyName) { + setValue(event.newValue ?? defaultValue); + } + } + + return () => window.removeEventListener("storage", handleStorage); + }, [storageArea, keyName, defaultValue]); + + return [value, setValue]; +} diff --git a/apps/admin-ui/src/utils/useStoredState.ts b/apps/admin-ui/src/utils/useStoredState.ts new file mode 100644 index 0000000000..290361a1ce --- /dev/null +++ b/apps/admin-ui/src/utils/useStoredState.ts @@ -0,0 +1,38 @@ +import { Dispatch, useCallback, useMemo } from "react"; +import { useStorageItem } from "./useStorageItem"; + +/** + * A hook that acts similarly to React's `useState()`, but persists the state using [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API). + * Automatically updates the value when modified in the context of another document (such as an open tab) trough the [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event. + * + * The value is serialized as [JSON](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) and therefore the value provided must be serializable as such. + * Because the value is always serialized it will never be referentially equal to originally provided value. + * + * @param storageArea The storage area to target, must implement the [`Storage`](https://developer.mozilla.org/en-US/docs/Web/API/Storage) interface (such as [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)). + * @param keyName The key of the item to get from storage, same as passed to [`Storage.getItem()`](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem) + * @param defaultValue The default value to fall back to in case no stored value was retrieved (must be serializable as JSON). + */ +export function useStoredState( + storageArea: Storage, + keyName: string, + defaultValue: S +): [S, Dispatch] { + const defaultValueSerialized = useMemo( + () => JSON.stringify(defaultValue), + [defaultValue] + ); + + const [storedValue, setStoredValue] = useStorageItem( + storageArea, + keyName, + defaultValueSerialized + ); + + const value = useMemo(() => JSON.parse(storedValue), [storedValue]); + const setValue = useCallback( + (value: S) => setStoredValue(JSON.stringify(value)), + [] + ); + + return [value, setValue]; +} diff --git a/package-lock.json b/package-lock.json index 4165ca224b..377066591e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "react-hook-form": "^7.42.1", "react-i18next": "^12.1.5", "react-router-dom": "6.8.0", - "react-use-localstorage": "^3.5.3", "reactflow": "^11.5.1", "use-react-router-breadcrumbs": "^4.0.1" }, @@ -13922,14 +13921,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-use-localstorage": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/react-use-localstorage/-/react-use-localstorage-3.5.3.tgz", - "integrity": "sha512-1oNvJmo72G4v5P9ytJZZTb6ywD3UzWBiainTtfbNlb+U08hc+SOD5HqgiLTKUF0MxGcIR9JSnZGmBttNLXaQYA==", - "peerDependencies": { - "react": ">=16.8.1" - } - }, "node_modules/reactflow": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.5.1.tgz",