From 2dbbbb542c5307280ea062080cabe9ad7cda1015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 1 May 2020 15:50:49 +0100 Subject: [PATCH] fix(ui): rewrite Browser component with hooks --- .../Components/SilenceModal/Browser/index.js | 394 ++++++++---------- .../SilenceModal/Browser/index.test.js | 255 +++++++----- 2 files changed, 323 insertions(+), 326 deletions(-) diff --git a/ui/src/Components/SilenceModal/Browser/index.js b/ui/src/Components/SilenceModal/Browser/index.js index 39e0b41f6..8bf834936 100644 --- a/ui/src/Components/SilenceModal/Browser/index.js +++ b/ui/src/Components/SilenceModal/Browser/index.js @@ -1,8 +1,7 @@ -import React, { Component, useContext } from "react"; +import React, { useEffect, useCallback } from "react"; import PropTypes from "prop-types"; -import { observable, action } from "mobx"; -import { observer } from "mobx-react"; +import { useObserver, useLocalStore } from "mobx-react"; import debounce from "lodash/debounce"; @@ -35,7 +34,7 @@ FetchError.propTypes = { }; const Placeholder = ({ content }) => { - const theme = useContext(ThemeContext); + const theme = React.useContext(ThemeContext); return ( @@ -49,230 +48,185 @@ Placeholder.propTypes = { content: PropTypes.node.isRequired, }; -const Browser = observer( - class Browser extends Component { - static propTypes = { - alertStore: PropTypes.instanceOf(AlertStore).isRequired, - silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, - settingsStore: PropTypes.instanceOf(Settings).isRequired, - onDeleteModalClose: PropTypes.func.isRequired, - }; +const Browser = ({ + alertStore, + silenceFormStore, + settingsStore, + onDeleteModalClose, +}) => { + const dataSource = useLocalStore(() => ({ + silences: [], + sortReverse: false, + showExpired: false, + searchTerm: "", + error: null, + fetch: null, + done: false, + setDone() { + this.done = true; + }, + setError(value) { + this.error = value; + }, + toggleSortReverse() { + this.sortReverse = !this.sortReverse; + }, + toggleShowExpired() { + this.showExpired = !this.showExpired; + }, + setSearchTerm(value) { + this.searchTerm = value; + }, + })); - constructor(props) { - super(props); - this.fetchTimer = null; - } + const maxPerPage = 5; - dataSource = observable( - { - silences: [], - sortReverse: false, - showExpired: false, - searchTerm: "", - error: null, - fetch: null, - done: false, - setDone() { - this.done = true; - }, - setError(value) { - this.error = value; - }, - toggleSortReverse() { - this.sortReverse = !this.sortReverse; - }, - toggleShowExpired() { - this.showExpired = !this.showExpired; - }, - setSearchTerm(value) { - this.searchTerm = value; - }, - }, - { - setDone: action.bound, - setError: action.bound, - toggleSortReverse: action.bound, - toggleShowExpired: action.bound, - setSearchTerm: action.bound, + const pagination = useLocalStore(() => ({ + activePage: 1, + onPageChange(pageNumber) { + this.activePage = pageNumber; + }, + resetIfNeeded(totalItemsCount, maxPerPage) { + const totalPages = Math.ceil(totalItemsCount / maxPerPage); + if (this.activePage > totalPages) { + this.activePage = Math.max(1, totalPages); } + }, + })); + + const onFetch = useCallback(() => { + const uri = FormatBackendURI( + `silences.json?sortReverse=${ + dataSource.sortReverse ? "1" : "0" + }&showExpired=${dataSource.showExpired ? "1" : "0"}&searchTerm=${ + dataSource.searchTerm + }` ); - onFetch = () => { - const uri = FormatBackendURI( - `silences.json?sortReverse=${ - this.dataSource.sortReverse ? "1" : "0" - }&showExpired=${this.dataSource.showExpired ? "1" : "0"}&searchTerm=${ - this.dataSource.searchTerm - }` - ); + dataSource.fetch = FetchGet(uri, {}) + .then((result) => { + return result.json(); + }) + .then((result) => { + dataSource.silences = result; + dataSource.setDone(); + dataSource.setError(null); + pagination.resetIfNeeded(dataSource.silences.length, maxPerPage); + }) + .catch((err) => { + console.trace(err); + dataSource.setDone(); + return dataSource.setError(`Request failed with: ${err.message}`); + }); + }, [dataSource, pagination]); - this.dataSource.fetch = FetchGet(uri, {}) - .then((result) => { - return result.json(); - }) - .then((result) => { - this.dataSource.silences = result; - this.dataSource.setDone(); - this.dataSource.setError(null); - this.pagination.resetIfNeeded( - this.dataSource.silences.length, - this.maxPerPage - ); - }) - .catch((err) => { - console.trace(err); - this.dataSource.setDone(); - return this.dataSource.setError( - `Request failed with: ${err.message}` - ); - }); - }; + const onDebouncedFetch = debounce(onFetch, 500); - onDebouncedFetch = debounce(this.onFetch, 500); + useEffect(() => { + onFetch(); + const timer = setInterval(() => { + onFetch(); + }, settingsStore.fetchConfig.config.interval * 1000); + return () => clearInterval(timer); + }, [onFetch, settingsStore.fetchConfig.config.interval]); - maxPerPage = 5; - - pagination = observable( - { - activePage: 1, - onPageChange(pageNumber) { - this.activePage = pageNumber; - }, - resetIfNeeded(totalItemsCount, maxPerPage) { - const totalPages = Math.ceil(totalItemsCount / maxPerPage); - if (this.activePage > totalPages) { - this.activePage = Math.max(1, totalPages); - } - }, - }, - { - onPageChange: action.bound, - resetIfNeeded: action.bound, - } - ); - - componentDidMount() { - const { settingsStore } = this.props; - - this.onFetch(); - this.fetchTimer = setInterval( - this.onFetch, - settingsStore.fetchConfig.config.interval * 1000 - ); - } - - componentWillUnmount() { - clearInterval(this.fetchTimer); - this.fetchTimer = null; - } - - render() { - const { - alertStore, - silenceFormStore, - settingsStore, - onDeleteModalClose, - } = this.props; - - return ( - -
- - { - this.dataSource.toggleShowExpired(); - this.onDebouncedFetch(); - }} - /> - - - { - this.dataSource.setSearchTerm(e.target.value); - this.onDebouncedFetch(); - }} - /> - -
- {this.dataSource.error !== null ? ( - - ) : this.dataSource.done ? ( - this.dataSource.silences.length === 0 ? ( - - ) : ( - - {this.dataSource.silences - .slice( - (this.pagination.activePage - 1) * this.maxPerPage, - this.pagination.activePage * this.maxPerPage - ) - .map((silence) => ( - - ))} - - ) - ) : ( - } - /> - )} - ( + +
+ + { + dataSource.toggleShowExpired(); + onDebouncedFetch(); + }} /> - - ); - } - } -); + + + { + dataSource.setSearchTerm(e.target.value); + onDebouncedFetch(); + }} + /> + +
+ {dataSource.error !== null ? ( + + ) : dataSource.done ? ( + dataSource.silences.length === 0 ? ( + + ) : ( + + {dataSource.silences + .slice( + (pagination.activePage - 1) * maxPerPage, + pagination.activePage * maxPerPage + ) + .map((silence) => ( + + ))} + + ) + ) : ( + } + /> + )} + +
+ )); +}; +Browser.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, + settingsStore: PropTypes.instanceOf(Settings).isRequired, + onDeleteModalClose: PropTypes.func.isRequired, +}; export { Browser }; diff --git a/ui/src/Components/SilenceModal/Browser/index.test.js b/ui/src/Components/SilenceModal/Browser/index.test.js index 8bbba5ed6..d9615cfb6 100644 --- a/ui/src/Components/SilenceModal/Browser/index.test.js +++ b/ui/src/Components/SilenceModal/Browser/index.test.js @@ -31,6 +31,8 @@ beforeEach(() => { cluster = "am"; silence = MockSilence(); + settingsStore.fetchConfig.config.interval = 30; + alertStore.data.upstreams = { instances: [ { @@ -48,15 +50,14 @@ beforeEach(() => { ], clusters: { am: ["am1"] }, }; - - fetch.resetMocks(); - jest.restoreAllMocks(); }); afterEach(() => { jest.restoreAllMocks(); fetch.resetMocks(); clear(); + + localStorage.setItem("fetchConfig.interval", ""); }); const MockOnDeleteModalClose = jest.fn(); @@ -91,7 +92,7 @@ const MountedBrowser = () => { }; describe("", () => { - it("fetches /silences.json on mount", async () => { + it("fetches /silences.json on mount", (done) => { fetch.mockResponse( JSON.stringify([ { @@ -101,14 +102,26 @@ describe("", () => { }, ]) ); - const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - expect(fetch.mock.calls[0][0]).toBe( - "./silences.json?sortReverse=0&showExpired=0&searchTerm=" - ); + MountedBrowser(); + setTimeout(() => { + expect(fetch.mock.calls[0][0]).toBe( + "./silences.json?sortReverse=0&showExpired=0&searchTerm=" + ); + done(); + }, 200); }); - it("enabling reverse sort passes sortReverse=1 to the API", async () => { + it("fetches /silences.json in a loop", (done) => { + settingsStore.fetchConfig.config.interval = 1; + fetch.mockResponse(JSON.stringify([])); + MountedBrowser(); + setTimeout(() => { + expect(fetch.mock.calls).toHaveLength(4); + done(); + }, 1100 * 3); + }); + + it("enabling reverse sort passes sortReverse=1 to the API", (done) => { fetch.mockResponse( JSON.stringify([ { @@ -124,13 +137,15 @@ describe("", () => { expect(sortOrder.text()).toBe("Sort order"); sortOrder.simulate("click"); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - expect(fetch.mock.calls[1][0]).toBe( - "./silences.json?sortReverse=1&showExpired=0&searchTerm=" - ); + setTimeout(() => { + expect(fetch.mock.calls[1][0]).toBe( + "./silences.json?sortReverse=1&showExpired=0&searchTerm=" + ); + done(); + }, 200); }); - it("enabling expired silences passes showExpired=1 to the API", async () => { + it("enabling expired silences passes showExpired=1 to the API", (done) => { fetch.mockResponse( JSON.stringify([ { @@ -145,42 +160,50 @@ describe("", () => { const expiredCheckbox = tree.find("input[type='checkbox']"); expiredCheckbox.simulate("change", { target: { checked: true } }); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - expect(fetch.mock.calls[1][0]).toBe( - "./silences.json?sortReverse=0&showExpired=1&searchTerm=" - ); + setTimeout(() => { + expect(fetch.mock.calls[1][0]).toBe( + "./silences.json?sortReverse=0&showExpired=1&searchTerm=" + ); + done(); + }, 200); }); - it("entering a search phrase passes searchTerm=foo to the API", async () => { + it("entering a search phrase passes searchTerm=foo to the API", (done) => { fetch.mockResponse(JSON.stringify([])); const tree = MountedBrowser(); const input = tree.find("input[type='text']").at(0); input.simulate("change", { target: { value: "foo" } }); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - expect(fetch.mock.calls[1][0]).toBe( - "./silences.json?sortReverse=0&showExpired=0&searchTerm=foo" - ); + setTimeout(() => { + expect(fetch.mock.calls[1][0]).toBe( + "./silences.json?sortReverse=0&showExpired=0&searchTerm=foo" + ); + done(); + }, 200); }); - it("renders loading placeholder before fetch finishes", async () => { + it("renders loading placeholder before fetch finishes", (done) => { fetch.mockResponse(JSON.stringify([])); const tree = MountedBrowser(); expect(tree.find("Placeholder")).toHaveLength(1); expect(toDiffableHtml(tree.html())).toMatch(/fa-spinner/); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); + setTimeout(() => { + done(); + }, 200); }); - it("renders empty placeholder after fetch with zero results", async () => { + it("renders empty placeholder after fetch with zero results", (done) => { fetch.mockResponse(JSON.stringify([])); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - expect(tree.find("Placeholder")).toHaveLength(1); - expect(toDiffableHtml(tree.html())).toMatch(/Nothing to show/); + setTimeout(() => { + expect(tree.find("Placeholder")).toHaveLength(1); + expect(toDiffableHtml(tree.html())).toMatch(/Nothing to show/); + done(); + }, 200); }); - it("renders silences after successful fetch", async () => { + it("renders silences after successful fetch", (done) => { fetch.mockResponse( JSON.stringify([ { @@ -191,118 +214,138 @@ describe("", () => { ]) ); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - tree.update(); - expect(tree.find("ManagedSilence")).toHaveLength(1); + setTimeout(() => { + tree.update(); + expect(tree.find("ManagedSilence")).toHaveLength(1); + done(); + }, 200); }); - it("renders only first 5 silences", async () => { + it("renders only first 5 silences", (done) => { fetch.mockResponse(JSON.stringify(MockSilenceList(6))); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - tree.update(); - expect(tree.find("ManagedSilence")).toHaveLength(5); + setTimeout(() => { + tree.update(); + expect(tree.find("ManagedSilence")).toHaveLength(5); + done(); + }, 200); }); - it("renders last silence after page change", async () => { + it("renders last silence after page change", (done) => { fetch.mockResponse(JSON.stringify(MockSilenceList(6))); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - tree.update(); - expect(tree.instance().pagination.activePage).toBe(1); - expect(tree.find("ManagedSilence")).toHaveLength(5); - const pageLink = tree.find(".page-link").at(3); - pageLink.simulate("click"); - tree.update(); - expect(tree.instance().pagination.activePage).toBe(2); - expect(tree.find("ManagedSilence")).toHaveLength(1); + setTimeout(() => { + tree.update(); + expect(tree.find("li.page-item").at(1).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(5); + + const pageLink = tree.find(".page-link").at(3); + pageLink.simulate("click"); + tree.update(); + expect(tree.find("li.page-item").at(2).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(1); + done(); + }, 200); }); - it("renders next/previous page after arrow key press", async () => { + it("renders next/previous page after arrow key press", (done) => { fetch.mockResponse(JSON.stringify(MockSilenceList(11))); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - tree.update(); - expect(tree.instance().pagination.activePage).toBe(1); - expect(tree.find("ManagedSilence")).toHaveLength(5); - const paginator = tree.find(".components-pagination").at(0); - paginator.simulate("focus"); + setTimeout(() => { + tree.update(); + expect(tree.find("li.page-item").at(1).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(5); - PressKey(paginator, "ArrowRight", 39); - expect(tree.instance().pagination.activePage).toBe(2); - expect(tree.find("ManagedSilence")).toHaveLength(5); + const paginator = tree.find(".components-pagination").at(0); + paginator.simulate("focus"); - PressKey(paginator, "ArrowRight", 39); - expect(tree.instance().pagination.activePage).toBe(3); - expect(tree.find("ManagedSilence")).toHaveLength(1); + PressKey(paginator, "ArrowRight", 39); + expect(tree.find("li.page-item").at(2).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(5); - PressKey(paginator, "ArrowRight", 39); - expect(tree.instance().pagination.activePage).toBe(3); - expect(tree.find("ManagedSilence")).toHaveLength(1); + PressKey(paginator, "ArrowRight", 39); + expect(tree.find("li.page-item").at(3).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(1); - PressKey(paginator, "ArrowLeft", 37); - expect(tree.instance().pagination.activePage).toBe(2); - expect(tree.find("ManagedSilence")).toHaveLength(5); + PressKey(paginator, "ArrowRight", 39); + expect(tree.find("li.page-item").at(3).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(1); - PressKey(paginator, "ArrowLeft", 37); - expect(tree.instance().pagination.activePage).toBe(1); - expect(tree.find("ManagedSilence")).toHaveLength(5); + PressKey(paginator, "ArrowLeft", 37); + expect(tree.find("li.page-item").at(2).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(5); - PressKey(paginator, "ArrowLeft", 37); - expect(tree.instance().pagination.activePage).toBe(1); - expect(tree.find("ManagedSilence")).toHaveLength(5); + PressKey(paginator, "ArrowLeft", 37); + expect(tree.find("li.page-item").at(1).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(5); + + PressKey(paginator, "ArrowLeft", 37); + expect(tree.find("li.page-item").at(1).hasClass("active")).toBe(true); + expect(tree.find("ManagedSilence")).toHaveLength(5); + done(); + }, 200); }); - it("resets pagination to last page on truncation", async () => { - fetch.mockResponseOnce(JSON.stringify(MockSilenceList(11))); + it("resets pagination to last page on truncation", (done) => { + fetch.mockResponse(JSON.stringify(MockSilenceList(11))); const tree = MountedBrowser(); - const instance = tree.instance(); - await expect(instance.dataSource.fetch).resolves.toBeUndefined(); - tree.update(); + setTimeout(() => { + tree.update(); + expect(tree.find("li.page-item").at(1).hasClass("active")).toBe(true); + const pageLink = tree.find(".page-link").at(3); + pageLink.simulate("click"); + tree.update(); + expect(tree.find("ManagedSilence")).toHaveLength(1); + expect(tree.find("li.page-item").at(3).hasClass("active")).toBe(true); - expect(instance.pagination.activePage).toBe(1); - const pageLink = tree.find(".page-link").at(3); - pageLink.simulate("click"); - tree.update(); - expect(tree.find("ManagedSilence")).toHaveLength(1); - expect(instance.pagination.activePage).toBe(3); + fetch.mockResponse(JSON.stringify(MockSilenceList(7))); + tree.find("button.btn-secondary").simulate("click"); - fetch.mockResponseOnce(JSON.stringify(MockSilenceList(7))); - instance.onFetch(); - await expect(instance.dataSource.fetch).resolves.toBeUndefined(); - tree.update(); + setTimeout(() => { + tree.update(); - expect(tree.find("ManagedSilence")).toHaveLength(2); - expect(instance.pagination.activePage).toBe(2); + expect(tree.find("ManagedSilence")).toHaveLength(2); + expect(tree.find("li.page-item").at(2).hasClass("active")).toBe(true); - fetch.mockResponseOnce(JSON.stringify([])); - instance.onFetch(); - await expect(instance.dataSource.fetch).resolves.toBeUndefined(); - tree.update(); + fetch.mockResponse(JSON.stringify([])); + tree.find("button.btn-secondary").simulate("click"); - expect(tree.find("ManagedSilence")).toHaveLength(0); - expect(instance.pagination.activePage).toBe(1); + setTimeout(() => { + tree.update(); + + expect(tree.find("ManagedSilence")).toHaveLength(0); + expect(tree.find("Placeholder")).toHaveLength(1); + done(); + }, 200); + }, 200); + }, 200); }); - it("renders error after failed fetch", async () => { + it("renders error after failed fetch", (done) => { jest.spyOn(console, "trace").mockImplementation(() => {}); fetch.mockReject("fake failure"); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); - tree.update(); - expect(tree.find("FetchError")).toHaveLength(1); - expect(toDiffableHtml(tree.html())).toMatch(/exclamation-circle/); + setTimeout(() => { + tree.update(); + expect(tree.find("FetchError")).toHaveLength(1); + expect(toDiffableHtml(tree.html())).toMatch(/exclamation-circle/); + done(); + }, 200); }); - it("resets the timer on unmount", async () => { + it("resets the timer on unmount", (done) => { fetch.mockResponse(JSON.stringify([])); const tree = MountedBrowser(); - await expect(tree.instance().dataSource.fetch).resolves.toBeUndefined(); + setTimeout(() => { + expect(fetch.mock.calls).toHaveLength(1); - expect(tree.instance().fetchTimer).not.toBeNull(); - tree.instance().componentWillUnmount(); - expect(tree.instance().fetchTimer).toBeNull(); + setTimeout(() => { + tree.unmount(); + expect(fetch.mock.calls).toHaveLength(1); + done(); + }); + }, 200); }); });