Merge pull request #157 from pi-hole/feature/cache-web-preferences

Cache the web interface preferences in local storage
This commit is contained in:
Mark Drobnak
2019-03-02 13:22:23 -05:00
committed by GitHub
8 changed files with 167 additions and 29 deletions
+6
View File
@@ -11629,6 +11629,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",
+1
View File
@@ -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",
+33
View File
@@ -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 () => (
<PreferencesContext.Consumer>
{({ settings }) => {
applyLanguage(settings.language);
return null;
}}
</PreferencesContext.Consumer>
);
@@ -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);
});
+43 -6
View File
@@ -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}
</PreferencesContext.Provider>
)}
renderOk={(settings, refresh) => (
<PreferencesContext.Provider value={{ settings, refresh }} {...props}>
{children}
</PreferencesContext.Provider>
)}
renderOk={(settings, refresh) => {
// Update the cached settings
localStorage.setItem(
WEB_PREFERENCES_STORAGE_KEY,
JSON.stringify(settings)
);
return (
<PreferencesContext.Provider value={{ settings, refresh }} {...props}>
{children}
</PreferencesContext.Provider>
);
}}
renderErr={(_, refresh) => (
<PreferencesContext.Provider
value={{ settings: { layout: "boxed", language: "en" }, refresh }}
+38 -20
View File
@@ -16,7 +16,6 @@ import Alert, { AlertType } from "../common/Alert";
import { Button, Col, Form, FormGroup, Input, Label } from "reactstrap";
import { PreferencesContext } from "../common/context";
import languages from "../../languages.json";
import i18n from "i18next";
export interface PreferenceSettingsProps {
settings: ApiPreferences;
@@ -28,6 +27,8 @@ export interface PreferenceSettingsState {
alertType: AlertType;
showAlert: boolean;
processing: boolean;
translateMessage: boolean;
error: { key: string; data: any } | null;
settings: ApiPreferences;
}
@@ -40,6 +41,8 @@ class PreferenceSettings extends Component<
alertType: "info",
showAlert: false,
processing: false,
translateMessage: true,
error: null,
// Initial value is the current settings
settings: this.props.settings
};
@@ -100,13 +103,13 @@ class PreferenceSettings extends Component<
saveSettings = (e: FormEvent) => {
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 ? (
<Alert
message={this.state.alertMessage}
message={this.getAlertMessage()}
type={this.state.alertType}
onClick={this.hideAlert}
/>
+2
View File
@@ -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) => (
<div className="app">
<GlobalContextProvider>
<LayoutApplier />
<LanguageApplier />
<Header />
<div className="app-body">
<Sidebar items={nav} {...props} />
+7 -3
View File
@@ -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