diff --git a/ui/package-lock.json b/ui/package-lock.json index dd343b584..a0897e8ca 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 331faa928..98bf176e9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/App.test.js b/ui/src/App.test.js index 4e6d4467c..89c6dc648 100644 --- a/ui/src/App.test.js +++ b/ui/src/App.test.js @@ -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("", () => { @@ -157,44 +158,140 @@ describe("", () => { let event = new PopStateEvent("popstate"); window.onpopstate(event); }); +}); - it("appends correct theme class to #root if dark mode is disabled", () => { - const tree = shallow( +describe(" theme", () => { + const getApp = theme => + mount( ); + + 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( - - ); + 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(); + 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( - - ); - 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(); + }); + } }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a560c3cd5..c6ae949d3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ( - - + - - - - - - - + {matches => ( + + + + + + + + + + )} + +
{ + 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 ( -
-
- - - - - Experimental - - -
+
+ matches snapshot with default values 1`] = ` " -
-
- - - - - Experimental - - +
+
+
+
+
+ Automatic theme, follow browser preferences +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + +
+
+
" diff --git a/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap b/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap index d69874e53..d49f218b2 100644 --- a/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap +++ b/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap @@ -151,6 +151,54 @@ exports[` matches snapshot 1`] = ` >
+
+
+
+
+ Automatic theme, follow browser preferences +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
matches snapshot 1`] = `
-
-
- - - - - Experimental - - -
-
diff --git a/ui/src/Components/MainModal/Configuration/index.js b/ui/src/Components/MainModal/Configuration/index.js index c322b9f8a..20777dc26 100644 --- a/ui/src/Components/MainModal/Configuration/index.js +++ b/ui/src/Components/MainModal/Configuration/index.js @@ -28,8 +28,8 @@ const Configuration = ({ settingsStore, defaultIsOpen }) => ( text="Theme" content={ - + } extraProps={{ open: defaultIsOpen }} diff --git a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap index 5457dcbf3..e8c486edd 100644 --- a/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap +++ b/ui/src/Components/MainModal/__snapshots__/MainModalContent.test.js.snap @@ -170,6 +170,54 @@ exports[` matches snapshot 1`] = ` >
+
+
+
+
+ Automatic theme, follow browser preferences +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + +
+
+
+
+
+
matches snapshot 1`] = `
-
-
- - - - - Experimental - - -
-
diff --git a/ui/src/Components/Theme/index.js b/ui/src/Components/Theme/index.js index 769f543e1..2bde87d9d 100644 --- a/ui/src/Components/Theme/index.js +++ b/ui/src/Components/Theme/index.js @@ -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 }) => ( - }> - {settingsStore.themeConfig.config.darkTheme ? ( - - ) : ( - - )} - -)); - 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 ( + }> + {this.context.isDark ? : } + + ); + } +} +BodyTheme.contextType = ThemeContext; + +export { BodyTheme, ThemeContext }; diff --git a/ui/src/Components/Theme/index.test.js b/ui/src/Components/Theme/index.test.js index 00157e7bf..7a83ed401 100644 --- a/ui/src/Components/Theme/index.test.js +++ b/ui/src/Components/Theme/index.test.js @@ -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("", () => { - it("renders DarkTheme when settingsStore.themeConfig.config.darkTheme is true", () => { - settingsStore.themeConfig.config.darkTheme = true; - const tree = mount(); - expect(tree.text()).toBe(""); +describe("", () => { + it("uses light theme when ThemeContext->isDark is false", () => { + mount(, { + 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(); - expect(tree.text()).toBe(""); + it("uses dark theme when ThemeContext->isDark is true", () => { + mount(, { + 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(, { + 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); }); }); diff --git a/ui/src/Stores/Settings.js b/ui/src/Stores/Settings.js index 4a714617c..520bd841e 100644 --- a/ui/src/Stores/Settings.js +++ b/ui/src/Stores/Settings.js @@ -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); } } diff --git a/ui/src/__mocks__/Defaults.js b/ui/src/__mocks__/Defaults.js index 381458357..333074b16 100644 --- a/ui/src/__mocks__/Defaults.js +++ b/ui/src/__mocks__/Defaults.js @@ -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" diff --git a/ui/src/__mocks__/matchMedia.js b/ui/src/__mocks__/matchMedia.js new file mode 100644 index 000000000..f2a0ddab4 --- /dev/null +++ b/ui/src/__mocks__/matchMedia.js @@ -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 }; diff --git a/ui/src/index.test.js b/ui/src/index.test.js index a836de339..fd56ccb24 100644 --- a/ui/src/index.test.js +++ b/ui/src/index.test.js @@ -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 => {