mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 07:25:44 +01:00
Simplify local storage access
The caching for local storage seems way over-engineered. With this the context for the local storage is removed and the local storage is accessed directly. Pushed-by: k8s-git-ops<admin@cloudogu.com> Pushed-by: Rene Pfeuffer<rene.pfeuffer@cloudogu.com> Co-authored-by: René Pfeuffer<rene.pfeuffer@cloudogu.com>
This commit is contained in:
2
gradle/changelog/local_storage.yaml
Normal file
2
gradle/changelog/local_storage.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: fixed
|
||||||
|
description: First access to local storage returning the default value instead of the actual value
|
||||||
64
scm-ui/ui-api/src/localStorage.ts
Normal file
64
scm-ui/ui-api/src/localStorage.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2020 - present Cloudogu GmbH
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type LocalStorageSetter<T> = (value: T | ((previousValue: T) => T)) => void;
|
||||||
|
|
||||||
|
const determineInitialValue = <T>(key: string, initialValue: T) => {
|
||||||
|
try {
|
||||||
|
const itemFromStorage = localStorage.getItem(key);
|
||||||
|
return itemFromStorage ? JSON.parse(itemFromStorage) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(key: string, initialValue: T): [value: T, setValue: LocalStorageSetter<T>] {
|
||||||
|
const initialValueOrValueFromStorage = useMemo(() => determineInitialValue(key, initialValue), [key, initialValue]);
|
||||||
|
const [item, setItem] = useState(initialValueOrValueFromStorage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: StorageEvent) => {
|
||||||
|
if (event.key === key) {
|
||||||
|
setItem(determineInitialValue(key, initialValue));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", listener);
|
||||||
|
return () => window.removeEventListener("storage", listener);
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
const setValue: LocalStorageSetter<T> = (newValue) => {
|
||||||
|
const computedNewValue = newValue instanceof Function ? newValue(item) : newValue;
|
||||||
|
setItem(computedNewValue);
|
||||||
|
const json = JSON.stringify(computedNewValue);
|
||||||
|
localStorage.setItem(key, json);
|
||||||
|
// storage event is no triggered in same tab
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent("storage", { key, oldValue: item, newValue: json, storageArea: localStorage })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [item, setValue];
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2020 - present Cloudogu GmbH
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it under
|
|
||||||
* the terms of the GNU Affero General Public License as published by the Free
|
|
||||||
* Software Foundation, version 3.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
* details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see https://www.gnu.org/licenses/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
type LocalStorage = {
|
|
||||||
getItem: <T>(key: string, fallback: T) => T;
|
|
||||||
setItem: <T>(key: string, value: T) => void;
|
|
||||||
preload: <T>(key: string, initialValue: T) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LocalStorageContext = createContext<LocalStorage>(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<Record<string, unknown>>({});
|
|
||||||
|
|
||||||
const setItem = useCallback(<T,>(key: string, value: T) => {
|
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
setLocalStorageCache((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getItem = useCallback(
|
|
||||||
<T,>(key: string, fallback: T): T => (key in localStorageCache ? (localStorageCache[key] as T) : fallback),
|
|
||||||
[localStorageCache]
|
|
||||||
);
|
|
||||||
|
|
||||||
const preload = useCallback(
|
|
||||||
<T,>(key: string, initialValue: T) => {
|
|
||||||
if (!(key in localStorageCache)) {
|
|
||||||
let initialLoadResult: T | undefined;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[localStorageCache, setItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LocalStorageContext.Provider value={useMemo(() => ({ getItem, setItem, preload }), [getItem, preload, setItem])}>
|
|
||||||
{children}
|
|
||||||
</LocalStorageContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<T>(
|
|
||||||
key: string,
|
|
||||||
initialValue: T
|
|
||||||
): [value: T, setValue: (value: T | ((previousConfig: T) => T)) => void] {
|
|
||||||
const { getItem, setItem, preload } = 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]
|
|
||||||
);
|
|
||||||
useEffect(() => preload(key, initialValue), [initialValue, key, preload]);
|
|
||||||
return useMemo(() => [value, setValue], [setValue, value]);
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import { withI18next } from "storybook-addon-i18next";
|
import { withI18next } from "storybook-addon-i18next";
|
||||||
import React, {useEffect} from "react";
|
import React, { useEffect } from "react";
|
||||||
import withApiProvider from "./withApiProvider";
|
import withApiProvider from "./withApiProvider";
|
||||||
import { withThemes } from 'storybook-addon-themes/react';
|
import { withThemes } from "storybook-addon-themes/react";
|
||||||
|
|
||||||
let i18n = i18next;
|
let i18n = i18next;
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ let i18n = i18next;
|
|||||||
// and not for storyshots
|
// and not for storyshots
|
||||||
if (!process.env.JEST_WORKER_ID) {
|
if (!process.env.JEST_WORKER_ID) {
|
||||||
const Backend = require("i18next-fetch-backend");
|
const Backend = require("i18next-fetch-backend");
|
||||||
i18n = i18n.use(Backend.default);
|
i18n = i18n.use(Backend);
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
@@ -58,10 +58,10 @@ export const decorators = [
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
withApiProvider,
|
withApiProvider,
|
||||||
withThemes
|
withThemes,
|
||||||
];
|
];
|
||||||
|
|
||||||
const Decorator = ({children, themeName}) => {
|
const Decorator = ({ children, themeName }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const link = document.querySelector("#ui-theme");
|
const link = document.querySelector("#ui-theme");
|
||||||
if (link && link["data-theme"] !== themeName) {
|
if (link && link["data-theme"] !== themeName) {
|
||||||
@@ -69,7 +69,7 @@ const Decorator = ({children, themeName}) => {
|
|||||||
link["data-theme"] = themeName;
|
link["data-theme"] = themeName;
|
||||||
}
|
}
|
||||||
}, [themeName]);
|
}, [themeName]);
|
||||||
return <>{children}</>
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parameters = {
|
export const parameters = {
|
||||||
|
|||||||
@@ -77755,16 +77755,9 @@ exports[`Storyshots Secondary Navigation Active when match 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
aria-label="secondaryNavigation.hideContent"
|
aria-label="secondaryNavigation.hideContent"
|
||||||
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 joMQbq menu-label is-clickable"
|
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 joMQbq menu-label"
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
<i
|
|
||||||
className="SecondaryNavigation__Icon-sc-8p1rgi-1 hDCYCs is-medium"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-caret-down"
|
|
||||||
/>
|
|
||||||
</i>
|
|
||||||
Hitchhiker
|
Hitchhiker
|
||||||
</p>
|
</p>
|
||||||
<ul
|
<ul
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { storiesOf } from "@storybook/react";
|
|||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import { Binder, BinderContext, extensionPoints } from "@scm-manager/ui-extensions";
|
import { Binder, BinderContext, extensionPoints } from "@scm-manager/ui-extensions";
|
||||||
import { Me } from "@scm-manager/ui-types";
|
import { Me } from "@scm-manager/ui-types";
|
||||||
import { LocalStorageProvider } from "@scm-manager/ui-api";
|
|
||||||
import { EXTENSION_POINT } from "../avatar/Avatar";
|
import { EXTENSION_POINT } from "../avatar/Avatar";
|
||||||
// @ts-ignore ignore unknown png
|
// @ts-ignore ignore unknown png
|
||||||
import hitchhiker from "../__resources__/hitchhiker.png";
|
import hitchhiker from "../__resources__/hitchhiker.png";
|
||||||
@@ -56,7 +55,6 @@ const withBinder = (binder: Binder) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
storiesOf("Footer", module)
|
storiesOf("Footer", module)
|
||||||
.addDecorator((story) => <LocalStorageProvider>{story()}</LocalStorageProvider>)
|
|
||||||
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||||
.add("Default", () => {
|
.add("Default", () => {
|
||||||
return <Footer me={trillian} version="2.0.0" links={{}} />;
|
return <Footer me={trillian} version="2.0.0" links={{}} />;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
import { storiesOf } from "@storybook/react";
|
import { storiesOf } from "@storybook/react";
|
||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { LocalStorageProvider } from "@scm-manager/ui-api";
|
|
||||||
import SecondaryNavigation from "./SecondaryNavigation";
|
import SecondaryNavigation from "./SecondaryNavigation";
|
||||||
import SecondaryNavigationItem from "./SecondaryNavigationItem";
|
import SecondaryNavigationItem from "./SecondaryNavigationItem";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
@@ -45,7 +44,6 @@ const withRoute = (route: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
storiesOf("Secondary Navigation", module)
|
storiesOf("Secondary Navigation", module)
|
||||||
.addDecorator((story) => <LocalStorageProvider>{story()}</LocalStorageProvider>)
|
|
||||||
.addDecorator((story) => (
|
.addDecorator((story) => (
|
||||||
<Columns className="columns">
|
<Columns className="columns">
|
||||||
<div className="column is-3">{story()}</div>
|
<div className="column is-3">{story()}</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { binder, extensionPoints } from "@scm-manager/ui-extensions";
|
|||||||
import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink";
|
import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink";
|
||||||
|
|
||||||
import "./tokenExpired";
|
import "./tokenExpired";
|
||||||
import { ApiProvider, LocalStorageProvider } from "@scm-manager/ui-api";
|
import { ApiProvider } from "@scm-manager/ui-api";
|
||||||
import { ShortcutDocsContextProvider } from "@scm-manager/ui-core"; // Makes sure that the global `define` function is registered and all provided modules are included in the final bundle at all times
|
import { ShortcutDocsContextProvider } from "@scm-manager/ui-core"; // 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";
|
import "./_modules/provided-modules";
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ if (!root) {
|
|||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<LocalStorageProvider>
|
|
||||||
<ShortcutDocsContextProvider>
|
<ShortcutDocsContextProvider>
|
||||||
<ActiveModalCountContextProvider>
|
<ActiveModalCountContextProvider>
|
||||||
<Router basename={urls.contextPath}>
|
<Router basename={urls.contextPath}>
|
||||||
@@ -50,7 +49,6 @@ ReactDOM.render(
|
|||||||
</Router>
|
</Router>
|
||||||
</ActiveModalCountContextProvider>
|
</ActiveModalCountContextProvider>
|
||||||
</ShortcutDocsContextProvider>
|
</ShortcutDocsContextProvider>
|
||||||
</LocalStorageProvider>
|
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</ApiProvider>,
|
</ApiProvider>,
|
||||||
root
|
root
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import EditRepoNavLink from "./EditRepoNavLink";
|
import EditRepoNavLink from "./EditRepoNavLink";
|
||||||
import { mount, shallow } from "@scm-manager/ui-tests";
|
import { mount, shallow } from "@scm-manager/ui-tests";
|
||||||
import { LocalStorageProvider } from "@scm-manager/ui-api";
|
|
||||||
|
|
||||||
describe("GeneralNavLink", () => {
|
describe("GeneralNavLink", () => {
|
||||||
it("should render nothing, if the modify link is missing", () => {
|
it("should render nothing, if the modify link is missing", () => {
|
||||||
@@ -44,11 +43,7 @@ describe("GeneralNavLink", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const navLink = mount(
|
const navLink = mount(<EditRepoNavLink repository={repository} editUrl="" />);
|
||||||
<LocalStorageProvider>
|
|
||||||
<EditRepoNavLink repository={repository} editUrl="" />
|
|
||||||
</LocalStorageProvider>
|
|
||||||
);
|
|
||||||
expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink");
|
expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { mount, shallow } from "@scm-manager/ui-tests";
|
import { mount, shallow } from "@scm-manager/ui-tests";
|
||||||
import "@scm-manager/ui-tests";
|
import "@scm-manager/ui-tests";
|
||||||
import { LocalStorageProvider } from "@scm-manager/ui-api";
|
|
||||||
import PermissionsNavLink from "./PermissionsNavLink";
|
import PermissionsNavLink from "./PermissionsNavLink";
|
||||||
|
|
||||||
describe("PermissionsNavLink", () => {
|
describe("PermissionsNavLink", () => {
|
||||||
@@ -39,11 +38,7 @@ describe("PermissionsNavLink", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const navLink = mount(
|
const navLink = mount(<PermissionsNavLink repository={repository} permissionUrl="" />);
|
||||||
<LocalStorageProvider>
|
|
||||||
<PermissionsNavLink repository={repository} permissionUrl="" />
|
|
||||||
</LocalStorageProvider>
|
|
||||||
);
|
|
||||||
expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink");
|
expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { mount, shallow } from "@scm-manager/ui-tests";
|
import { mount, shallow } from "@scm-manager/ui-tests";
|
||||||
import "@scm-manager/ui-tests";
|
import "@scm-manager/ui-tests";
|
||||||
import { LocalStorageProvider } from "@scm-manager/ui-api";
|
|
||||||
import RepositoryNavLink from "./RepositoryNavLink";
|
import RepositoryNavLink from "./RepositoryNavLink";
|
||||||
|
|
||||||
describe("RepositoryNavLink", () => {
|
describe("RepositoryNavLink", () => {
|
||||||
@@ -54,7 +53,6 @@ describe("RepositoryNavLink", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navLink = mount(
|
const navLink = mount(
|
||||||
<LocalStorageProvider>
|
|
||||||
<RepositoryNavLink
|
<RepositoryNavLink
|
||||||
repository={repository}
|
repository={repository}
|
||||||
linkName="sources"
|
linkName="sources"
|
||||||
@@ -62,7 +60,6 @@ describe("RepositoryNavLink", () => {
|
|||||||
label="Sources"
|
label="Sources"
|
||||||
activeOnlyWhenExact={true}
|
activeOnlyWhenExact={true}
|
||||||
/>
|
/>
|
||||||
</LocalStorageProvider>
|
|
||||||
);
|
);
|
||||||
expect(navLink.text()).toBe("Sources");
|
expect(navLink.text()).toBe("Sources");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user