Convert ClientsGraph to use WithAPIData

Signed-off-by: Mcat12 <newtoncat12@yahoo.com>
This commit is contained in:
Mcat12
2018-11-10 11:10:42 -05:00
parent 131a418cb7
commit a87ed892cd
2 changed files with 171 additions and 164 deletions
+155 -132
View File
@@ -10,149 +10,78 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import { Line } from "react-chartjs-2";
import { translate } from "react-i18next";
import { padNumber, makeCancelable, ignoreCancel } from "../../util";
import { padNumber } from "../../util";
import api from "../../util/api";
import ChartTooltip from "./ChartTooltip";
import { WithAPIData } from "../common/WithAPIData";
class ClientsGraph extends Component {
state = {
loading: true,
data: {
labels: [],
datasets: []
}
static propTypes = {
loading: PropTypes.bool.isRequired,
labels: PropTypes.array.isRequired,
datasets: PropTypes.array.isRequired
};
static graphOptions = {
tooltips: {
enabled: false,
mode: "x-axis",
custom: () => "placeholder",
itemSort: function(a, b) {
return b.yLabel - a.yLabel;
},
callbacks: {
title: tooltipItem => {
const time = tooltipItem[0].xLabel.match(/(\d?\d):?(\d?\d?)/);
const hour = parseInt(time[1], 10);
const minute = parseInt(time[2], 10) || 0;
const from = padNumber(hour) + ":" + padNumber(minute - 5) + ":00";
const to = padNumber(hour) + ":" + padNumber(minute + 4) + ":59";
return t("Client activity from {{from}} to {{to}}", { from, to });
},
label: (tooltipItems, data) => {
return (
data.datasets[tooltipItems.datasetIndex].label +
": " +
tooltipItems.yLabel
);
}
}
},
legend: { display: false },
scales: {
xAxes: [
{
type: "time",
time: {
unit: "hour",
displayFormats: { hour: "HH:mm" },
tooltipFormat: "HH:mm"
}
}
],
yAxes: [
{
ticks: { beginAtZero: true },
stacked: true
}
]
},
maintainAspectRatio: false
};
constructor(props) {
super(props);
this.graphRef = React.createRef();
this.updateGraph = this.updateGraph.bind(this);
}
updateGraph() {
this.updateHandler = makeCancelable(api.getClientsGraph(), {
repeat: this.updateGraph,
interval: 10 * 60 * 1000
});
this.updateHandler.promise
.then(res => {
// Remove last data point as it's not yet finished
res.over_time.splice(-1, 1);
const colors = [
"#20a8d8",
"#f86c6b",
"#4dbd74",
"#f8cb00",
"#263238",
"#63c2de",
"#b0bec5"
];
const labels = res.over_time.map(
step => new Date(1000 * step.timestamp)
);
const datasets = [];
// Fill in dataset metadata
let i = 0;
for (let client of res.clients) {
datasets.push({
label: client.name.length !== 0 ? client.name : client.ip,
// If we ran out of colors, make a random one
backgroundColor:
i < colors.length
? colors[i]
: "#" +
parseInt("" + Math.random() * 0xffffff, 10)
.toString(16)
.padStart(6, "0"),
pointRadius: 0,
pointHitRadius: 5,
pointHoverRadius: 5,
cubicInterpolationMode: "monotone",
data: []
});
i++;
}
// Fill in data & labels
for (let step of res.over_time) {
for (let destination in datasets) {
if (datasets.hasOwnProperty(destination))
datasets[destination].data.push(step.data[destination]);
}
}
datasets.sort((a, b) => a.label.localeCompare(b.label));
this.setState({ data: { labels, datasets }, loading: false });
})
.catch(ignoreCancel);
}
componentDidMount() {
this.updateGraph();
}
componentWillUnmount() {
this.updateHandler.cancel();
}
render() {
const { t } = this.props;
const options = {
tooltips: {
enabled: false,
mode: "x-axis",
custom: () => "placeholder",
itemSort: function(a, b) {
return b.yLabel - a.yLabel;
},
callbacks: {
title: tooltipItem => {
const time = tooltipItem[0].xLabel.match(/(\d?\d):?(\d?\d?)/);
const hour = parseInt(time[1], 10);
const minute = parseInt(time[2], 10) || 0;
const from = padNumber(hour) + ":" + padNumber(minute - 5) + ":00";
const to = padNumber(hour) + ":" + padNumber(minute + 4) + ":59";
return t("Client activity from {{from}} to {{to}}", { from, to });
},
label: (tooltipItems, data) => {
return (
data.datasets[tooltipItems.datasetIndex].label +
": " +
tooltipItems.yLabel
);
}
}
},
legend: { display: false },
scales: {
xAxes: [
{
type: "time",
time: {
unit: "hour",
displayFormats: { hour: "HH:mm" },
tooltipFormat: "HH:mm"
}
}
],
yAxes: [
{
ticks: { beginAtZero: true },
stacked: true
}
]
},
maintainAspectRatio: false
};
return (
<div className="card">
<div className="card-header">{t("Clients Over Last 24 Hours")}</div>
@@ -160,12 +89,16 @@ class ClientsGraph extends Component {
<Line
width={970}
height={170}
data={this.state.data}
options={options}
data={{
labels: this.props.labels,
datasets: this.props.datasets
}}
options={ClientsGraph.graphOptions}
ref={this.graphRef}
/>
</div>
{this.state.loading ? (
{this.props.loading ? (
<div
className="card-img-overlay"
style={{ background: "rgba(255,255,255,0.7)" }}
@@ -181,9 +114,13 @@ class ClientsGraph extends Component {
/>
</div>
) : null}
{// Now you're thinking with portals!
ReactDOM.createPortal(
<ChartTooltip chart={this.graphRef} handler={options.tooltips} />,
<ChartTooltip
chart={this.graphRef}
handler={ClientsGraph.graphOptions.tooltips}
/>,
document.body
)}
</div>
@@ -191,4 +128,90 @@ class ClientsGraph extends Component {
}
}
export default translate("dashboard")(ClientsGraph);
/**
* Transform the API data into props for ClientsGraph
*
* @param data the API data
* @returns {{labels: Date[], datasets: Array, loading: boolean}} ClientsGraph
* props
*/
export const transformData = data => {
// Remove last data point as it's not yet finished
const overTime = data.over_time.slice(0, -1);
const colors = [
"#20a8d8",
"#f86c6b",
"#4dbd74",
"#f8cb00",
"#263238",
"#63c2de",
"#b0bec5"
];
const labels = overTime.map(step => new Date(1000 * step.timestamp));
const datasets = [];
// Fill in dataset metadata
let i = 0;
for (let client of data.clients) {
datasets.push({
label: client.name.length !== 0 ? client.name : client.ip,
// If we ran out of colors, make a random one
backgroundColor:
i < colors.length
? colors[i]
: "#" +
parseInt("" + Math.random() * 0xffffff, 10)
.toString(16)
.padStart(6, "0"),
pointRadius: 0,
pointHitRadius: 5,
pointHoverRadius: 5,
cubicInterpolationMode: "monotone",
data: []
});
i++;
}
// Fill in data & labels
for (let step of overTime) {
for (let destination in datasets) {
if (datasets.hasOwnProperty(destination))
datasets[destination].data.push(step.data[destination]);
}
}
datasets.sort((a, b) => a.label.localeCompare(b.label));
return { labels, datasets, loading: false };
};
/**
* The props used to show a loading state (either initial load or error)
*
* @returns {*} the loading props
*/
export const loadingState = () => ({
loading: true,
labels: [],
datasets: []
});
export const TranslatedClientsGraph = translate("dashboard")(ClientsGraph);
export default props => (
<WithAPIData
apiCall={api.getClientsGraph}
repeatOptions={{
interval: 10 * 60 * 1000
}}
renderInitial={() => (
<TranslatedClientsGraph {...loadingState()} {...props} />
)}
renderOk={data => (
<TranslatedClientsGraph {...transformData(data)} {...props} />
)}
renderErr={() => <TranslatedClientsGraph {...loadingState()} {...props} />}
/>
);
+16 -32
View File
@@ -10,10 +10,12 @@
import React from "react";
import { shallow } from "enzyme";
import fetchMock from "fetch-mock";
import ClientsGraph from "./ClientsGraph";
import {
TranslatedClientsGraph,
transformData,
loadingState
} from "./ClientsGraph";
const endpoint = "/admin/api/stats/overTime/clients";
const fakeData = {
over_time: [
{ timestamp: 1513218354, data: [48476, 35688, 95153, 56971, 83497] },
@@ -36,45 +38,27 @@ const fakeData = {
]
};
it("shows loading indicator before first load", () => {
fetchMock.mock(endpoint, ignoreAPI);
it("shows loading indicator correctly", () => {
const wrapper = shallow(<TranslatedClientsGraph {...loadingState()} />);
const wrapper = shallow(<ClientsGraph />);
expect(wrapper.state().loading).toBeTruthy();
expect(wrapper.children(".card-img-overlay")).toExist();
});
it("hides loading indicator after first load", async () => {
fetchMock.mock(endpoint, { body: fakeData });
it("hides loading indicator correctly", async () => {
const wrapper = shallow(
<TranslatedClientsGraph {...loadingState()} loading={false} />
);
const wrapper = shallow(<ClientsGraph />);
await tick();
wrapper.update();
expect(wrapper.state().loading).toBeFalsy();
expect(wrapper.children(".card-img-overlay")).not.toExist();
});
it("loads API data correctly", async () => {
fetchMock.mock(endpoint, { body: fakeData });
const data = transformData(fakeData);
const wrapper = shallow(<ClientsGraph />);
await tick();
wrapper.update();
expect(wrapper.state().data.labels[0]).toEqual(
expect(data.labels[0]).toEqual(
new Date(1000 * fakeData.over_time[0].timestamp)
);
expect(wrapper.state().data.datasets[2].label).toEqual(
fakeData.clients[2].name
);
expect(wrapper.state().data.datasets[1].label).toEqual(
fakeData.clients[1].ip
);
expect(wrapper.state().data.datasets[0].data.length).toEqual(
fakeData.over_time.length - 1
);
expect(data.datasets[2].label).toEqual(fakeData.clients[2].name);
expect(data.datasets[1].label).toEqual(fakeData.clients[1].ip);
expect(data.datasets[0].data.length).toEqual(fakeData.over_time.length - 1);
});