Skip to content

Commit

Permalink
Introduce useStoredState() hook (#4351)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonkoops authored Feb 8, 2023
1 parent 6cb730c commit abc7306
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 35 deletions.
1 change: 0 additions & 1 deletion apps/admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
9 changes: 5 additions & 4 deletions apps/admin-ui/src/components/help-enabler/HelpHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -32,13 +32,14 @@ export const HelpContext = createNamedContext<HelpContextProps | undefined>(
export const useHelp = () => useRequiredContext(HelpContext);

export const Help = ({ children }: PropsWithChildren<unknown>) => {
const [enabled, setHelp] = useLocalStorage("helpEnabled", "true");
const [enabled, setHelp] = useStoredState(localStorage, "helpEnabled", true);

function toggleHelp() {
setHelp(enabled === "true" ? "false" : "true");
setHelp(!enabled);
}

return (
<HelpContext.Provider value={{ enabled: enabled === "true", toggleHelp }}>
<HelpContext.Provider value={{ enabled, toggleHelp }}>
{children}
</HelpContext.Provider>
);
Expand Down
44 changes: 23 additions & 21 deletions apps/admin-ui/src/components/table-toolbar/KeycloakDataTable.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<T> = keyof T | JSX.Element | TitleCell;
Expand Down Expand Up @@ -206,11 +206,13 @@ export function KeycloakDataTable<T>({
const [unPaginatedData, setUnPaginatedData] = useState<T[]>();
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<string>("");
const prevSearch = useRef<string>();
Expand Down Expand Up @@ -410,7 +412,7 @@ export function KeycloakDataTable<T>({
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
setDefaultPageSize(`${max}`);
setDefaultPageSize(max);
}}
inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
Expand Down
57 changes: 57 additions & 0 deletions apps/admin-ui/src/utils/useStorageItem.ts
Original file line number Diff line number Diff line change
@@ -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<string>] {
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];
}
38 changes: 38 additions & 0 deletions apps/admin-ui/src/utils/useStoredState.ts
Original file line number Diff line number Diff line change
@@ -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<S>(
storageArea: Storage,
keyName: string,
defaultValue: S
): [S, Dispatch<S>] {
const defaultValueSerialized = useMemo(
() => JSON.stringify(defaultValue),
[defaultValue]
);

const [storedValue, setStoredValue] = useStorageItem(
storageArea,
keyName,
defaultValueSerialized
);

const value = useMemo<S>(() => JSON.parse(storedValue), [storedValue]);
const setValue = useCallback(
(value: S) => setStoredValue(JSON.stringify(value)),
[]
);

return [value, setValue];
}
9 changes: 0 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit abc7306

Please sign in to comment.