diff --git a/ui/.storybook/config.js b/ui/.storybook/config.js index 52284b361..30486249a 100644 --- a/ui/.storybook/config.js +++ b/ui/.storybook/config.js @@ -1,4 +1,10 @@ -import { configure, getStorybook, setAddon } from "@storybook/react"; +import React from "react"; +import { + configure, + getStorybook, + setAddon, + addDecorator +} from "@storybook/react"; import createPercyAddon from "@percy-io/percy-storybook"; @@ -10,6 +16,12 @@ setAddon(percyAddon); // mock date so the silence form always shows same preview advanceTo(new Date(Date.UTC(2018, 7, 14, 17, 36, 40))); +addDecorator(story => { + document.body.classList.add("theme-light"); + document.body.style = ""; + return story(); +}); + const req = require.context("../src/Components", true, /\.stories\.(js|tsx)$/); function loadStories() { diff --git a/ui/src/App.scss b/ui/src/App.scss deleted file mode 100644 index 85e38c8c4..000000000 --- a/ui/src/App.scss +++ /dev/null @@ -1,32 +0,0 @@ -// bundled font assets, so we don't need to talk to Google Fonts API -@import "src/Fonts.scss"; - -@import "Theme.scss"; - -@import "~bootstrap/scss/bootstrap"; -@import "~bootswatch/dist/flatly/bootswatch"; - -// negative margin used for silence expiry badges with progress -.nmb-05 { - margin-bottom: -($spacer * 0.125) !important; -} - -// this is used for navbar, to make it transparent -.bg-primary-transparent { - background-color: rgba($primary, 0.95); -} -// version for modals -.bg-primary-transparent-80 { - background-color: rgba($dark, 0.8); -} - -.cursor-pointer { - cursor: pointer; -} -.cursor-text { - cursor: text; -} - -.mw-1p { - min-width: 1%; -} diff --git a/ui/src/App.test.js b/ui/src/App.test.js index 03fe95049..4e6d4467c 100644 --- a/ui/src/App.test.js +++ b/ui/src/App.test.js @@ -1,6 +1,6 @@ import React from "react"; -import { shallow } from "enzyme"; +import { shallow, mount } from "enzyme"; import { NewUnappliedFilter } from "Stores/AlertStore"; import { App } from "./App"; @@ -18,12 +18,15 @@ beforeEach(() => { // createing App instance will push current filters into window.location // ensure it's wiped after each test window.history.pushState({}, "App", "/"); + + document.body.className = ""; }); afterEach(() => { localStorage.setItem("savedFilters", ""); jest.restoreAllMocks(); window.history.pushState({}, "App", "/"); + document.body.className = ""; }); describe("", () => { @@ -155,7 +158,19 @@ describe("", () => { window.onpopstate(event); }); - it("appends 'dark-theme' class to #root if dark mode is enabled", () => { + it("appends correct theme class to #root if dark mode is disabled", () => { + const tree = shallow( + + ); + tree.instance().componentWillUnmount(); + + expect(document.body.className.split(" ")).toContain("theme-light"); + }); + + it("appends 'theme-dark' class to #root if dark mode is enabled", () => { const tree = shallow( ", () => { ); tree.instance().componentWillUnmount(); - expect(document.body.className.split(" ")).toContain("dark-theme"); + expect(document.body.className.split(" ")).toContain("theme-dark"); + }); + + it("toggling settingsStore.themeConfig.config.darkTheme modifies the theme", () => { + const tree = mount( + + ); + tree.update(); + expect(document.body.className.split(" ")).toContain("theme-light"); + + tree.instance().settingsStore.themeConfig.config.darkTheme = true; + tree.update(); + expect(document.body.className.split(" ")).toContain("theme-dark"); + tree.instance().componentWillUnmount(); }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2f50957d0..f961e3d2a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,7 @@ import React, { Component } from "react"; +import { observer } from "mobx-react"; + import { AlertStore, DecodeLocationSearch } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { SilenceFormStore } from "Stores/SilenceFormStore"; @@ -7,10 +9,12 @@ import { NavBar } from "Components/NavBar"; import { Grid } from "Components/Grid"; import { Fetcher } from "Components/Fetcher"; import { FaviconBadge } from "Components/FaviconBadge"; +import { ReactSelectColors, ReactSelectStyles } from "Components/MultiSelect"; +import { Theme, ThemeContext } from "Components/Theme"; import { ErrorBoundary } from "./ErrorBoundary"; -import "./App.scss"; -import "./DarkTheme.scss"; +import "Styles/ResetCSS.scss"; +import "Styles/App.scss"; interface UIDefaults { Refresh: number; @@ -27,81 +31,98 @@ interface AppProps { uiDefaults: UIDefaults; } -class App extends Component { - alertStore: AlertStore; - silenceFormStore: SilenceFormStore; - settingsStore: Settings; - filters: Array = []; +const App = observer( + class App extends Component { + alertStore: AlertStore; + silenceFormStore: SilenceFormStore; + settingsStore: Settings; + filters: Array = []; - constructor(props: AppProps) { - super(props); + constructor(props: AppProps) { + super(props); - const { defaultFilters, uiDefaults } = this.props; + const { defaultFilters, uiDefaults } = this.props; - this.silenceFormStore = new SilenceFormStore(); - this.settingsStore = new Settings(uiDefaults); + this.silenceFormStore = new SilenceFormStore(); + this.settingsStore = new Settings(uiDefaults); - let filters; + this.state = { darkTheme: false }; - // parse and decode request query args - const p = DecodeLocationSearch(window.location.search); + let filters; - // p.defaultsUsed means that karma URI didn't have ?q=foo query args - if (p.defaultsUsed) { - // no ?q=foo set, use defaults saved by the user or from backend config - if (this.settingsStore.savedFilters.config.present) { - filters = this.settingsStore.savedFilters.config.filters; + // parse and decode request query args + const p = DecodeLocationSearch(window.location.search); + + // p.defaultsUsed means that karma URI didn't have ?q=foo query args + if (p.defaultsUsed) { + // no ?q=foo set, use defaults saved by the user or from backend config + if (this.settingsStore.savedFilters.config.present) { + filters = this.settingsStore.savedFilters.config.filters; + } else { + filters = defaultFilters; + } } else { - filters = defaultFilters; + // user passed ?q=foo, use it as initial filters + filters = p.params.q; } - } else { - // user passed ?q=foo, use it as initial filters - filters = p.params.q; + + this.alertStore = new AlertStore(filters); } - this.alertStore = new AlertStore(filters); + onPopState = (event: PopStateEvent) => { + event.preventDefault(); + const p = DecodeLocationSearch(window.location.search); + this.alertStore.filters.setWithoutLocation(p.params.q); + }; + + 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() { + window.onpopstate = () => {}; + } + + render() { + return ( + + + + + + + + + + ); + } } - - onPopState = (event: PopStateEvent) => { - event.preventDefault(); - const p = DecodeLocationSearch(window.location.search); - this.alertStore.filters.setWithoutLocation(p.params.q); - }; - - componentDidMount() { - window.onpopstate = this.onPopState; - - document.body.classList.toggle( - "dark-theme", - this.settingsStore.themeConfig.config.darkTheme - ); - } - - componentWillUnmount() { - window.onpopstate = () => {}; - } - - render() { - return ( - - - - - - - ); - } -} +); export { App }; diff --git a/ui/src/Components/Accordion/index.js b/ui/src/Components/Accordion/index.js index 4a86a5fb0..811857ff2 100644 --- a/ui/src/Components/Accordion/index.js +++ b/ui/src/Components/Accordion/index.js @@ -6,8 +6,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp"; import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown"; -import "./index.scss"; - const Trigger = ({ text, isOpen }) => (
{text}
@@ -28,7 +26,7 @@ const Accordion = ({ text, content, extraProps }) => ( className="card" openedClassName="card" triggerClassName="card-header cursor-pointer border-bottom-0" - triggerOpenedClassName="card-header cursor-pointer bg-light" + triggerOpenedClassName="card-header cursor-pointer" contentOuterClassName="collapse show" contentInnerClassName="card-body my-2" {...extraProps} diff --git a/ui/src/Components/Accordion/index.scss b/ui/src/Components/Accordion/index.scss deleted file mode 100644 index 5bd4b051e..000000000 --- a/ui/src/Components/Accordion/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -.accordion > .Collapsible.card { - overflow: unset; -} diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index b36a4bf2f..9512f7dd8 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -2,6 +2,8 @@ import React from "react"; import { mount } from "enzyme"; +import toDiffableHtml from "diffable-html"; + import { advanceTo, clear } from "jest-date-mock"; import { MockAlertGroup, MockAlert } from "__mocks__/Alerts.js"; @@ -85,7 +87,7 @@ describe("", () => { it("uses faCheck icon when idle", () => { const tree = MountedAlertAck(); - expect(tree.html()).toMatch(/fa-check/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-check/); }); it("uses faExclamationCircle after failed fetch", async () => { @@ -96,7 +98,7 @@ describe("", () => { await expect( tree.instance().submitState.silencesByCluster["default"].fetch ).resolves.toBeUndefined(); - expect(tree.html()).toMatch(/fa-exclamation-circle/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-exclamation-circle/); }); it("[v1] uses faCheckCircle after successful fetch", async () => { @@ -109,7 +111,7 @@ describe("", () => { await expect( tree.instance().submitState.silencesByCluster["default"].fetch ).resolves.toBeUndefined(); - expect(tree.html()).toMatch(/fa-check-circle/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-check-circle/); }); it("[v2] uses faCheckCircle after successful fetch", async () => { @@ -121,7 +123,7 @@ describe("", () => { await expect( tree.instance().submitState.silencesByCluster["default"].fetch ).resolves.toBeUndefined(); - expect(tree.html()).toMatch(/fa-check-circle/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-check-circle/); }); it("sends a request on click", () => { diff --git a/ui/src/Components/Animations/DropdownSlide/index.js b/ui/src/Components/Animations/DropdownSlide/index.js index 5275644c0..f2b72a474 100644 --- a/ui/src/Components/Animations/DropdownSlide/index.js +++ b/ui/src/Components/Animations/DropdownSlide/index.js @@ -3,8 +3,6 @@ import PropTypes from "prop-types"; import { CSSTransition } from "react-transition-group"; -import "./index.css"; - const DropdownSlide = ({ children, duration, ...props }) => ( ( ( ( matches snapshot when inhibited 1`] = ` " -
  • +
  • matches snapshot when inhibited 1`] = ` exports[` matches snapshot with showAlertmanagers=false showReceiver=false 1`] = ` " -
  • +
  • ", () => { it("contains value when visible=true", () => { const tree = ShallowNonLinkAnnotation(true); - expect(tree.html()).toMatch(/some long text/); + expect(toDiffableHtml(tree.html())).toMatch(/some long text/); }); it("matches snapshot when visible=false", () => { @@ -89,7 +89,7 @@ describe("", () => { it("doesn't contain value when visible=false", () => { const tree = ShallowNonLinkAnnotation(false); - expect(tree.html()).not.toMatch(/some long text/); + expect(toDiffableHtml(tree.html())).not.toMatch(/some long text/); }); it("links inside annotation are rendered as a.href", () => { @@ -100,19 +100,19 @@ describe("", () => { it("clicking on - icon hides the value", () => { const tree = MountedNonLinkAnnotation(true); - expect(tree.html()).toMatch(/fa-search-minus/); - expect(tree.html()).toMatch(/some long text/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-search-minus/); + expect(toDiffableHtml(tree.html())).toMatch(/some long text/); tree.find(".fa-search-minus").simulate("click"); - expect(tree.html()).toMatch(/fa-search-plus/); - expect(tree.html()).not.toMatch(/some long text/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-search-plus/); + expect(toDiffableHtml(tree.html())).not.toMatch(/some long text/); }); it("clicking on + icon shows the value", () => { const tree = MountedNonLinkAnnotation(false); - expect(tree.html()).toMatch(/fa-search-plus/); - expect(tree.html()).not.toMatch(/some long text/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-search-plus/); + expect(toDiffableHtml(tree.html())).not.toMatch(/some long text/); tree.find(".components-grid-annotation").simulate("click"); - expect(tree.html()).toMatch(/fa-search-minus/); - expect(tree.html()).toMatch(/some long text/); + expect(toDiffableHtml(tree.html())).toMatch(/fa-search-minus/); + expect(toDiffableHtml(tree.html())).toMatch(/some long text/); }); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap index 077095c38..ae7faf160 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupFooter/__snapshots__/index.test.js.snap @@ -2,7 +2,7 @@ exports[` matches snapshot 1`] = ` " -