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

View file

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

View file

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

View file

@ -1,10 +1,9 @@
import { DEMO_MODE } from "@saleor/config";
import { User } from "@saleor/fragments/types/User";
import { SetLocalStorage } from "@saleor/hooks/useLocalStorage";
import { commonMessages } from "@saleor/intl";
import { getFullName, getMutationStatus } from "@saleor/misc";
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 {
@ -57,7 +56,7 @@ export interface UseExternalAuthProvider extends UseAuthProvider {
) => Promise<ExternalObtainAccessTokens_externalObtainAccessTokens>;
}
export interface UseExternalAuthProviderOpts extends UseAuthProviderOpts {
setAuthPlugin: SetLocalStorage<any>;
setAuthPlugin: Dispatch<SetStateAction<any>>;
authPlugin: string;
}

View file

@ -1,6 +1,5 @@
import { DEMO_MODE } from "@saleor/config";
import { User } from "@saleor/fragments/types/User";
import { SetLocalStorage } from "@saleor/hooks/useLocalStorage";
import { commonMessages } from "@saleor/intl";
import { getFullName, getMutationStatus } from "@saleor/misc";
import errorTracker from "@saleor/services/errorTracking";
@ -9,7 +8,7 @@ import {
login as loginWithCredentialsManagementAPI,
saveCredentials
} from "@saleor/utils/credentialsManagement";
import { useEffect, useRef, useState } from "react";
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useMutation } from "react-apollo";
import {
@ -38,7 +37,7 @@ export interface UseSaleorAuthProvider extends UseAuthProvider {
loginByToken: (auth: string, csrf: string, user: User) => void;
}
export interface UseSaleorAuthProviderOpts extends UseAuthProviderOpts {
setAuthPlugin: SetLocalStorage<any>;
setAuthPlugin: Dispatch<SetStateAction<any>>;
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 { useEffect } from "react";
import merge from "lodash/merge";
import { AppListViewSettings, defaultListSettings } from "./../config";
import { ListSettings, ListViews } from "./../types";
export const listSettingsStorageKey = "listConfig";
export interface UseListSettings<TColumns extends string = string> {
settings: ListSettings<TColumns>;
updateListSettings: <T extends keyof ListSettings<TColumns>>(
@ -15,18 +16,15 @@ export default function useListSettings<TColumns extends string = string>(
listName: ListViews
): UseListSettings<TColumns> {
const [settings, setListSettings] = useLocalStorage<AppListViewSettings>(
"listConfig",
defaultListSettings
);
listSettingsStorageKey,
storedListSettings => {
if (typeof storedListSettings !== "object") {
return defaultListSettings;
}
useEffect(() => {
if (settings[listName] === undefined) {
setListSettings(settings => ({
...settings,
[listName]: defaultListSettings[listName]
}));
return merge({}, defaultListSettings, storedListSettings);
}
}, []);
);
const updateListSettings = <T extends keyof ListSettings>(
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 SetLocalStorage<T> = (value: SetLocalStorageValue<T>) => void;
export type UseLocalStorage<T> = [T, Dispatch<SetStateAction<T>>];
export default function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetLocalStorage<T>] {
const [storedValue, setStoredValue] = useState<T>(() => {
let result: T;
initialValue: SetStateAction<T>
): UseLocalStorage<T> {
const saveToLocalStorage = (valueToStore: T) => {
try {
const item = window.localStorage.getItem(key);
result = item ? JSON.parse(item) : initialValue;
} catch {
result = initialValue;
}
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));
if (typeof valueToStore === "string") {
localStorage.setItem(key, valueToStore);
} else {
localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch {
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];
}