Merge pull request #1199 from prymitive/theme-detect

feat(ui): follow browser preferences when setting theme
This commit is contained in:
Łukasz Mierzwa
2019-11-29 22:28:55 +00:00
committed by GitHub
31 changed files with 589 additions and 230 deletions
+5
View File
@@ -5,6 +5,11 @@ Alert dashboard for
---
Release notes can be found on
[GitHub Release Page](https://github.com/prymitive/karma/releases).
---
Alertmanager `0.16.x` is **NOT** supported by karma due to changes
in the API, see [this issue](https://github.com/prymitive/karma/issues/115)
for details.
+1
View File
@@ -84,6 +84,7 @@ ui:
refresh: 10s
hideFiltersWhenIdle: true
colorTitlebar: false
theme: auto
minimalGroupWidth: 420
alertsPerGroup: 5
collapseGroups: collapsedOnMobile
+10 -4
View File
@@ -842,7 +842,7 @@ ui:
refresh: duration
hideFiltersWhenIdle: bool
colorTitlebar: bool
darkTheme: bool
theme: string
minimalGroupWidth: integer
alertsPerGroup: integer
collapseGroups: string
@@ -854,8 +854,14 @@ ui:
user inactivity
- `colorTitlebar` - if enabled alert group title bar color will be set to follow
alerts in that group
- `darkTheme` - if enabled dark mode will be enabled.
Note: dark mode is *experimental* and might be buggy.
- `theme` - default theme, possible values:
- `light` - bright theme
- `dark` - dark theme
- `auto` - follows browser preferences using
[prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)
media queries
Default value is `auto`.
- `minimalGroupWidth` - minimal width (in pixels) for each alert group rendered
on the grid. This value is used to calculate the number of columns rendered on
the grid.
@@ -874,7 +880,7 @@ ui:
refresh: 30s
hideFiltersWhenIdle: true
colorTitlebar: false
darkTheme: false
theme: "auto"
minimalGroupWidth: 420
alertsPerGroup: 5
collapseGroups: collapsedOnMobile
+6 -2
View File
@@ -101,7 +101,7 @@ func init() {
pflag.Duration("ui.refresh", time.Second*30, "UI refresh interval")
pflag.Bool("ui.hideFiltersWhenIdle", true, "Hide the filters bar when idle")
pflag.Bool("ui.colorTitlebar", false, "Color alert group titlebar based on alert state")
pflag.Bool("ui.darkTheme", false, "Enable dark theme")
pflag.String("ui.theme", "auto", "Default theme, 'light', 'dark' or 'auto' (follow browser preference)")
pflag.Int("ui.minimalGroupWidth", 420, "Minimal width for each alert group on the grid")
pflag.Int("ui.alertsPerGroup", 5, "Default number of alerts to show for each alert group")
pflag.String("ui.collapseGroups", "collapsedOnMobile", "Default state for alert groups")
@@ -196,7 +196,7 @@ func (config *configSchema) Read() {
config.UI.Refresh = v.GetDuration("ui.refresh")
config.UI.HideFiltersWhenIdle = v.GetBool("ui.hideFiltersWhenIdle")
config.UI.ColorTitlebar = v.GetBool("ui.colorTitlebar")
config.UI.DarkTheme = v.GetBool("ui.darkTheme")
config.UI.Theme = v.GetString("ui.theme")
config.UI.MinimalGroupWidth = v.GetInt("ui.minimalGroupWidth")
config.UI.AlertsPerGroup = v.GetInt("ui.alertsPerGroup")
config.UI.CollapseGroups = v.GetString("ui.collapseGroups")
@@ -254,6 +254,10 @@ func (config *configSchema) Read() {
log.Fatalf("Invalid ui.collapseGroups value '%s', allowed options: expanded, collapsed, collapsedOnMobile", config.UI.CollapseGroups)
}
if !slices.StringInSlice([]string{"light", "dark", "auto"}, config.UI.Theme) {
log.Fatalf("Invalid ui.theme value '%s', allowed options: light, dark, auto", config.UI.Theme)
}
// FIXME workaround for https://github.com/prymitive/karma/issues/507
// until https://github.com/spf13/viper/pull/635 is merged
// read in raw config file if it's used and override maps where keys are label
+17 -1
View File
@@ -144,7 +144,7 @@ ui:
refresh: 30s
hideFiltersWhenIdle: true
colorTitlebar: false
darkTheme: false
theme: auto
minimalGroupWidth: 420
alertsPerGroup: 5
collapseGroups: collapsedOnMobile
@@ -311,3 +311,19 @@ func TestInvalidUICollapseGroups(t *testing.T) {
t.Error("Invalid ui.collapseGroups value didn't cause log.Fatal()")
}
}
func TestInvalidUITheme(t *testing.T) {
resetEnv()
os.Setenv("UI_THEME", "foo")
log.SetLevel(log.PanicLevel)
defer func() { log.StandardLogger().ExitFunc = nil }()
var wasFatal bool
log.StandardLogger().ExitFunc = func(int) { wasFatal = true }
Config.Read()
if !wasFatal {
t.Error("Invalid ui.theme value didn't cause log.Fatal()")
}
}
+1 -1
View File
@@ -124,7 +124,7 @@ type configSchema struct {
Refresh time.Duration
HideFiltersWhenIdle bool `yaml:"hideFiltersWhenIdle" mapstructure:"hideFiltersWhenIdle"`
ColorTitlebar bool `yaml:"colorTitlebar" mapstructure:"colorTitlebar"`
DarkTheme bool `yaml:"darkTheme" mapstructure:"darkTheme"`
Theme string `yaml:"theme" mapstructure:"theme"`
MinimalGroupWidth int `yaml:"minimalGroupWidth" mapstructure:"minimalGroupWidth"`
AlertsPerGroup int `yaml:"alertsPerGroup" mapstructure:"alertsPerGroup"`
CollapseGroups string `yaml:"collapseGroups" mapstructure:"collapseGroups"`
+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);
});
@@ -5,7 +5,7 @@ exports[`<AlertGroupCollapseConfiguration /> matches snapshot with default value
<div class=\\"form-group mb-0\\">
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Collapse on mobile
</div>
@@ -7,7 +7,7 @@ exports[`<AlertGroupSortConfiguration /> matches snapshot with default values 1`
<div class=\\"flex-shrink-0 flex-grow-1 flex-basis-auto\\">
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Use defaults from karma config file
</div>
@@ -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-ct477o\\">
<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-ct477o\\">
<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>
@@ -361,7 +390,7 @@ exports[`<Configuration /> matches snapshot 1`] = `
<div class=\\"form-group mb-0\\">
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Collapse on mobile
</div>
@@ -442,7 +471,7 @@ exports[`<Configuration /> matches snapshot 1`] = `
<div class=\\"flex-shrink-0 flex-grow-1 flex-basis-auto\\">
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Use defaults from karma config file
</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-ct477o\\">
<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>
@@ -380,7 +409,7 @@ exports[`<MainModalContent /> matches snapshot 1`] = `
<div class=\\"form-group mb-0\\">
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Collapse on mobile
</div>
@@ -461,7 +490,7 @@ exports[`<MainModalContent /> matches snapshot 1`] = `
<div class=\\"flex-shrink-0 flex-grow-1 flex-basis-auto\\">
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
Use defaults from karma config file
</div>
@@ -4,7 +4,7 @@ exports[`<MultiSelect /> matches snapshot without any extra props 1`] = `
"
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container css-97xgis\\">
<div class=\\"react-select__value-container css-ct477o\\">
<div class=\\"react-select__placeholder css-1wa3eu0-placeholder\\">
Select...
</div>
@@ -65,7 +65,7 @@ exports[`<WrappedCustomMultiSelect /> matches snapshot when focused 1`] = `
</p>
</span>
<div class=\\"react-select__control react-select__control--is-focused css-11rrdhm-control\\">
<div class=\\"react-select__value-container css-97xgis\\">
<div class=\\"react-select__value-container css-ct477o\\">
<div class=\\"react-select__placeholder css-1wa3eu0-placeholder\\">
Select...
</div>
@@ -116,7 +116,7 @@ exports[`<WrappedCustomMultiSelect /> matches snapshot with a value 1`] = `
"
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
foo
</div>
@@ -167,7 +167,7 @@ exports[`<WrappedCustomMultiSelect /> matches snapshot with defaults 1`] = `
"
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container css-97xgis\\">
<div class=\\"react-select__value-container css-ct477o\\">
<div class=\\"react-select__placeholder css-1wa3eu0-placeholder\\">
Select...
</div>
@@ -218,7 +218,7 @@ exports[`<WrappedCustomMultiSelect /> matches snapshot with isDisabled=true 1`]
"
<div class=\\"react-select--is-disabled css-14jk2my-container\\">
<div class=\\"react-select__control react-select__control--is-disabled css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--has-value css-97xgis\\">
<div class=\\"react-select__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value react-select__single-value--is-disabled css-1wh03ml-singleValue\\">
foo
</div>
@@ -270,7 +270,7 @@ exports[`<WrappedCustomMultiSelect /> matches snapshot with isMulti=true 1`] = `
"
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi css-nj4yyx\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi css-puzefo\\">
<div class=\\"react-select__placeholder css-1wa3eu0-placeholder\\">
Select...
</div>
@@ -321,7 +321,7 @@ exports[`<WrappedCustomMultiSelect /> matches snapshot with isMulti=true and a v
"
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi react-select__value-container--has-value css-nj4yyx\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi react-select__value-container--has-value css-puzefo\\">
<div class=\\"css-owt1hd-multiValue react-select__multi-value\\">
<div class=\\"css-xbn6jz react-select__multi-value__label\\">
foo
@@ -4,7 +4,7 @@ exports[`<AlertManagerInput /> matches snapshot 1`] = `
"
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi react-select__value-container--has-value css-nj4yyx\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi react-select__value-container--has-value css-puzefo\\">
<div class=\\"css-owt1hd-multiValue react-select__multi-value\\">
<div class=\\"css-xbn6jz react-select__multi-value__label\\">
am1 | am2
@@ -4,7 +4,7 @@ exports[`<LabelNameInput /> matches snapshot 1`] = `
"
<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__value-container react-select__value-container--has-value css-ct477o\\">
<div class=\\"react-select__single-value css-1wh03ml-singleValue\\">
name
</div>
@@ -4,7 +4,7 @@ exports[`<LabelValueInput /> matches snapshot 1`] = `
"
<div class=\\" css-2b097c-container\\">
<div class=\\"react-select__control css-r5n82u-control\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi css-nj4yyx\\">
<div class=\\"react-select__value-container react-select__value-container--is-multi css-puzefo\\">
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
+4 -2
View File
@@ -51,7 +51,8 @@ const ReactSelectStyles = theme => ({
state.isMulti
? {
...base,
borderRadius: 0,
borderTopLeftRadius: "0.25rem",
borderBottomLeftRadius: "0.25rem",
backgroundColor: state.isDisabled
? theme.disabledValueContainerBackground
: theme.valueContainerBackground,
@@ -65,7 +66,8 @@ const ReactSelectStyles = theme => ({
}
: {
...base,
borderRadius: 0,
borderTopLeftRadius: "0.25rem",
borderBottomLeftRadius: "0.25rem",
backgroundColor: state.isDisabled
? theme.disabledValueContainerBackground
: theme.valueContainerBackground
+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 => {