feat(ui): follow browser preferences when setting theme

This commit is contained in:
Łukasz Mierzwa
2019-11-29 22:00:41 +00:00
parent 4cc15f10d1
commit cc4da6d16b
18 changed files with 529 additions and 204 deletions
+24
View File
@@ -11390,6 +11390,14 @@
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"json2mq": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
"integrity": "sha1-tje9O6nqvhIsg+lyBIOusQ0skEo=",
"requires": {
"string-convert": "^0.2.0"
}
},
"json3": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz",
@@ -15348,6 +15356,17 @@
"react-infinite-scroller": "^1.0.12"
}
},
"react-media": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/react-media/-/react-media-1.10.0.tgz",
"integrity": "sha512-FjgYmFoaPTImST06jqotuu0Mk8LOXiGYS/fIyiXuLnf20l3DPniBwtrxi604/HxxjqvmHS3oz5rAwnqdvosV4A==",
"requires": {
"@babel/runtime": "^7.2.0",
"invariant": "^2.2.2",
"json2mq": "^0.2.0",
"prop-types": "^15.5.10"
}
},
"react-moment": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-0.9.6.tgz",
@@ -17527,6 +17546,11 @@
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
},
"string-convert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
"integrity": "sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c="
},
"string-length": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz",
+1
View File
@@ -44,6 +44,7 @@
"react-json-pretty": "2.2.0",
"react-linkify": "0.2.2",
"react-masonry-infinite": "1.2.2",
"react-media": "1.10.0",
"react-moment": "0.9.6",
"react-onclickoutside": "6.9.0",
"react-popper": "1.3.6",
+124 -27
View File
@@ -2,6 +2,7 @@ import React from "react";
import { shallow, mount } from "enzyme";
import { mockMatchMedia } from "__mocks__/matchMedia";
import { NewUnappliedFilter } from "Stores/AlertStore";
import { App } from "./App";
@@ -19,14 +20,14 @@ beforeEach(() => {
// ensure it's wiped after each test
window.history.pushState({}, "App", "/");
document.body.className = "";
// matchMedia needs mocking
window.matchMedia = mockMatchMedia({});
});
afterEach(() => {
localStorage.setItem("savedFilters", "");
jest.restoreAllMocks();
window.history.pushState({}, "App", "/");
document.body.className = "";
});
describe("<App />", () => {
@@ -157,44 +158,140 @@ describe("<App />", () => {
let event = new PopStateEvent("popstate");
window.onpopstate(event);
});
});
it("appends correct theme class to #root if dark mode is disabled", () => {
const tree = shallow(
describe("<App /> theme", () => {
const getApp = theme =>
mount(
<App
defaultFilters={["foo=bar"]}
uiDefaults={Object.assign({}, uiDefaults, { DarkMode: false })}
uiDefaults={Object.assign({}, uiDefaults, { Theme: theme })}
/>
);
it("configures light theme when uiDefaults passes it", () => {
const tree = getApp("light");
expect(tree.instance().settingsStore.themeConfig.config.theme).toBe(
"light"
);
tree.instance().componentWillUnmount();
});
expect(document.body.className.split(" ")).toContain("theme-light");
it("configures dark theme when uiDefaults passes it", () => {
const tree = getApp("dark");
expect(tree.instance().settingsStore.themeConfig.config.theme).toBe("dark");
tree.instance().componentWillUnmount();
});
it("appends 'theme-dark' class to #root if dark mode is enabled", () => {
const tree = shallow(
<App
defaultFilters={["foo=bar"]}
uiDefaults={Object.assign({}, uiDefaults, { DarkMode: true })}
/>
);
it("configures automatic theme when uiDefaults passes it", () => {
const tree = getApp("auto");
expect(tree.instance().settingsStore.themeConfig.config.theme).toBe("auto");
tree.instance().componentWillUnmount();
});
it("configures automatic theme when uiDefaults doesn't pass any value", () => {
const tree = mount(<App defaultFilters={["foo=bar"]} uiDefaults={null} />);
expect(tree.instance().settingsStore.themeConfig.config.theme).toBe("auto");
tree.instance().componentWillUnmount();
});
expect(document.body.className.split(" ")).toContain("theme-dark");
it("applies light theme when theme=auto and browser doesn't support prefers-color-scheme", () => {
window.matchMedia = mockMatchMedia({});
const tree = getApp("auto");
expect(tree.find("LightTheme")).toHaveLength(1);
tree.instance().componentWillUnmount();
});
it("toggling settingsStore.themeConfig.config.darkTheme modifies the theme", () => {
const tree = mount(
<App
defaultFilters={["foo=bar"]}
uiDefaults={Object.assign({}, uiDefaults, { DarkMode: false })}
/>
);
tree.update();
expect(document.body.className.split(" ")).toContain("theme-light");
const lightMatch = () => ({
"(prefers-color-scheme)": {
media: "(prefers-color-scheme)",
matches: true
},
"(prefers-color-scheme: light)": {
media: "(prefers-color-scheme: light)",
matches: true
},
"(prefers-color-scheme: dark)": {
media: "(prefers-color-scheme: dark)",
matches: false
}
});
tree.instance().settingsStore.themeConfig.config.darkTheme = true;
tree.update();
expect(document.body.className.split(" ")).toContain("theme-dark");
tree.instance().componentWillUnmount();
const darkMatch = () => ({
"(prefers-color-scheme)": {
media: "(prefers-color-scheme)",
matches: true
},
"(prefers-color-scheme: light)": {
media: "(prefers-color-scheme: light)",
matches: false
},
"(prefers-color-scheme: dark)": {
media: "(prefers-color-scheme: dark)",
matches: true
}
});
const testCases = [
{
name:
"applies LightTheme when config=auto and browser doesn't support prefers-color-scheme",
settings: "auto",
matchMedia: {},
theme: "LightTheme"
},
{
name:
"applies LightTheme when config=auto and browser prefers-color-scheme:light matches",
settings: "auto",
matchMedia: lightMatch(),
theme: "LightTheme"
},
{
name:
"applies DarkTheme when config=auto and browser prefers-color-scheme:dark matches",
settings: "auto",
matchMedia: darkMatch(),
theme: "DarkTheme"
},
{
name:
"applies LightTheme when config=light and browser doesn't support prefers-color-scheme",
settings: "light",
matchMedia: {},
theme: "LightTheme"
},
{
name:
"applies LightTheme when config=light and browser prefers-color-scheme:light matches",
settings: "light",
matchMedia: lightMatch(),
theme: "LightTheme"
},
{
name:
"applies DarkTheme when config=dark and browser doesn't support prefers-color-scheme",
settings: "dark",
matchMedia: {},
theme: "DarkTheme"
},
{
name:
"applies DarkTheme when config=dark and browser prefers-color-scheme:dark matches",
settings: "dark",
matchMedia: darkMatch(),
theme: "DarkTheme"
}
];
for (const testCase of testCases) {
it(testCase.name, () => {
window.matchMedia = mockMatchMedia(testCase.matchMedia);
const tree = getApp(testCase.settings);
expect(tree.find(testCase.theme)).toHaveLength(1);
tree.instance().componentWillUnmount();
window.matchMedia.mockRestore();
});
}
});
+50 -33
View File
@@ -2,6 +2,8 @@ import React, { Component } from "react";
import { observer } from "mobx-react";
import Media from "react-media";
import { AlertStore, DecodeLocationSearch } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
@@ -11,7 +13,7 @@ import {
ReactSelectColors,
ReactSelectStyles
} from "Components/Theme/ReactSelect";
import { Theme, ThemeContext } from "Components/Theme";
import { BodyTheme, ThemeContext } from "Components/Theme";
import { ErrorBoundary } from "./ErrorBoundary";
import "Styles/ResetCSS.scss";
@@ -59,8 +61,6 @@ const App = observer(
this.silenceFormStore = new SilenceFormStore();
this.settingsStore = new Settings(uiDefaults);
this.state = { darkTheme: false };
let filters;
// parse and decode request query args
@@ -90,15 +90,6 @@ const App = observer(
componentDidMount() {
window.onpopstate = this.onPopState;
document.body.classList.toggle(
"theme-dark",
this.settingsStore.themeConfig.config.darkTheme
);
document.body.classList.toggle(
"theme-light",
!this.settingsStore.themeConfig.config.darkTheme
);
}
componentWillUnmount() {
@@ -108,29 +99,55 @@ const App = observer(
render() {
return (
<ErrorBoundary>
<Theme settingsStore={this.settingsStore} />
<ThemeContext.Provider
value={{
reactSelectStyles: this.settingsStore.themeConfig.config.darkTheme
? ReactSelectStyles(ReactSelectColors.Dark)
: ReactSelectStyles(ReactSelectColors.Light)
<span data-theme={`${this.settingsStore.themeConfig.config.theme}`} />
<Media
queries={{
isSupported: "(prefers-color-scheme)",
light: "(prefers-color-scheme: light)",
dark: "(prefers-color-scheme: dark)"
}}
>
<React.Suspense fallback={null}>
<NavBar
alertStore={this.alertStore}
settingsStore={this.settingsStore}
silenceFormStore={this.silenceFormStore}
/>
</React.Suspense>
<React.Suspense fallback={null}>
<Grid
alertStore={this.alertStore}
settingsStore={this.settingsStore}
silenceFormStore={this.silenceFormStore}
/>
</React.Suspense>
</ThemeContext.Provider>
{matches => (
<ThemeContext.Provider
value={{
isDark:
this.settingsStore.themeConfig.config.theme ===
this.settingsStore.themeConfig.options.auto.value &&
matches.isSupported
? matches.dark
: this.settingsStore.themeConfig.config.theme ===
this.settingsStore.themeConfig.options.dark.value,
reactSelectStyles:
this.settingsStore.themeConfig.config.theme ===
this.settingsStore.themeConfig.options.auto.value &&
matches.isSupported
? matches.dark
? ReactSelectStyles(ReactSelectColors.Dark)
: ReactSelectStyles(ReactSelectColors.Light)
: this.settingsStore.themeConfig.config.theme ===
this.settingsStore.themeConfig.options.dark.value
? ReactSelectStyles(ReactSelectColors.Dark)
: ReactSelectStyles(ReactSelectColors.Light)
}}
>
<BodyTheme />
<React.Suspense fallback={null}>
<NavBar
alertStore={this.alertStore}
settingsStore={this.settingsStore}
silenceFormStore={this.silenceFormStore}
/>
</React.Suspense>
<React.Suspense fallback={null}>
<Grid
alertStore={this.alertStore}
settingsStore={this.settingsStore}
silenceFormStore={this.silenceFormStore}
/>
</React.Suspense>
</ThemeContext.Provider>
)}
</Media>
<FaviconBadge alertStore={this.alertStore} />
<Fetcher
alertStore={this.alertStore}
@@ -22,7 +22,7 @@ const AlertGroupTitleBarColor = observer(
const { settingsStore } = this.props;
return (
<div className="form-group mb-2">
<div className="form-group mb-0">
<div className="form-check form-check-inline">
<span className="custom-control custom-switch">
<input
@@ -4,54 +4,68 @@ import PropTypes from "prop-types";
import { action } from "mobx";
import { observer } from "mobx-react";
import Select from "react-select";
import { Settings } from "Stores/Settings";
import { ThemeContext } from "Components/Theme";
const ThemeConfiguration = observer(
class ThemeConfiguration extends Component {
static propTypes = {
settingsStore: PropTypes.instanceOf(Settings).isRequired
};
static contextType = ThemeContext;
onChange = action(event => {
constructor(props) {
super(props);
this.validateConfig();
}
valueToOption = val => {
const { settingsStore } = this.props;
settingsStore.themeConfig.config.darkTheme = event.target.checked;
document.body.classList.toggle(
"theme-dark",
settingsStore.themeConfig.config.darkTheme
);
document.body.classList.toggle(
"theme-light",
!settingsStore.themeConfig.config.darkTheme
);
return {
label: settingsStore.themeConfig.options[val].label,
value: val
};
};
validateConfig = action(() => {
const { settingsStore } = this.props;
if (
!Object.values(settingsStore.themeConfig.options)
.map(o => o.value)
.includes(settingsStore.themeConfig.config.theme)
) {
settingsStore.themeConfig.config.theme =
settingsStore.themeConfig.options.auto.value;
}
});
onCollapseChange = action((newValue, actionMeta) => {
const { settingsStore } = this.props;
settingsStore.themeConfig.config.theme = newValue.value;
});
render() {
const { settingsStore } = this.props;
return (
<div className="form-group mb-0">
<div className="form-check form-check-inline">
<span className="custom-control custom-switch">
<input
id="configuration-theme"
className="custom-control-input"
type="checkbox"
value=""
checked={settingsStore.themeConfig.config.darkTheme || false}
onChange={this.onChange}
/>
<label
className="custom-control-label cursor-pointer mr-3"
htmlFor="configuration-theme"
>
Enable dark mode
</label>
<span className="ml-5 badge badge-danger align-text-bottom">
Experimental
</span>
</span>
</div>
<div className="form-group mb-2">
<Select
styles={this.context.reactSelectStyles}
classNamePrefix="react-select"
instanceId="configuration-theme"
defaultValue={this.valueToOption(
settingsStore.themeConfig.config.theme
)}
options={Object.values(settingsStore.themeConfig.options)}
onChange={this.onCollapseChange}
hideSelectedOptions
/>
</div>
);
}
@@ -5,6 +5,11 @@ import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { Settings } from "Stores/Settings";
import { ThemeContext } from "Components/Theme";
import {
ReactSelectColors,
ReactSelectStyles
} from "Components/Theme/ReactSelect";
import { ThemeConfiguration } from "./ThemeConfiguration";
let settingsStore;
@@ -13,7 +18,15 @@ beforeEach(() => {
});
const FakeConfiguration = () => {
return mount(<ThemeConfiguration settingsStore={settingsStore} />);
return mount(
<ThemeContext.Provider
value={{
reactSelectStyles: ReactSelectStyles(ReactSelectColors.Light)
}}
>
<ThemeConfiguration settingsStore={settingsStore} />
</ThemeContext.Provider>
);
};
describe("<ThemeConfiguration />", () => {
@@ -22,32 +35,41 @@ describe("<ThemeConfiguration />", () => {
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("darkTheme is 'false' by default", () => {
expect(settingsStore.themeConfig.config.darkTheme).toBe(false);
});
it("unchecking the checkbox sets stored darkTheme value to 'false'", done => {
it("resets stored config to defaults if it is invalid", done => {
settingsStore.themeConfig.config.theme = "foo";
const tree = FakeConfiguration();
const checkbox = tree.find("#configuration-theme");
settingsStore.themeConfig.config.darkTheme = true;
expect(settingsStore.themeConfig.config.darkTheme).toBe(true);
checkbox.simulate("change", { target: { checked: false } });
const select = tree.find("div.react-select__value-container");
expect(select.text()).toBe(settingsStore.themeConfig.options.auto.label);
setTimeout(() => {
expect(settingsStore.themeConfig.config.darkTheme).toBe(false);
expect(settingsStore.themeConfig.config.theme).toBe(
settingsStore.themeConfig.options.auto.value
);
done();
}, 200);
});
it("checking the checkbox sets stored darkTheme value to 'true'", done => {
it("rendered correct default value", done => {
settingsStore.themeConfig.config.theme =
settingsStore.themeConfig.options.auto.value;
const tree = FakeConfiguration();
const checkbox = tree.find("#configuration-theme");
settingsStore.themeConfig.config.darkTheme = false;
expect(settingsStore.themeConfig.config.darkTheme).toBe(false);
checkbox.simulate("change", { target: { checked: true } });
const select = tree.find("div.react-select__value-container");
setTimeout(() => {
expect(settingsStore.themeConfig.config.darkTheme).toBe(true);
expect(select.text()).toBe(settingsStore.themeConfig.options.auto.label);
done();
}, 200);
});
it("clicking on a label option updates settingsStore", done => {
const tree = FakeConfiguration();
tree
.find("input#react-select-configuration-theme-input")
.simulate("change", { target: { value: " " } });
const options = tree.find("div.react-select__option");
options.at(1).simulate("click");
setTimeout(() => {
expect(settingsStore.themeConfig.config.theme).toBe(
settingsStore.themeConfig.options.dark.value
);
done();
}, 200);
});
@@ -2,7 +2,7 @@
exports[`<AlertGroupTitleBarColor /> matches snapshot with default values 1`] = `
"
<div class=\\"form-group mb-2\\">
<div class=\\"form-group mb-0\\">
<div class=\\"form-check form-check-inline\\">
<span class=\\"custom-control custom-switch\\">
<input id=\\"configuration-colortitlebar\\"
@@ -2,23 +2,52 @@
exports[`<ThemeConfiguration /> matches snapshot with default values 1`] = `
"
<div class=\\"form-group mb-0\\">
<div class=\\"form-check form-check-inline\\">
<span class=\\"custom-control custom-switch\\">
<input id=\\"configuration-theme\\"
class=\\"custom-control-input\\"
type=\\"checkbox\\"
value
>
<label class=\\"custom-control-label cursor-pointer mr-3\\"
for=\\"configuration-theme\\"
>
Enable dark mode
</label>
<span class=\\"ml-5 badge badge-danger align-text-bottom\\">
Experimental
</span>
</span>
<div class=\\"form-group mb-2\\">
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--has-value css-97xgis\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Automatic theme, follow browser preferences
</div>
<div class=\\"css-b8ldur-Input\\">
<div class=\\"react-select__input\\"
style=\\"display: inline-block;\\"
>
<input autocapitalize=\\"none\\"
autocomplete=\\"off\\"
autocorrect=\\"off\\"
id=\\"react-select-configuration-theme-input\\"
spellcheck=\\"false\\"
tabindex=\\"0\\"
type=\\"text\\"
aria-autocomplete=\\"list\\"
style=\\"box-sizing: content-box; width: 2px; border: 0px; font-size: inherit; opacity: 1; outline: 0; padding: 0px;\\"
value
>
<div style=\\"position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-size: inherit; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;\\">
</div>
</div>
</div>
</div>
<div class=\\"react-select__indicators css-vcwr3k-IndicatorsContainer\\">
<span class=\\"react-select__indicator-separator css-1okebmr-indicatorSeparator\\">
</span>
<div aria-hidden=\\"true\\"
class=\\"react-select__indicator react-select__dropdown-indicator css-tlfecz-indicatorContainer\\"
>
<svg height=\\"20\\"
width=\\"20\\"
viewbox=\\"0 0 20 20\\"
aria-hidden=\\"true\\"
focusable=\\"false\\"
class=\\"css-6q0nyr-Svg\\"
>
<path d=\\"M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z\\">
</path>
</svg>
</div>
</div>
</div>
</div>
</div>
"
@@ -151,6 +151,54 @@ exports[`<Configuration /> matches snapshot 1`] = `
>
<div class=\\"Collapsible__contentInner card-body my-2\\">
<div class=\\"form-group mb-2\\">
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--has-value css-97xgis\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Automatic theme, follow browser preferences
</div>
<div class=\\"css-b8ldur-Input\\">
<div class=\\"react-select__input\\"
style=\\"display: inline-block;\\"
>
<input autocapitalize=\\"none\\"
autocomplete=\\"off\\"
autocorrect=\\"off\\"
id=\\"react-select-configuration-theme-input\\"
spellcheck=\\"false\\"
tabindex=\\"0\\"
type=\\"text\\"
aria-autocomplete=\\"list\\"
style=\\"box-sizing: content-box; width: 2px; border: 0px; font-size: inherit; opacity: 1; outline: 0; padding: 0px;\\"
value
>
<div style=\\"position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-size: inherit; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;\\">
</div>
</div>
</div>
</div>
<div class=\\"react-select__indicators css-vcwr3k-IndicatorsContainer\\">
<span class=\\"react-select__indicator-separator css-1okebmr-indicatorSeparator\\">
</span>
<div aria-hidden=\\"true\\"
class=\\"react-select__indicator react-select__dropdown-indicator css-tlfecz-indicatorContainer\\"
>
<svg height=\\"20\\"
width=\\"20\\"
viewbox=\\"0 0 20 20\\"
aria-hidden=\\"true\\"
focusable=\\"false\\"
class=\\"css-6q0nyr-Svg\\"
>
<path d=\\"M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z\\">
</path>
</svg>
</div>
</div>
</div>
</div>
</div>
<div class=\\"form-group mb-0\\">
<div class=\\"form-check form-check-inline\\">
<span class=\\"custom-control custom-switch\\">
<input id=\\"configuration-colortitlebar\\"
@@ -166,25 +214,6 @@ exports[`<Configuration /> matches snapshot 1`] = `
</span>
</div>
</div>
<div class=\\"form-group mb-0\\">
<div class=\\"form-check form-check-inline\\">
<span class=\\"custom-control custom-switch\\">
<input id=\\"configuration-theme\\"
class=\\"custom-control-input\\"
type=\\"checkbox\\"
value
>
<label class=\\"custom-control-label cursor-pointer mr-3\\"
for=\\"configuration-theme\\"
>
Enable dark mode
</label>
<span class=\\"ml-5 badge badge-danger align-text-bottom\\">
Experimental
</span>
</span>
</div>
</div>
</div>
</div>
</div>
@@ -28,8 +28,8 @@ const Configuration = ({ settingsStore, defaultIsOpen }) => (
text="Theme"
content={
<React.Fragment>
<AlertGroupTitleBarColor settingsStore={settingsStore} />
<ThemeConfiguration settingsStore={settingsStore} />
<AlertGroupTitleBarColor settingsStore={settingsStore} />
</React.Fragment>
}
extraProps={{ open: defaultIsOpen }}
@@ -170,6 +170,54 @@ exports[`<MainModalContent /> matches snapshot 1`] = `
>
<div class=\\"Collapsible__contentInner card-body my-2\\">
<div class=\\"form-group mb-2\\">
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--has-value css-97xgis\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Automatic theme, follow browser preferences
</div>
<div class=\\"css-b8ldur-Input\\">
<div class=\\"react-select__input\\"
style=\\"display: inline-block;\\"
>
<input autocapitalize=\\"none\\"
autocomplete=\\"off\\"
autocorrect=\\"off\\"
id=\\"react-select-configuration-theme-input\\"
spellcheck=\\"false\\"
tabindex=\\"0\\"
type=\\"text\\"
aria-autocomplete=\\"list\\"
style=\\"box-sizing: content-box; width: 2px; border: 0px; font-size: inherit; opacity: 1; outline: 0; padding: 0px;\\"
value
>
<div style=\\"position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-size: inherit; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;\\">
</div>
</div>
</div>
</div>
<div class=\\"react-select__indicators css-vcwr3k-IndicatorsContainer\\">
<span class=\\"react-select__indicator-separator css-1okebmr-indicatorSeparator\\">
</span>
<div aria-hidden=\\"true\\"
class=\\"react-select__indicator react-select__dropdown-indicator css-tlfecz-indicatorContainer\\"
>
<svg height=\\"20\\"
width=\\"20\\"
viewbox=\\"0 0 20 20\\"
aria-hidden=\\"true\\"
focusable=\\"false\\"
class=\\"css-6q0nyr-Svg\\"
>
<path d=\\"M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z\\">
</path>
</svg>
</div>
</div>
</div>
</div>
</div>
<div class=\\"form-group mb-0\\">
<div class=\\"form-check form-check-inline\\">
<span class=\\"custom-control custom-switch\\">
<input id=\\"configuration-colortitlebar\\"
@@ -185,25 +233,6 @@ exports[`<MainModalContent /> matches snapshot 1`] = `
</span>
</div>
</div>
<div class=\\"form-group mb-0\\">
<div class=\\"form-check form-check-inline\\">
<span class=\\"custom-control custom-switch\\">
<input id=\\"configuration-theme\\"
class=\\"custom-control-input\\"
type=\\"checkbox\\"
value
>
<label class=\\"custom-control-label cursor-pointer mr-3\\"
for=\\"configuration-theme\\"
>
Enable dark mode
</label>
<span class=\\"ml-5 badge badge-danger align-text-bottom\\">
Experimental
</span>
</span>
</div>
</div>
</div>
</div>
</div>
+26 -14
View File
@@ -1,8 +1,6 @@
import React from "react";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import { observer } from "mobx-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
@@ -41,16 +39,30 @@ const Placeholder = () => {
);
};
const Theme = observer(({ settingsStore }) => (
<React.Suspense fallback={<Placeholder />}>
{settingsStore.themeConfig.config.darkTheme ? (
<DarkTheme />
) : (
<LightTheme />
)}
</React.Suspense>
));
const ThemeContext = React.createContext();
export { Theme, ThemeContext };
class BodyTheme extends Component {
onToggleBodyClass = isDark => {
document.body.classList.toggle("theme-dark", isDark);
document.body.classList.toggle("theme-light", !isDark);
};
componentDidMount() {
this.onToggleBodyClass(this.context.isDark);
}
componentDidUpdate() {
this.onToggleBodyClass(this.context.isDark);
}
render() {
return (
<React.Suspense fallback={<Placeholder />}>
{this.context.isDark ? <DarkTheme /> : <LightTheme />}
</React.Suspense>
);
}
}
BodyTheme.contextType = ThemeContext;
export { BodyTheme, ThemeContext };
+33 -15
View File
@@ -1,26 +1,44 @@
import React from "react";
import * as React from "react";
import { mount } from "enzyme";
import { Settings } from "Stores/Settings";
import { Theme } from ".";
let settingsStore;
import { BodyTheme, ThemeContext } from ".";
beforeEach(() => {
settingsStore = new Settings();
document.body.classList.remove("theme-light");
document.body.classList.remove("theme-dark");
});
describe("<Theme />", () => {
it("renders DarkTheme when settingsStore.themeConfig.config.darkTheme is true", () => {
settingsStore.themeConfig.config.darkTheme = true;
const tree = mount(<Theme settingsStore={settingsStore} />);
expect(tree.text()).toBe("");
describe("<BodyTheme />", () => {
it("uses light theme when ThemeContext->isDark is false", () => {
mount(<BodyTheme />, {
wrappingComponent: ThemeContext.Provider,
wrappingComponentProps: { value: { isDark: false } }
});
expect(document.body.classList.contains("theme-light")).toEqual(true);
});
it("renders LightTheme when settingsStore.themeConfig.config.darkTheme is false", () => {
settingsStore.themeConfig.config.darkTheme = false;
const tree = mount(<Theme settingsStore={settingsStore} />);
expect(tree.text()).toBe("");
it("uses dark theme when ThemeContext->isDark is true", () => {
mount(<BodyTheme />, {
wrappingComponent: ThemeContext.Provider,
wrappingComponentProps: { value: { isDark: true } }
});
expect(document.body.classList.contains("theme-dark")).toEqual(true);
});
it("updates theme when ThemeContext->isDark is updated", () => {
const tree = mount(<BodyTheme />, {
wrappingComponent: ThemeContext.Provider,
wrappingComponentProps: { value: { isDark: true } }
});
expect(document.body.classList.contains("theme-dark")).toEqual(true);
document.body.classList.remove("theme-light");
document.body.classList.remove("theme-dark");
const provider = tree.getWrappingComponent();
provider.setProps({ value: { isDark: false } });
expect(document.body.classList.contains("theme-light")).toEqual(true);
});
});
+12 -4
View File
@@ -111,11 +111,19 @@ class FilterBarConfig {
}
class ThemeConfig {
constructor(darkTheme) {
options = Object.freeze({
auto: {
label: "Automatic theme, follow browser preferences",
value: "auto"
},
light: { label: "Light theme", value: "light" },
dark: { label: "Dark theme", value: "dark" }
});
constructor(defaultTheme) {
this.config = localStored(
"themeConfig",
{
darkTheme: darkTheme
theme: defaultTheme
},
{
delay: 100
@@ -132,7 +140,7 @@ class Settings {
Refresh: 30 * 1000 * 1000 * 1000,
HideFiltersWhenIdle: true,
ColorTitlebar: false,
DarkTheme: false,
Theme: "auto",
MinimalGroupWidth: 420,
AlertsPerGroup: 5,
CollapseGroups: "collapsedOnMobile"
@@ -155,7 +163,7 @@ class Settings {
this.filterBarConfig = new FilterBarConfig(
defaultSettings.HideFiltersWhenIdle
);
this.themeConfig = new ThemeConfig(defaultSettings.DarkTheme);
this.themeConfig = new ThemeConfig(defaultSettings.Theme);
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
const DefaultsBase64 =
"eyJSZWZyZXNoIjo0NTAwMDAwMDAwMCwiSGlkZUZpbHRlcnNXaGVuSWRsZSI6ZmFsc2UsIkNvbG9yVGl0bGViYXIiOmZhbHNlLCJEYXJrTW9kZSI6ZmFsc2UsIk1pbmltYWxHcm91cFdpZHRoIjo1NTUsIkFsZXJ0c1Blckdyb3VwIjoxNSwiQ29sbGFwc2VHcm91cHMiOiJleHBhbmRlZCJ9Cg==";
"eyJSZWZyZXNoIjo0NTAwMDAwMDAwMCwiSGlkZUZpbHRlcnNXaGVuSWRsZSI6ZmFsc2UsIkNvbG9yVGl0bGViYXIiOmZhbHNlLCJUaGVtZSI6ImxpZ2h0IiwiTWluaW1hbEdyb3VwV2lkdGgiOjU1NSwiQWxlcnRzUGVyR3JvdXAiOjE1LCJDb2xsYXBzZUdyb3VwcyI6ImV4cGFuZGVkIn0K==";
const DefaultsObject = {
Refresh: 45000000000,
HideFiltersWhenIdle: false,
ColorTitlebar: false,
DarkMode: false,
Theme: "light",
MinimalGroupWidth: 555,
AlertsPerGroup: 15,
CollapseGroups: "expanded"
+16
View File
@@ -0,0 +1,16 @@
const mockMatchMedia = mapOfMedia => {
return jest.fn().mockImplementation(query => {
return {
matches: mapOfMedia[query] ? mapOfMedia[query].matches : false,
media: mapOfMedia[query] ? mapOfMedia[query].media : "not all",
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn()
};
});
};
export { mockMatchMedia };
+9
View File
@@ -1,5 +1,6 @@
import { EmptyAPIResponse } from "__mocks__/Fetch";
import { DefaultsBase64 } from "__mocks__/Defaults";
import { mockMatchMedia } from "__mocks__/matchMedia";
const settingsElement = {
dataset: {
@@ -9,6 +10,14 @@ const settingsElement = {
}
};
beforeEach(() => {
window.matchMedia = mockMatchMedia({});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders without crashing with missing defaults div", () => {
const root = document.createElement("div");
jest.spyOn(global.document, "getElementById").mockImplementation(name => {