diff --git a/src/components/dashboard/ClientsGraph.js b/src/components/dashboard/ClientsGraph.js index 7c0e164..f6f1921 100644 --- a/src/components/dashboard/ClientsGraph.js +++ b/src/components/dashboard/ClientsGraph.js @@ -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 (
{t("Clients Over Last 24 Hours")}
@@ -160,12 +89,16 @@ class ClientsGraph extends Component {
- {this.state.loading ? ( + + {this.props.loading ? (
) : null} + {// Now you're thinking with portals! ReactDOM.createPortal( - , + , document.body )} @@ -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 => ( + ( + + )} + renderOk={data => ( + + )} + renderErr={() => } + /> +); diff --git a/src/components/dashboard/ClientsGraph.test.js b/src/components/dashboard/ClientsGraph.test.js index 360d6f7..e6aa970 100644 --- a/src/components/dashboard/ClientsGraph.test.js +++ b/src/components/dashboard/ClientsGraph.test.js @@ -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(); - const wrapper = shallow(); - - 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( + + ); - const wrapper = shallow(); - - 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(); - - 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); });