From 563ac8acc86c935a2c1007044e1313ce2598bf87 Mon Sep 17 00:00:00 2001 From: Mcat12 Date: Fri, 1 Mar 2019 21:18:10 -0800 Subject: [PATCH] Cache the web interface preferences in local storage As part of this change, updating the language with i18n was moved from `PreferenceSettings` into a new `LanguageApplier` component. Also, the alert message translation process in `PreferenceSettings` was refactored. A new dev dependency, `jest-localstorage-mock`, was added to help test the preferences cache implementation. See #155 Signed-off-by: Mcat12 --- package-lock.json | 6 ++ package.json | 1 + src/components/common/LanguageApplier.tsx | 33 +++++++++++ .../common/__tests__/context.test.tsx | 37 ++++++++++++ src/components/common/context.tsx | 49 ++++++++++++++-- .../settings/PreferenceSettings.tsx | 58 ++++++++++++------- src/containers/Full.tsx | 2 + src/setupTests.tsx | 10 +++- 8 files changed, 167 insertions(+), 29 deletions(-) create mode 100644 src/components/common/LanguageApplier.tsx create mode 100644 src/components/common/__tests__/context.test.tsx diff --git a/package-lock.json b/package-lock.json index f4fd821..485dcd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10965,6 +10965,12 @@ "pretty-format": "^23.6.0" } }, + "jest-localstorage-mock": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.0.tgz", + "integrity": "sha512-/mC1JxnMeuIlAaQBsDMilskC/x/BicsQ/BXQxEOw+5b1aGZkkOAqAF3nu8yq449CpzGtp5jJ5wCmDNxLgA2m6A==", + "dev": true + }, "jest-matcher-utils": { "version": "23.6.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", diff --git a/package.json b/package.json index 602869d..2455f2a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "fetch-mock": "^7.3.0", "fs-extra": "^7.0.1", "jest-enzyme": "^7.0.1", + "jest-localstorage-mock": "^2.4.0", "multi-progress": "^2.0.0", "node-fetch": "^2.3.0", "node-sass-chokidar": "^1.3.4", diff --git a/src/components/common/LanguageApplier.tsx b/src/components/common/LanguageApplier.tsx new file mode 100644 index 0000000..b5da269 --- /dev/null +++ b/src/components/common/LanguageApplier.tsx @@ -0,0 +1,33 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Apply the web interface language preference + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import React from "react"; +import { PreferencesContext } from "./context"; +import i18n from "i18next"; + +const applyLanguage = (language: string) => { + if (i18n.language === language) { + // Don't change the language if it's already correctly set + return; + } + + // noinspection JSIgnoredPromiseFromCall + i18n.changeLanguage(language); +}; + +export default () => ( + + {({ settings }) => { + applyLanguage(settings.language); + + return null; + }} + +); diff --git a/src/components/common/__tests__/context.test.tsx b/src/components/common/__tests__/context.test.tsx new file mode 100644 index 0000000..b44c9f1 --- /dev/null +++ b/src/components/common/__tests__/context.test.tsx @@ -0,0 +1,37 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * React context object tests + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { + defaultPreferences, + loadInitialPreferences, + WEB_PREFERENCES_STORAGE_KEY +} from "../context"; + +it("loads the default preferences if none are cached", () => { + const preferences = loadInitialPreferences(); + + expect(preferences).toEqual(defaultPreferences); +}); + +it("loads the cached preferences when available", () => { + const expectedPreferences: ApiPreferences = { + language: "testLang", + layout: "boxed" + }; + + localStorage.setItem( + WEB_PREFERENCES_STORAGE_KEY, + JSON.stringify(expectedPreferences) + ); + + const actualPreferences = loadInitialPreferences(); + + expect(actualPreferences).toEqual(expectedPreferences); +}); diff --git a/src/components/common/context.tsx b/src/components/common/context.tsx index 4e17afb..03cc533 100644 --- a/src/components/common/context.tsx +++ b/src/components/common/context.tsx @@ -22,12 +22,41 @@ export interface PreferencesContextType { refresh: (data?: ApiPreferences) => void; } +export const WEB_PREFERENCES_STORAGE_KEY = "webPreferences"; + const initialStatus: StatusContextType = { status: "unknown", refresh: () => {} }; + +export const defaultPreferences: ApiPreferences = { + layout: "boxed", + language: "en" +}; + +/** + * Load the initial web interface preferences. Cached preferences from local + * storage will be used if available, otherwise default preferences will be + * used. + */ +export const loadInitialPreferences = (): ApiPreferences => { + const cachedPreferencesString = localStorage.getItem( + WEB_PREFERENCES_STORAGE_KEY + ); + + if (cachedPreferencesString === null) { + return defaultPreferences; + } + + try { + return JSON.parse(cachedPreferencesString); + } catch (e) { + return defaultPreferences; + } +}; + const initialPreferences: PreferencesContextType = { - settings: { layout: "boxed", language: "en" }, + settings: loadInitialPreferences(), refresh: () => {} }; @@ -101,11 +130,19 @@ export const PreferencesProvider = ({ {children} )} - renderOk={(settings, refresh) => ( - - {children} - - )} + renderOk={(settings, refresh) => { + // Update the cached settings + localStorage.setItem( + WEB_PREFERENCES_STORAGE_KEY, + JSON.stringify(settings) + ); + + return ( + + {children} + + ); + }} renderErr={(_, refresh) => ( { e.preventDefault(); - const { t } = this.props; - this.setState({ - alertMessage: t("Processing..."), + alertMessage: "Processing...", + error: null, alertType: "info", showAlert: true, - processing: true + processing: true, + translateMessage: true }); this.updateHandler = makeCancelable( @@ -115,40 +118,37 @@ class PreferenceSettings extends Component< this.updateHandler.promise .then(() => { this.setState({ - alertMessage: t("Successfully saved preferences"), + alertMessage: "Successfully saved preferences", alertType: "success", showAlert: true, - processing: false + processing: false, + translateMessage: true }); - // Update the language - i18n.changeLanguage(this.state.settings.language).then(() => - // Once the language is updated, update the alert message to use the - // new language - this.setState({ alertMessage: t("Successfully saved preferences") }) - ); - // Update anyone using the preferences this.props.refresh(this.state.settings); }) .catch(ignoreCancel) .catch(error => { let message = ""; + let apiError = null; + let translateMessage = true; if (error instanceof Error) { message = error.message; + translateMessage = false; } else { - // Translate the API's error message - message = t("API Error: {{error}}", { - error: t(error.key, error.data) - }); + message = "API Error: {{error}}"; + apiError = error; } this.setState({ alertMessage: message, + error: apiError, alertType: "danger", showAlert: true, - processing: false + processing: false, + translateMessage }); }); }; @@ -157,12 +157,30 @@ class PreferenceSettings extends Component< this.setState({ showAlert: false }); }; + getAlertMessage = () => { + const { t } = this.props; + + if (this.state.error) { + // Translate the API error + return t(this.state.alertMessage, { + error: t(this.state.error.key, this.state.error.data) + }); + } else { + // Check if the message should be translated + if (this.state.translateMessage) { + return t(this.state.alertMessage); + } else { + return this.state.alertMessage; + } + } + }; + render() { const { t } = this.props; const alert = this.state.showAlert ? ( diff --git a/src/containers/Full.tsx b/src/containers/Full.tsx index d97c9c2..0457be3 100644 --- a/src/containers/Full.tsx +++ b/src/containers/Full.tsx @@ -23,11 +23,13 @@ import { } from "../routes"; import { GlobalContextProvider } from "../components/common/context"; import LayoutApplier from "../components/common/LayoutApplier"; +import LanguageApplier from "../components/common/LanguageApplier"; export default (props: any) => (
+
diff --git a/src/setupTests.tsx b/src/setupTests.tsx index 78150dc..cb5e536 100644 --- a/src/setupTests.tsx +++ b/src/setupTests.tsx @@ -13,6 +13,7 @@ import Adapter from "enzyme-adapter-react-16"; import "jest-enzyme"; import api from "./util/api"; import fetchMock from "fetch-mock"; +import "jest-localstorage-mock"; // Setup enzyme configure({ adapter: new Adapter() }); @@ -29,11 +30,14 @@ jest.mock("react-i18next", () => ({ } })); -// Temporary fix to reset logged in state for each test. -// The permanent fix would be to not use a global variable, and instead give this information to components through -// props. Redux would be good for this, if we want to add it to the project. beforeEach(() => { + // Temporary fix to reset logged in state for each test. + // The permanent fix would be to not use a global variable, and instead give this information to components through + // props. Redux would be good for this, if we want to add it to the project. api.loggedIn = false; + + // Clear local storage mock + localStorage.clear(); }); // Clear fetch mocks after each test