Fix initial list settings (#1401)

* Allow ocalStorage values to be initialized by cb

* Initialize list settings if not found

* Fix settings merging

* Refactor types
This commit is contained in:
Dominik Żegleń 2021-09-17 11:59:28 +02:00 committed by GitHub
parent 4fcc3eec5e
commit 51e204076f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 314 additions and 112 deletions

128
package-lock.json generated
View file

@ -6931,33 +6931,32 @@
"integrity": "sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==" "integrity": "sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg=="
}, },
"@typescript-eslint/eslint-plugin": { "@typescript-eslint/eslint-plugin": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.0.tgz",
"integrity": "sha512-FPUyCPKZbVGexmbCFI3EQHzCZdy2/5f+jv6k2EDljGdXSRc0cKvbndd2nHZkSLqCNOPk0jB6lGzwIkglXcYVsQ==", "integrity": "sha512-iPKZTZNavAlOhfF4gymiSuUkgLne/nh5Oz2/mdiUmuZVD42m9PapnCnzjxuDsnpnbH3wT5s2D8bw6S39TC6GNw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/experimental-utils": "4.21.0", "@typescript-eslint/experimental-utils": "4.31.0",
"@typescript-eslint/scope-manager": "4.21.0", "@typescript-eslint/scope-manager": "4.31.0",
"debug": "^4.1.1", "debug": "^4.3.1",
"functional-red-black-tree": "^1.0.1", "functional-red-black-tree": "^1.0.1",
"lodash": "^4.17.15", "regexpp": "^3.1.0",
"regexpp": "^3.0.0", "semver": "^7.3.5",
"semver": "^7.3.2", "tsutils": "^3.21.0"
"tsutils": "^3.17.1"
} }
}, },
"@typescript-eslint/experimental-utils": { "@typescript-eslint/experimental-utils": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.0.tgz",
"integrity": "sha512-cEbgosW/tUFvKmkg3cU7LBoZhvUs+ZPVM9alb25XvR0dal4qHL3SiUqHNrzoWSxaXA9gsifrYrS1xdDV6w/gIA==", "integrity": "sha512-Hld+EQiKLMppgKKkdUsLeVIeEOrwKc2G983NmznY/r5/ZtZCDvIOXnXtwqJIgYz/ymsy7n7RGvMyrzf1WaSQrw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/json-schema": "^7.0.3", "@types/json-schema": "^7.0.7",
"@typescript-eslint/scope-manager": "4.21.0", "@typescript-eslint/scope-manager": "4.31.0",
"@typescript-eslint/types": "4.21.0", "@typescript-eslint/types": "4.31.0",
"@typescript-eslint/typescript-estree": "4.21.0", "@typescript-eslint/typescript-estree": "4.31.0",
"eslint-scope": "^5.0.0", "eslint-scope": "^5.1.1",
"eslint-utils": "^2.0.0" "eslint-utils": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"eslint-scope": { "eslint-scope": {
@ -6973,55 +6972,55 @@
} }
}, },
"@typescript-eslint/parser": { "@typescript-eslint/parser": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.0.tgz",
"integrity": "sha512-eyNf7QmE5O/l1smaQgN0Lj2M/1jOuNg2NrBm1dqqQN0sVngTLyw8tdCbih96ixlhbF1oINoN8fDCyEH9SjLeIA==", "integrity": "sha512-oWbzvPh5amMuTmKaf1wp0ySxPt2ZXHnFQBN2Szu1O//7LmOvgaKTCIDNLK2NvzpmVd5A2M/1j/rujBqO37hj3w==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/scope-manager": "4.21.0", "@typescript-eslint/scope-manager": "4.31.0",
"@typescript-eslint/types": "4.21.0", "@typescript-eslint/types": "4.31.0",
"@typescript-eslint/typescript-estree": "4.21.0", "@typescript-eslint/typescript-estree": "4.31.0",
"debug": "^4.1.1" "debug": "^4.3.1"
} }
}, },
"@typescript-eslint/scope-manager": { "@typescript-eslint/scope-manager": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.0.tgz",
"integrity": "sha512-kfOjF0w1Ix7+a5T1knOw00f7uAP9Gx44+OEsNQi0PvvTPLYeXJlsCJ4tYnDj5PQEYfpcgOH5yBlw7K+UEI9Agw==", "integrity": "sha512-LJ+xtl34W76JMRLjbaQorhR0hfRAlp3Lscdiz9NeI/8i+q0hdBZ7BsiYieLoYWqy+AnRigaD3hUwPFugSzdocg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/types": "4.21.0", "@typescript-eslint/types": "4.31.0",
"@typescript-eslint/visitor-keys": "4.21.0" "@typescript-eslint/visitor-keys": "4.31.0"
} }
}, },
"@typescript-eslint/types": { "@typescript-eslint/types": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.0.tgz",
"integrity": "sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==", "integrity": "sha512-9XR5q9mk7DCXgXLS7REIVs+BaAswfdHhx91XqlJklmqWpTALGjygWVIb/UnLh4NWhfwhR5wNe1yTyCInxVhLqQ==",
"dev": true "dev": true
}, },
"@typescript-eslint/typescript-estree": { "@typescript-eslint/typescript-estree": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.0.tgz",
"integrity": "sha512-ZD3M7yLaVGVYLw4nkkoGKumb7Rog7QID9YOWobFDMQKNl+vPxqVIW/uDk+MDeGc+OHcoG2nJ2HphwiPNajKw3w==", "integrity": "sha512-QHl2014t3ptg+xpmOSSPn5hm4mY8D4s97ftzyk9BZ8RxYQ3j73XcwuijnJ9cMa6DO4aLXeo8XS3z1omT9LA/Eg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/types": "4.21.0", "@typescript-eslint/types": "4.31.0",
"@typescript-eslint/visitor-keys": "4.21.0", "@typescript-eslint/visitor-keys": "4.31.0",
"debug": "^4.1.1", "debug": "^4.3.1",
"globby": "^11.0.1", "globby": "^11.0.3",
"is-glob": "^4.0.1", "is-glob": "^4.0.1",
"semver": "^7.3.2", "semver": "^7.3.5",
"tsutils": "^3.17.1" "tsutils": "^3.21.0"
} }
}, },
"@typescript-eslint/visitor-keys": { "@typescript-eslint/visitor-keys": {
"version": "4.21.0", "version": "4.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.0.tgz",
"integrity": "sha512-dH22dROWGi5Z6p+Igc8bLVLmwy7vEe8r+8c+raPQU0LxgogPUrRAtRGtvBWmlr9waTu3n+QLt/qrS/hWzk1x5w==", "integrity": "sha512-HUcRp2a9I+P21+O21yu3ezv3GEPGjyGiXoEUQwZXjR8UxRApGeLyWH4ZIIUSalE28aG4YsV6GjtaAVB3QKOu0w==",
"dev": true, "dev": true,
"requires": { "requires": {
"@typescript-eslint/types": "4.21.0", "@typescript-eslint/types": "4.31.0",
"eslint-visitor-keys": "^2.0.0" "eslint-visitor-keys": "^2.0.0"
} }
}, },
@ -14117,26 +14116,18 @@
} }
}, },
"eslint-utils": { "eslint-utils": {
"version": "2.1.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
"integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
"dev": true, "dev": true,
"requires": { "requires": {
"eslint-visitor-keys": "^1.1.0" "eslint-visitor-keys": "^2.0.0"
},
"dependencies": {
"eslint-visitor-keys": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
"integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
"dev": true
}
} }
}, },
"eslint-visitor-keys": { "eslint-visitor-keys": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
"integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
"dev": true "dev": true
}, },
"espree": { "espree": {
@ -25120,9 +25111,9 @@
} }
}, },
"regexpp": { "regexpp": {
"version": "3.1.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
"integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
"dev": true "dev": true
}, },
"regexpu-core": { "regexpu-core": {
@ -29125,9 +29116,10 @@
} }
}, },
"typescript": { "typescript": {
"version": "4.2.4", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
"integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==" "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
"dev": true
}, },
"ua-parser-js": { "ua-parser-js": {
"version": "0.7.28", "version": "0.7.28",

View file

@ -77,7 +77,6 @@
"react-sortable-tree": "^2.6.2", "react-sortable-tree": "^2.6.2",
"semver-compare": "^1.0.0", "semver-compare": "^1.0.0",
"slugify": "^1.4.6", "slugify": "^1.4.6",
"typescript": "^4.2.3",
"url-join": "^4.0.1", "url-join": "^4.0.1",
"use-react-router": "^1.0.7" "use-react-router": "^1.0.7"
}, },
@ -178,6 +177,7 @@
"start-server-and-test": "^1.11.0", "start-server-and-test": "^1.11.0",
"ts-jest": "^24.2.0", "ts-jest": "^24.2.0",
"tsconfig-paths-webpack-plugin": "^3.2.0", "tsconfig-paths-webpack-plugin": "^3.2.0",
"typescript": "^4.3.5",
"webpack": "^4.35.3", "webpack": "^4.35.3",
"webpack-bundle-analyzer": "^4.4.1", "webpack-bundle-analyzer": "^4.4.1",
"webpack-cli": "^3.3.6", "webpack-cli": "^3.3.6",
@ -194,6 +194,7 @@
"fsevents": "^1.2.9" "fsevents": "^1.2.9"
}, },
"jest": { "jest": {
"resetMocks": false,
"setupFiles": [ "setupFiles": [
"jest-localstorage-mock" "jest-localstorage-mock"
], ],

View file

@ -53,10 +53,9 @@ interface AppsListProps {
export const AppsList: React.FC<AppsListProps> = ({ params }) => { export const AppsList: React.FC<AppsListProps> = ({ params }) => {
const { action } = params; const { action } = params;
const [activeInstallations, setActiveInstallations] = useLocalStorage( const [activeInstallations, setActiveInstallations] = useLocalStorage<
"activeInstallations", Array<Record<"id" | "name", string>>
[] >("activeInstallations", []);
);
const notify = useNotifier(); const notify = useNotifier();
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigator(); const navigate = useNavigator();

View file

@ -1,10 +1,9 @@
import { DEMO_MODE } from "@saleor/config"; import { DEMO_MODE } from "@saleor/config";
import { User } from "@saleor/fragments/types/User"; import { User } from "@saleor/fragments/types/User";
import { SetLocalStorage } from "@saleor/hooks/useLocalStorage";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { getFullName, getMutationStatus } from "@saleor/misc"; import { getFullName, getMutationStatus } from "@saleor/misc";
import errorTracker from "@saleor/services/errorTracking"; import errorTracker from "@saleor/services/errorTracking";
import { useEffect, useRef, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useMutation } from "react-apollo"; import { useMutation } from "react-apollo";
import { import {
@ -57,7 +56,7 @@ export interface UseExternalAuthProvider extends UseAuthProvider {
) => Promise<ExternalObtainAccessTokens_externalObtainAccessTokens>; ) => Promise<ExternalObtainAccessTokens_externalObtainAccessTokens>;
} }
export interface UseExternalAuthProviderOpts extends UseAuthProviderOpts { export interface UseExternalAuthProviderOpts extends UseAuthProviderOpts {
setAuthPlugin: SetLocalStorage<any>; setAuthPlugin: Dispatch<SetStateAction<any>>;
authPlugin: string; authPlugin: string;
} }

View file

@ -1,6 +1,5 @@
import { DEMO_MODE } from "@saleor/config"; import { DEMO_MODE } from "@saleor/config";
import { User } from "@saleor/fragments/types/User"; import { User } from "@saleor/fragments/types/User";
import { SetLocalStorage } from "@saleor/hooks/useLocalStorage";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { getFullName, getMutationStatus } from "@saleor/misc"; import { getFullName, getMutationStatus } from "@saleor/misc";
import errorTracker from "@saleor/services/errorTracking"; import errorTracker from "@saleor/services/errorTracking";
@ -9,7 +8,7 @@ import {
login as loginWithCredentialsManagementAPI, login as loginWithCredentialsManagementAPI,
saveCredentials saveCredentials
} from "@saleor/utils/credentialsManagement"; } from "@saleor/utils/credentialsManagement";
import { useEffect, useRef, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useMutation } from "react-apollo"; import { useMutation } from "react-apollo";
import { import {
@ -38,7 +37,7 @@ export interface UseSaleorAuthProvider extends UseAuthProvider {
loginByToken: (auth: string, csrf: string, user: User) => void; loginByToken: (auth: string, csrf: string, user: User) => void;
} }
export interface UseSaleorAuthProviderOpts extends UseAuthProviderOpts { export interface UseSaleorAuthProviderOpts extends UseAuthProviderOpts {
setAuthPlugin: SetLocalStorage<any>; setAuthPlugin: Dispatch<SetStateAction<any>>;
authPlugin: string; authPlugin: string;
} }

View file

@ -0,0 +1,80 @@
import { defaultListSettings } from "@saleor/config";
import { ListViews } from "@saleor/types";
import { renderHook } from "@testing-library/react-hooks";
import useListSettings, { listSettingsStorageKey } from "./useListSettings";
const key = ListViews.CATEGORY_LIST;
const storedValue = {
...defaultListSettings,
[key]: {
...defaultListSettings[key],
rowNumber: 100
}
};
const valueWithoutKey = {
...defaultListSettings,
[key]: undefined
};
const valueWithoutSettings = {
...defaultListSettings,
[key]: {
foo: "bar"
}
};
beforeEach(() => {
localStorage.clear();
});
describe("useListSettings", () => {
it("properly inits from value", () => {
expect(localStorage.getItem(listSettingsStorageKey)).toBe(null);
const { result } = renderHook(() => useListSettings(key));
expect(result.current.settings).toStrictEqual(defaultListSettings[key]);
});
it("omits init if value is present", () => {
localStorage.setItem(listSettingsStorageKey, JSON.stringify(storedValue));
expect(localStorage.getItem(listSettingsStorageKey)).toBe(
JSON.stringify(storedValue)
);
const { result } = renderHook(() => useListSettings(key));
expect(result.current.settings).toStrictEqual(storedValue[key]);
});
it("properly merges new default values to saved ones", () => {
localStorage.setItem(
listSettingsStorageKey,
JSON.stringify(valueWithoutKey)
);
expect(localStorage.getItem(listSettingsStorageKey)).toBe(
JSON.stringify(valueWithoutKey)
);
const { result } = renderHook(() => useListSettings(key));
expect(result.current.settings).toStrictEqual(defaultListSettings[key]);
});
it("properly fills missing settings", () => {
localStorage.setItem(
listSettingsStorageKey,
JSON.stringify(valueWithoutSettings)
);
expect(localStorage.getItem(listSettingsStorageKey)).toBe(
JSON.stringify(valueWithoutSettings)
);
const { result } = renderHook(() => useListSettings(key));
expect(result.current.settings).toStrictEqual({
...valueWithoutSettings[key],
...defaultListSettings[key]
});
});
});

View file

@ -1,9 +1,10 @@
import useLocalStorage from "@saleor/hooks/useLocalStorage"; import useLocalStorage from "@saleor/hooks/useLocalStorage";
import { useEffect } from "react"; import merge from "lodash/merge";
import { AppListViewSettings, defaultListSettings } from "./../config"; import { AppListViewSettings, defaultListSettings } from "./../config";
import { ListSettings, ListViews } from "./../types"; import { ListSettings, ListViews } from "./../types";
export const listSettingsStorageKey = "listConfig";
export interface UseListSettings<TColumns extends string = string> { export interface UseListSettings<TColumns extends string = string> {
settings: ListSettings<TColumns>; settings: ListSettings<TColumns>;
updateListSettings: <T extends keyof ListSettings<TColumns>>( updateListSettings: <T extends keyof ListSettings<TColumns>>(
@ -15,18 +16,15 @@ export default function useListSettings<TColumns extends string = string>(
listName: ListViews listName: ListViews
): UseListSettings<TColumns> { ): UseListSettings<TColumns> {
const [settings, setListSettings] = useLocalStorage<AppListViewSettings>( const [settings, setListSettings] = useLocalStorage<AppListViewSettings>(
"listConfig", listSettingsStorageKey,
defaultListSettings storedListSettings => {
); if (typeof storedListSettings !== "object") {
return defaultListSettings;
}
useEffect(() => { return merge({}, defaultListSettings, storedListSettings);
if (settings[listName] === undefined) {
setListSettings(settings => ({
...settings,
[listName]: defaultListSettings[listName]
}));
} }
}, []); );
const updateListSettings = <T extends keyof ListSettings>( const updateListSettings = <T extends keyof ListSettings>(
key: T, key: T,

View file

@ -0,0 +1,105 @@
import { renderHook } from "@testing-library/react-hooks";
import useLocalStorage from "./useLocalStorage";
const key = "exampleKey";
const initialValue = "exampleValue";
const savedValue = "savedValue";
const numberValue = 12;
const objectValue = { foo: numberValue };
const booleanValue = true;
const postfix = "-test";
beforeEach(() => {
localStorage.clear();
});
describe("useLocalStorage", () => {
it("properly inits from value", () => {
expect(localStorage.getItem(key)).toBe(null);
const { result } = renderHook(() => useLocalStorage(key, initialValue));
expect(result.current[0]).toBe(initialValue);
});
it("omits initializing if value is found", () => {
localStorage.setItem(key, savedValue);
expect(localStorage.getItem(key)).toBe(savedValue);
const { result } = renderHook(() => useLocalStorage(key, initialValue));
expect(result.current[0]).toBe(savedValue);
});
it("properly casts value to number", () => {
localStorage.setItem(key, JSON.stringify(numberValue));
const { result } = renderHook(() => useLocalStorage(key, initialValue));
expect(result.current[0]).toBe(numberValue);
});
it("properly casts value to boolean", () => {
localStorage.setItem(key, JSON.stringify(booleanValue));
const { result } = renderHook(() => useLocalStorage(key, initialValue));
expect(result.current[0]).toBe(booleanValue);
});
it("properly casts value to object", () => {
localStorage.setItem(key, JSON.stringify(objectValue));
const { result } = renderHook(() => useLocalStorage(key, initialValue));
expect(result.current[0]).toStrictEqual(objectValue);
});
it("properly inits from callback if value is not found", () => {
const { result } = renderHook(() =>
useLocalStorage(key, storedValue =>
storedValue ? storedValue + postfix : initialValue
)
);
expect(result.current[0]).toBe(initialValue);
});
it("properly inits from callback if value is found", () => {
localStorage.setItem(key, savedValue);
const { result } = renderHook(() =>
useLocalStorage(key, storedValue =>
storedValue ? storedValue + postfix : initialValue
)
);
expect(result.current[0]).toBe(savedValue + postfix);
expect(localStorage.getItem(key)).toBe(savedValue + postfix);
});
it("properly inits from callback if value is object", () => {
localStorage.setItem(key, JSON.stringify(objectValue));
const { result } = renderHook(() =>
useLocalStorage(key, storedValue => {
if (typeof storedValue === "object") {
return {
...storedValue,
bar: "baz"
};
}
return objectValue;
})
);
const newValue = {
foo: numberValue,
bar: "baz"
};
expect(result.current[0]).toStrictEqual(newValue);
expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue));
});
});

View file

@ -1,33 +1,62 @@
import { useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
export type SetLocalStorageValue<T> = T | ((prevValue: T) => T); export type UseLocalStorage<T> = [T, Dispatch<SetStateAction<T>>];
export type SetLocalStorage<T> = (value: SetLocalStorageValue<T>) => void;
export default function useLocalStorage<T>( export default function useLocalStorage<T>(
key: string, key: string,
initialValue: T initialValue: SetStateAction<T>
): [T, SetLocalStorage<T>] { ): UseLocalStorage<T> {
const [storedValue, setStoredValue] = useState<T>(() => { const saveToLocalStorage = (valueToStore: T) => {
let result: T;
try { try {
const item = window.localStorage.getItem(key); if (typeof valueToStore === "string") {
result = item ? JSON.parse(item) : initialValue; localStorage.setItem(key, valueToStore);
} catch { } else {
result = initialValue; localStorage.setItem(key, JSON.stringify(valueToStore));
} }
return result;
});
const setValue = (value: SetLocalStorageValue<T>) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
try {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch { } catch {
console.warn(`Could not save ${key} to localStorage`); console.warn(`Could not save ${key} to localStorage`);
} }
}; };
function getValue(value: T, initOrCb: SetStateAction<T>): T {
if (initOrCb instanceof Function) {
const newValue = initOrCb(value);
saveToLocalStorage(newValue);
return newValue;
}
return value ?? initOrCb;
}
const [storedValue, setStoredValue] = useState<T>(() => {
let result: T | null;
const item = localStorage.getItem(key);
if (item === null) {
return getValue(null, initialValue);
}
try {
const parsed = JSON.parse(item);
if (!parsed) {
throw new Error("Empty value");
}
result = parsed;
} catch {
// Casting to T (which should resolve to string) because JSON.parse would
// throw an error if "foo" was passed, but properly casting "true" or "1"
// to their respective types
result = (item as unknown) as T;
}
return getValue(result, initialValue);
});
const setValue = (value: SetStateAction<T>) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
saveToLocalStorage(valueToStore);
};
return [storedValue, setValue]; return [storedValue, setValue];
} }