fix(ui): rewrite Browser component with hooks

This commit is contained in:
Łukasz Mierzwa
2020-05-01 15:50:49 +01:00
committed by Łukasz Mierzwa
parent f9c007d3bd
commit 2dbbbb542c
2 changed files with 323 additions and 326 deletions

View File

@@ -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 (
<Fade in={theme.animations.in} duration={theme.animations.duration}>
@@ -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 (
<React.Fragment>
<div
className="d-flex flex-fill flex-lg-row flex-column justify-content-between mb-3"
data-refresh={settingsStore.fetchConfig.config.interval}
>
<span className="custom-control custom-switch my-auto flex-grow-0 flex-shrink-0">
<input
id="silence-show-expired"
className="custom-control-input"
type="checkbox"
value=""
checked={this.dataSource.showExpired}
onChange={() => {
this.dataSource.toggleShowExpired();
this.onDebouncedFetch();
}}
/>
<label
className="custom-control-label cursor-pointer"
htmlFor="silence-show-expired"
>
Show expired
</label>
</span>
<input
type="text"
className="form-control flex-grow-1 flex-shrink-1 mx-lg-3 mx-0 my-lg-0 my-2"
placeholder="Search query"
value={this.dataSource.searchTerm}
autoComplete="off"
onChange={(e) => {
this.dataSource.setSearchTerm(e.target.value);
this.onDebouncedFetch();
}}
/>
<button
type="button"
className="btn btn-secondary flex-grow-0 flex-shrink-0"
onClick={() => {
this.dataSource.toggleSortReverse();
this.onDebouncedFetch();
}}
>
<FontAwesomeIcon
className="mr-1"
icon={
this.dataSource.sortReverse
? faSortAmountUp
: faSortAmountDownAlt
}
/>
Sort order
</button>
</div>
{this.dataSource.error !== null ? (
<FetchError message={this.dataSource.error} />
) : this.dataSource.done ? (
this.dataSource.silences.length === 0 ? (
<Placeholder content="Nothing to show" />
) : (
<React.Fragment>
{this.dataSource.silences
.slice(
(this.pagination.activePage - 1) * this.maxPerPage,
this.pagination.activePage * this.maxPerPage
)
.map((silence) => (
<ManagedSilence
key={`${silence.cluster}/${silence.silence.id}`}
cluster={silence.cluster}
alertCount={silence.alertCount}
alertCountAlwaysVisible={true}
silence={silence.silence}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
onDeleteModalClose={onDeleteModalClose}
/>
))}
</React.Fragment>
)
) : (
<Placeholder
content={<FontAwesomeIcon icon={faSpinner} size="lg" spin />}
/>
)}
<PageSelect
totalPages={Math.ceil(
this.dataSource.silences.length / this.maxPerPage
)}
activePage={this.pagination.activePage}
maxPerPage={this.maxPerPage}
totalItemsCount={this.dataSource.silences.length}
setPageCallback={this.pagination.onPageChange}
return useObserver(() => (
<React.Fragment>
<div
className="d-flex flex-fill flex-lg-row flex-column justify-content-between mb-3"
data-refresh={settingsStore.fetchConfig.config.interval}
>
<span className="custom-control custom-switch my-auto flex-grow-0 flex-shrink-0">
<input
id="silence-show-expired"
className="custom-control-input"
type="checkbox"
value=""
checked={dataSource.showExpired}
onChange={() => {
dataSource.toggleShowExpired();
onDebouncedFetch();
}}
/>
</React.Fragment>
);
}
}
);
<label
className="custom-control-label cursor-pointer"
htmlFor="silence-show-expired"
>
Show expired
</label>
</span>
<input
type="text"
className="form-control flex-grow-1 flex-shrink-1 mx-lg-3 mx-0 my-lg-0 my-2"
placeholder="Search query"
value={dataSource.searchTerm}
autoComplete="off"
onChange={(e) => {
dataSource.setSearchTerm(e.target.value);
onDebouncedFetch();
}}
/>
<button
type="button"
className="btn btn-secondary flex-grow-0 flex-shrink-0"
onClick={() => {
dataSource.toggleSortReverse();
onDebouncedFetch();
}}
>
<FontAwesomeIcon
className="mr-1"
icon={dataSource.sortReverse ? faSortAmountUp : faSortAmountDownAlt}
/>
Sort order
</button>
</div>
{dataSource.error !== null ? (
<FetchError message={dataSource.error} />
) : dataSource.done ? (
dataSource.silences.length === 0 ? (
<Placeholder content="Nothing to show" />
) : (
<React.Fragment>
{dataSource.silences
.slice(
(pagination.activePage - 1) * maxPerPage,
pagination.activePage * maxPerPage
)
.map((silence) => (
<ManagedSilence
key={`${silence.cluster}/${silence.silence.id}`}
cluster={silence.cluster}
alertCount={silence.alertCount}
alertCountAlwaysVisible={true}
silence={silence.silence}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
onDeleteModalClose={onDeleteModalClose}
/>
))}
</React.Fragment>
)
) : (
<Placeholder
content={<FontAwesomeIcon icon={faSpinner} size="lg" spin />}
/>
)}
<PageSelect
totalPages={Math.ceil(dataSource.silences.length / maxPerPage)}
activePage={pagination.activePage}
maxPerPage={maxPerPage}
totalItemsCount={dataSource.silences.length}
setPageCallback={pagination.onPageChange}
/>
</React.Fragment>
));
};
Browser.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
settingsStore: PropTypes.instanceOf(Settings).isRequired,
onDeleteModalClose: PropTypes.func.isRequired,
};
export { Browser };

View File

@@ -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("<Browser />", () => {
it("fetches /silences.json on mount", async () => {
it("fetches /silences.json on mount", (done) => {
fetch.mockResponse(
JSON.stringify([
{
@@ -101,14 +102,26 @@ describe("<Browser />", () => {
},
])
);
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("<Browser />", () => {
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("<Browser />", () => {
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("<Browser />", () => {
])
);
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);
});
});