From c7d97b87e0a379bb4881bd86dbe828390c0acf0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Mon, 7 Sep 2020 14:49:37 +0100 Subject: [PATCH] feat(ui): use toasts for error messages --- ui/src/App.tsx | 10 +- .../__snapshots__/index.test.tsx.snap | 17 -- .../Grid/UpstreamError/index.test.tsx | 19 -- .../Components/Grid/UpstreamError/index.tsx | 17 -- ui/src/Components/Grid/index.test.tsx | 47 ----- ui/src/Components/Grid/index.tsx | 22 +-- ui/src/Components/Toast/AppToasts.test.tsx | 77 ++++++++ ui/src/Components/Toast/AppToasts.tsx | 46 +++++ .../Components/Toast/ToastMessages.test.tsx | 51 +++++ ui/src/Components/Toast/ToastMessages.tsx | 68 +++++++ .../__snapshots__/AppToasts.test.tsx.snap | 183 ++++++++++++++++++ .../__snapshots__/ToastMessages.test.tsx.snap | 63 ++++++ ui/src/Components/Toast/index.stories.tsx | 38 ++++ ui/src/Components/Toast/index.tsx | 60 ++++++ ui/src/Stores/AlertStore.test.ts | 35 +++- ui/src/Stores/AlertStore.ts | 20 +- ui/src/Styles/Components/Toast.scss | 40 ++++ ui/src/Styles/Components/_index.scss | 1 + 18 files changed, 688 insertions(+), 126 deletions(-) delete mode 100644 ui/src/Components/Grid/UpstreamError/__snapshots__/index.test.tsx.snap delete mode 100644 ui/src/Components/Grid/UpstreamError/index.test.tsx delete mode 100644 ui/src/Components/Grid/UpstreamError/index.tsx create mode 100644 ui/src/Components/Toast/AppToasts.test.tsx create mode 100644 ui/src/Components/Toast/AppToasts.tsx create mode 100644 ui/src/Components/Toast/ToastMessages.test.tsx create mode 100644 ui/src/Components/Toast/ToastMessages.tsx create mode 100644 ui/src/Components/Toast/__snapshots__/AppToasts.test.tsx.snap create mode 100644 ui/src/Components/Toast/__snapshots__/ToastMessages.test.tsx.snap create mode 100644 ui/src/Components/Toast/index.stories.tsx create mode 100644 ui/src/Components/Toast/index.tsx create mode 100644 ui/src/Styles/Components/Toast.scss diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fc0983d88..6b53ee776 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -41,6 +41,11 @@ const FaviconBadge = React.lazy(() => default: module.FaviconBadge, })) ); +const AppToasts = React.lazy(() => + import("Components/Toast/AppToasts").then((module) => ({ + default: module.AppToasts, + })) +); interface AppProps { defaultFilters: Array; @@ -138,11 +143,10 @@ const App: FunctionComponent = ({ defaultFilters, uiDefaults }) => { settingsStore={settingsStore} silenceFormStore={silenceFormStore} /> + + - - - )); }; diff --git a/ui/src/Components/Grid/UpstreamError/__snapshots__/index.test.tsx.snap b/ui/src/Components/Grid/UpstreamError/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 67ab8f410..000000000 --- a/ui/src/Components/Grid/UpstreamError/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` matches snapshot 1`] = ` -" -
-

- Alertmanager - - foo - - raised an error: bar -

-
-" -`; diff --git a/ui/src/Components/Grid/UpstreamError/index.test.tsx b/ui/src/Components/Grid/UpstreamError/index.test.tsx deleted file mode 100644 index 02cd3deaf..000000000 --- a/ui/src/Components/Grid/UpstreamError/index.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; - -import { shallow } from "enzyme"; - -import toDiffableHtml from "diffable-html"; - -import { MockThemeContext } from "__mocks__/Theme"; -import { UpstreamError } from "."; - -beforeAll(() => { - jest.spyOn(React, "useContext").mockImplementation(() => MockThemeContext); -}); - -describe("", () => { - it("matches snapshot", () => { - const tree = shallow(); - expect(toDiffableHtml(tree.html())).toMatchSnapshot(); - }); -}); diff --git a/ui/src/Components/Grid/UpstreamError/index.tsx b/ui/src/Components/Grid/UpstreamError/index.tsx deleted file mode 100644 index 7a0459a68..000000000 --- a/ui/src/Components/Grid/UpstreamError/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { FunctionComponent } from "react"; - -const UpstreamError: FunctionComponent<{ - name: string; - message: string; -}> = ({ name, message }) => { - return ( -
-

- Alertmanager {name} raised an - error: {message} -

-
- ); -}; - -export { UpstreamError }; diff --git a/ui/src/Components/Grid/index.test.tsx b/ui/src/Components/Grid/index.test.tsx index f12be391d..6728db869 100644 --- a/ui/src/Components/Grid/index.test.tsx +++ b/ui/src/Components/Grid/index.test.tsx @@ -89,53 +89,6 @@ describe("", () => { expect(tree.text()).toBe(""); }); - it("renders UpstreamError for each unhealthy upstream", () => { - alertStore.data.upstreams = { - counters: { total: 3, healthy: 1, failed: 2 }, - instances: [ - { - name: "am1", - cluster: "am", - clusterMembers: ["am1"], - uri: "http://am1", - publicURI: "http://am1", - error: "error 1", - version: "0.21.0", - readonly: false, - corsCredentials: "include", - headers: {}, - }, - { - name: "am2", - cluster: "am", - clusterMembers: ["am2"], - uri: "file:///mock", - publicURI: "file:///mock", - error: "", - version: "0.21.0", - readonly: false, - corsCredentials: "include", - headers: {}, - }, - { - name: "am3", - cluster: "am", - clusterMembers: ["am3"], - uri: "http://am3", - publicURI: "http://am3", - error: "error 2", - version: "0.21.0", - readonly: false, - corsCredentials: "include", - headers: {}, - }, - ], - clusters: { am1: ["am1"], am2: ["am2"], am3: ["am3"] }, - }; - const tree = ShallowGrid(); - expect(tree.text()).toBe(""); - }); - it("renders only FatalError on failed fetch", () => { alertStore.status.error = "error"; alertStore.data.upstreams = { diff --git a/ui/src/Components/Grid/index.tsx b/ui/src/Components/Grid/index.tsx index b6f525ac9..da2914045 100644 --- a/ui/src/Components/Grid/index.tsx +++ b/ui/src/Components/Grid/index.tsx @@ -7,7 +7,6 @@ import { Settings } from "Stores/Settings"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { AlertGrid } from "./AlertGrid"; import { FatalError } from "./FatalError"; -import { UpstreamError } from "./UpstreamError"; import { UpgradeNeeded } from "./UpgradeNeeded"; import { ReloadNeeded } from "./ReloadNeeded"; import { EmptyGrid } from "./EmptyGrid"; @@ -34,22 +33,11 @@ const Grid: FC<{ alertStore.info.totalAlerts === 0 ? ( ) : ( - - {alertStore.data.upstreams.instances - .filter((upstream) => upstream.error !== "") - .map((upstream) => ( - - ))} - - + ) ); }; diff --git a/ui/src/Components/Toast/AppToasts.test.tsx b/ui/src/Components/Toast/AppToasts.test.tsx new file mode 100644 index 000000000..d1ed5f353 --- /dev/null +++ b/ui/src/Components/Toast/AppToasts.test.tsx @@ -0,0 +1,77 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { AlertStore } from "Stores/AlertStore"; +import { AppToasts } from "./AppToasts"; + +let alertStore: AlertStore; + +beforeEach(() => { + alertStore = new AlertStore([]); +}); + +describe("", () => { + it("doesn't render anything when alertStore.info.upgradeNeeded=true", () => { + alertStore.info.upgradeNeeded = true; + const tree = mount(); + expect(tree.html()).toBeNull(); + }); + + it("renders upstream error toasts for each unhealthy upstream", () => { + alertStore.data.upstreams = { + counters: { total: 3, healthy: 1, failed: 2 }, + instances: [ + { + name: "am1", + cluster: "am", + clusterMembers: ["am1"], + uri: "http://am1", + publicURI: "http://am1", + error: "error 1", + version: "0.21.0", + readonly: false, + corsCredentials: "include", + headers: {}, + }, + { + name: "am2", + cluster: "am", + clusterMembers: ["am2"], + uri: "file:///mock", + publicURI: "file:///mock", + error: "", + version: "0.21.0", + readonly: false, + corsCredentials: "include", + headers: {}, + }, + { + name: "am3", + cluster: "am", + clusterMembers: ["am3"], + uri: "http://am3", + publicURI: "http://am3", + error: "error 2", + version: "0.21.0", + readonly: false, + corsCredentials: "include", + headers: {}, + }, + ], + clusters: { am1: ["am1"], am2: ["am2"], am3: ["am3"] }, + }; + const tree = mount(); + expect(tree.find("Toast")).toHaveLength(2); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("renders UpgradeToastMessage when alertStore.info.upgradeReady=true", () => { + alertStore.info.upgradeReady = true; + const tree = mount(); + expect(tree.find("UpgradeToastMessage")).toHaveLength(1); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); +}); diff --git a/ui/src/Components/Toast/AppToasts.tsx b/ui/src/Components/Toast/AppToasts.tsx new file mode 100644 index 000000000..4029fa06e --- /dev/null +++ b/ui/src/Components/Toast/AppToasts.tsx @@ -0,0 +1,46 @@ +import React, { FC } from "react"; + +import { useObserver } from "mobx-react-lite"; + +import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp"; +import { faExclamation } from "@fortawesome/free-solid-svg-icons/faExclamation"; + +import { AlertStore } from "Stores/AlertStore"; +import { ToastContainer, Toast } from "."; +import { ToastMessage, UpgradeToastMessage } from "./ToastMessages"; + +const AppToasts: FC<{ + alertStore: AlertStore; +}> = ({ alertStore }) => { + return useObserver(() => + alertStore.info.upgradeNeeded ? null : ( + + {alertStore.data.upstreams.instances + .filter((upstream) => upstream.error !== "") + .map((upstream) => ( + + } + /> + ))} + {alertStore.info.upgradeReady ? ( + } + /> + ) : null} + + ) + ); +}; + +export { AppToasts }; diff --git a/ui/src/Components/Toast/ToastMessages.test.tsx b/ui/src/Components/Toast/ToastMessages.test.tsx new file mode 100644 index 000000000..40915c3f8 --- /dev/null +++ b/ui/src/Components/Toast/ToastMessages.test.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { AlertStore } from "Stores/AlertStore"; +import { ToastMessage, UpgradeToastMessage } from "./ToastMessages"; + +let alertStore: AlertStore; + +beforeEach(() => { + alertStore = new AlertStore([]); + alertStore.info.version = "1.2.3"; +}); + +describe("", () => { + it("matches snapshot", () => { + const tree = mount( + Div Message} /> + ); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); +}); + +describe("", () => { + it("matches snapshot", () => { + const tree = mount(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("clicking on the stop button pauses page reload", () => { + const tree = mount(); + expect(tree.find("button").html()).toMatch(/fa-stop/); + expect(tree.find("button").text()).toBe("Stop auto-reload"); + + tree.find("button").simulate("click"); + expect(tree.find("button").html()).toMatch(/fa-sync/); + expect(tree.find("button").text()).toBe("Reload now"); + }); + + it("clicking on the reload buton triggers a reload", () => { + const tree = mount(); + + tree.find("button").simulate("click"); + expect(tree.find("button").text()).toBe("Reload now"); + + tree.find("button").simulate("click"); + expect(alertStore.info.upgradeNeeded).toBe(true); + }); +}); diff --git a/ui/src/Components/Toast/ToastMessages.tsx b/ui/src/Components/Toast/ToastMessages.tsx new file mode 100644 index 000000000..24e2ee160 --- /dev/null +++ b/ui/src/Components/Toast/ToastMessages.tsx @@ -0,0 +1,68 @@ +import React, { FC, ReactNode, useState, useCallback } from "react"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faStop } from "@fortawesome/free-solid-svg-icons/faStop"; +import { faSync } from "@fortawesome/free-solid-svg-icons/faSync"; + +import { AlertStore } from "Stores/AlertStore"; + +const ToastMessage: FC<{ + title: ReactNode; + message: ReactNode; +}> = ({ title, message }) => { + return ( +
+
{title}
+
+ {message} +
+
+ ); +}; + +const UpgradeToastMessage: FC<{ + alertStore: AlertStore; +}> = ({ alertStore }) => { + const [isPaused, setIsPaused] = useState(false); + + const setPause = useCallback(() => { + if (isPaused) { + alertStore.info.setUpgradeNeeded(); + } else { + setIsPaused(true); + } + }, [alertStore.info, isPaused]); + + return ( +
+
+ New version available, updates are paused until this page auto reloads +
+
+ {alertStore.info.version} +
+
+ +
+
+
+
+
+ ); +}; + +export { ToastMessage, UpgradeToastMessage }; diff --git a/ui/src/Components/Toast/__snapshots__/AppToasts.test.tsx.snap b/ui/src/Components/Toast/__snapshots__/AppToasts.test.tsx.snap new file mode 100644 index 000000000..3751c595b --- /dev/null +++ b/ui/src/Components/Toast/__snapshots__/AppToasts.test.tsx.snap @@ -0,0 +1,183 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders UpgradeToastMessage when alertStore.info.upgradeReady=true 1`] = ` +" +
+
+
+
+ + + + + + + + +
+
+
+
+ New version available, updates are paused until this page auto reloads +
+
+ + unknown + +
+
+ +
+
+
+
+
+
+
+
+
+
+" +`; + +exports[` renders upstream error toasts for each unhealthy upstream 1`] = ` +" +
+
+
+
+ + + + + + + + +
+
+
+
+ Alertmanager am1 raised an error +
+
+ + error 1 + +
+
+
+
+
+
+
+
+ + + + + + + + +
+
+
+
+ Alertmanager am3 raised an error +
+
+ + error 2 + +
+
+
+
+
+
+" +`; diff --git a/ui/src/Components/Toast/__snapshots__/ToastMessages.test.tsx.snap b/ui/src/Components/Toast/__snapshots__/ToastMessages.test.tsx.snap new file mode 100644 index 000000000..74d6b2811 --- /dev/null +++ b/ui/src/Components/Toast/__snapshots__/ToastMessages.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` +" +
+
+ title string +
+
+ +
+ Div Message +
+
+
+
+" +`; + +exports[` matches snapshot 1`] = ` +" +
+
+ New version available, updates are paused until this page auto reloads +
+
+ + 1.2.3 + +
+
+ +
+
+
+
+
+
+" +`; diff --git a/ui/src/Components/Toast/index.stories.tsx b/ui/src/Components/Toast/index.stories.tsx new file mode 100644 index 000000000..f5f71d7b1 --- /dev/null +++ b/ui/src/Components/Toast/index.stories.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import { storiesOf } from "@storybook/react"; + +import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp"; +import { faExclamation } from "@fortawesome/free-solid-svg-icons/faExclamation"; + +import { AlertStore } from "Stores/AlertStore"; +import { Toast } from "."; +import { ToastMessage, UpgradeToastMessage } from "./ToastMessages"; + +import "Styles/Percy.scss"; + +storiesOf("AppToasts", module).add("AppToasts", () => { + const alertStore = new AlertStore([]); + alertStore.info.version = "999.99.0"; + + return ( +
+ + } + /> + } + /> +
+ ); +}); diff --git a/ui/src/Components/Toast/index.tsx b/ui/src/Components/Toast/index.tsx new file mode 100644 index 000000000..63986fd9c --- /dev/null +++ b/ui/src/Components/Toast/index.tsx @@ -0,0 +1,60 @@ +import React, { FC, ReactNode } from "react"; +import ReactDOM from "react-dom"; + +import TransitionGroup from "react-transition-group/TransitionGroup"; +import { CSSTransition } from "react-transition-group"; + +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCircle } from "@fortawesome/free-solid-svg-icons/faCircle"; + +import { ThemeContext } from "Components/Theme"; + +const Toast: FC<{ + icon: IconDefinition; + iconClass: string; + message: ReactNode; +}> = ({ icon, iconClass, message }) => { + return ( +
+
+
+ + +
+
+ {message} +
+
+
+ ); +}; + +const ToastContainer: FC = ({ children }) => { + const context = React.useContext(ThemeContext); + + return ReactDOM.createPortal( +
+ + {React.Children.map(children, (toast, i) => + toast ? ( + + {toast} + + ) : null + )} + +
, + document.body + ); +}; + +export { ToastContainer, Toast }; diff --git a/ui/src/Stores/AlertStore.test.ts b/ui/src/Stores/AlertStore.test.ts index 64dc72652..799cfa1fa 100644 --- a/ui/src/Stores/AlertStore.test.ts +++ b/ui/src/Stores/AlertStore.test.ts @@ -153,6 +153,37 @@ describe("AlertStore.status", () => { store.status.togglePause(); expect(store.status.paused).toBe(false); }); + + it("togglePause() always leaves store paused if it's stopped", () => { + const store = new AlertStore([]); + expect(store.status.paused).toBe(false); + store.status.stop(); + expect(store.status.paused).toBe(true); + store.status.togglePause(); + expect(store.status.paused).toBe(true); + store.status.togglePause(); + expect(store.status.paused).toBe(true); + }); + + it("stop() enforces a pause", () => { + const store = new AlertStore([]); + expect(store.status.paused).toBe(false); + store.status.stop(); + expect(store.status.paused).toBe(true); + store.status.resume(); + expect(store.status.paused).toBe(true); + }); +}); + +describe("AlertStore.info", () => { + it("setUpgradeNeeded() sets upgradeNeeded to true", () => { + const store = new AlertStore([]); + expect(store.info.upgradeNeeded).toBe(false); + store.info.setUpgradeNeeded(); + expect(store.info.upgradeNeeded).toBe(true); + store.info.setUpgradeNeeded(); + expect(store.info.upgradeNeeded).toBe(true); + }); }); describe("AlertStore.filters", () => { @@ -609,7 +640,7 @@ describe("AlertStore.fetch", () => { }); const store = new AlertStore(["label=value"]); await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); - expect(store.info.upgradeNeeded).toBe(false); + expect(store.info.upgradeReady).toBe(false); response.version = "newFakeVersion"; fetchMock.reset(); @@ -617,7 +648,7 @@ describe("AlertStore.fetch", () => { body: JSON.stringify(response), }); await expect(store.fetch("", false, "", "", "")).resolves.toBeUndefined(); - expect(store.info.upgradeNeeded).toBe(true); + expect(store.info.upgradeReady).toBe(true); }); it("adds new groups to the store after fetch", () => { diff --git a/ui/src/Stores/AlertStore.ts b/ui/src/Stores/AlertStore.ts index 1ef718f7f..277de84cd 100644 --- a/ui/src/Stores/AlertStore.ts +++ b/ui/src/Stores/AlertStore.ts @@ -259,6 +259,7 @@ class AlertStore { }, totalAlerts: 0, version: "unknown", + upgradeReady: false as boolean, upgradeNeeded: false as boolean, isRetrying: false as boolean, reloadNeeded: false as boolean, @@ -268,6 +269,9 @@ class AlertStore { clearIsRetrying() { this.isRetrying = false; }, + setUpgradeNeeded() { + this.upgradeNeeded = true; + }, setReloadNeeded() { this.reloadNeeded = true; }, @@ -275,7 +279,8 @@ class AlertStore { { setIsRetrying: action.bound, clearIsRetrying: action.bound, - setReloadNeeded: action, + setReloadNeeded: action.bound, + setUpgradeNeeded: action.bound, }, { name: "API response info" } ); @@ -319,6 +324,7 @@ class AlertStore { value: AlertStoreStatuses.Idle, lastUpdateAt: 0 as number | Date, error: null as null | string, + stopped: false as boolean, paused: false as boolean, setIdle() { this.value = AlertStoreStatuses.Idle; @@ -341,10 +347,14 @@ class AlertStore { this.paused = true; }, resume() { - this.paused = false; + this.paused = this.stopped ? true : false; }, togglePause() { - this.paused = !this.paused; + this.paused = this.stopped ? true : !this.paused; + }, + stop() { + this.paused = true; + this.stopped = true; }, }, { @@ -355,6 +365,7 @@ class AlertStore { pause: action.bound, resume: action.bound, togglePause: action.bound, + stop: action.bound, }, { name: "Store status" } ); @@ -467,7 +478,8 @@ class AlertStore { this.info.version !== "unknown" && this.info.version !== result.version ) { - this.info.upgradeNeeded = true; + this.info.upgradeReady = true; + this.status.stop(); } // update extra root level keys that are stored under 'info' this.info.totalAlerts = result.totalAlerts; diff --git a/ui/src/Styles/Components/Toast.scss b/ui/src/Styles/Components/Toast.scss new file mode 100644 index 000000000..552b87e9c --- /dev/null +++ b/ui/src/Styles/Components/Toast.scss @@ -0,0 +1,40 @@ +.toast-container { + position: fixed; + bottom: 0.5rem; + right: 0.6rem; + + z-index: 500; + + max-width: 500px; + + code { + color: $warning; + } +} + +@media screen and (max-width: 600px) { + .toast-container { + max-width: 100%; + bottom: 0; + right: 0; + } +} + +.bg-toast { + background-color: darken($dark, 5%); +} + +.toast-upgrade-progressbar { + animation-duration: 20s; + animation-name: upgradeProgress; + animation-timing-function: linear; +} + +@keyframes upgradeProgress { + 0% { + width: 0%; + } + 100% { + width: 100%; + } +} diff --git a/ui/src/Styles/Components/_index.scss b/ui/src/Styles/Components/_index.scss index 4a4ed7dc7..f9a45291a 100644 --- a/ui/src/Styles/Components/_index.scss +++ b/ui/src/Styles/Components/_index.scss @@ -24,3 +24,4 @@ @import "Pagination"; @import "Tooltip"; @import "Fetcher"; +@import "Toast";