mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #271 from acelaya-forks/feature/line-chart
Feature/line chart
This commit is contained in:
commit
9340512980
8 changed files with 213 additions and 12 deletions
|
@ -25,6 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
|
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
|
||||||
|
|
||||||
|
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
|
||||||
|
|
||||||
#### Changed
|
#### Changed
|
||||||
|
|
||||||
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import bowser from 'bowser';
|
import bowser from 'bowser';
|
||||||
|
import { zipObj } from 'ramda';
|
||||||
import { hasValue } from '../utils';
|
import { hasValue } from '../utils';
|
||||||
|
|
||||||
const DEFAULT = 'Others';
|
const DEFAULT = 'Others';
|
||||||
|
@ -35,3 +36,5 @@ export const extractDomain = (url) => {
|
||||||
|
|
||||||
return domain.split(':')[0];
|
return domain.split(':')[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fillTheGaps = (stats, labels) => Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });
|
||||||
|
|
|
@ -2,7 +2,8 @@ import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { keys, values, zipObj } from 'ramda';
|
import { keys, values } from 'ramda';
|
||||||
|
import { fillTheGaps } from '../utils/helpers/visits';
|
||||||
import './GraphCard.scss';
|
import './GraphCard.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
@ -20,7 +21,7 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) =>
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
label: highlightedData && 'Non-selected',
|
label: highlightedData ? 'Non-selected' : 'Visits',
|
||||||
data,
|
data,
|
||||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||||
'#97BBCD',
|
'#97BBCD',
|
||||||
|
@ -70,9 +71,7 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) =
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, { ...stats }));
|
}, { ...stats }));
|
||||||
const highlightedData = hasHighlightedStats && values(
|
const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels);
|
||||||
{ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }
|
|
||||||
);
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||||
|
@ -120,7 +119,7 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) =
|
||||||
};
|
};
|
||||||
|
|
||||||
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, onClick }) => (
|
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, onClick }) => (
|
||||||
<Card className="mt-4">
|
<Card>
|
||||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||||
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)}</CardBody>
|
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)}</CardBody>
|
||||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { formatDate } from '../utils/helpers/date';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import SortableBarGraph from './SortableBarGraph';
|
import SortableBarGraph from './SortableBarGraph';
|
||||||
import GraphCard from './GraphCard';
|
import GraphCard from './GraphCard';
|
||||||
|
import LineChartCard from './helpers/LineChartCard';
|
||||||
import VisitsTable from './VisitsTable';
|
import VisitsTable from './VisitsTable';
|
||||||
import { VisitsInfoType } from './types';
|
import { VisitsInfoType } from './types';
|
||||||
|
|
||||||
|
@ -109,13 +110,16 @@ const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-xl-4 col-lg-6">
|
<div className="col-12 mt-4">
|
||||||
|
<LineChartCard title="Visits during time" visits={visits} highlightedVisits={highlightedVisits} />
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-4 col-lg-6 mt-4">
|
||||||
<GraphCard title="Operating systems" stats={os} />
|
<GraphCard title="Operating systems" stats={os} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-xl-4 col-lg-6">
|
<div className="col-xl-4 col-lg-6 mt-4">
|
||||||
<GraphCard title="Browsers" stats={browsers} />
|
<GraphCard title="Browsers" stats={browsers} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-xl-4">
|
<div className="col-xl-4 mt-4">
|
||||||
<SortableBarGraph
|
<SortableBarGraph
|
||||||
title="Referrers"
|
title="Referrers"
|
||||||
stats={referrers}
|
stats={referrers}
|
||||||
|
@ -128,7 +132,7 @@ const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBt
|
||||||
onClick={highlightVisitsForProp('referer')}
|
onClick={highlightVisitsForProp('referer')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6 mt-4">
|
||||||
<SortableBarGraph
|
<SortableBarGraph
|
||||||
title="Countries"
|
title="Countries"
|
||||||
stats={countries}
|
stats={countries}
|
||||||
|
@ -140,7 +144,7 @@ const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBt
|
||||||
onClick={highlightVisitsForProp('country')}
|
onClick={highlightVisitsForProp('country')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6 mt-4">
|
||||||
<SortableBarGraph
|
<SortableBarGraph
|
||||||
title="Cities"
|
title="Cities"
|
||||||
stats={cities}
|
stats={cities}
|
||||||
|
|
9
src/visits/helpers/LineCHartCard.scss
Normal file
9
src/visits/helpers/LineCHartCard.scss
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
@import '../../utils/base';
|
||||||
|
|
||||||
|
.line-chart-card__body canvas {
|
||||||
|
height: 300px !important;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
height: 350px !important;
|
||||||
|
}
|
||||||
|
}
|
117
src/visits/helpers/LineChartCard.js
Normal file
117
src/visits/helpers/LineChartCard.js
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
UncontrolledDropdown,
|
||||||
|
DropdownToggle,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
} from 'reactstrap';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { reverse } from 'ramda';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { VisitType } from '../types';
|
||||||
|
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||||
|
import './LineCHartCard.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
visits: PropTypes.arrayOf(VisitType),
|
||||||
|
highlightedVisits: PropTypes.arrayOf(VisitType),
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ value: 'monthly', menuText: 'Month' },
|
||||||
|
{ value: 'weekly', menuText: 'Week' },
|
||||||
|
{ value: 'daily', menuText: 'Day' },
|
||||||
|
{ value: 'hourly', menuText: 'Hour' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_TO_DATE_FORMAT = {
|
||||||
|
hourly: (date) => moment(date).format('YYYY-MM-DD HH:00'),
|
||||||
|
daily: (date) => moment(date).format('YYYY-MM-DD'),
|
||||||
|
weekly(date) {
|
||||||
|
const firstWeekDay = moment(date).isoWeekday(1).format('YYYY-MM-DD');
|
||||||
|
const lastWeekDay = moment(date).isoWeekday(7).format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
return `${firstWeekDay} - ${lastWeekDay}`;
|
||||||
|
},
|
||||||
|
monthly: (date) => moment(date).format('YYYY-MM'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupVisitsByStep = (step, visits) => visits.reduce((acc, visit) => {
|
||||||
|
const key = STEP_TO_DATE_FORMAT[step](visit.date);
|
||||||
|
|
||||||
|
acc[key] = acc[key] ? acc[key] + 1 : 1;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const generateDataset = (stats, label, color) => ({
|
||||||
|
label,
|
||||||
|
data: Object.values(stats),
|
||||||
|
fill: false,
|
||||||
|
lineTension: 0.2,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color,
|
||||||
|
});
|
||||||
|
|
||||||
|
const LineChartCard = ({ title, visits, highlightedVisits }) => {
|
||||||
|
const [ step, setStep ] = useState(steps[0].value);
|
||||||
|
const groupedVisits = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ visits, step ]);
|
||||||
|
const labels = useMemo(() => Object.keys(groupedVisits), [ groupedVisits ]);
|
||||||
|
const groupedHighlighted = useMemo(
|
||||||
|
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
||||||
|
[ highlightedVisits, step, labels ]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
generateDataset(groupedVisits, 'Visits', '#4696e5'),
|
||||||
|
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, 'Selected', '#F77F28'),
|
||||||
|
].filter(Boolean),
|
||||||
|
};
|
||||||
|
const options = {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
legend: { display: false },
|
||||||
|
scales: {
|
||||||
|
yAxes: [
|
||||||
|
{
|
||||||
|
ticks: { beginAtZero: true, precision: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
{title}
|
||||||
|
<div className="float-right">
|
||||||
|
<UncontrolledDropdown>
|
||||||
|
<DropdownToggle caret color="link" className="btn-sm p-0">
|
||||||
|
Group by
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu right>
|
||||||
|
{steps.map(({ menuText, value }) => (
|
||||||
|
<DropdownItem key={value} active={step === value} onClick={() => setStep(value)}>
|
||||||
|
{menuText}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</UncontrolledDropdown>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="line-chart-card__body">
|
||||||
|
<Line data={data} options={options} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LineChartCard.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default LineChartCard;
|
|
@ -82,8 +82,9 @@ describe('<GraphCard />', () => {
|
||||||
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} highlightedStats={highlightedStats} />);
|
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} highlightedStats={highlightedStats} />);
|
||||||
const horizontal = wrapper.find(HorizontalBar);
|
const horizontal = wrapper.find(HorizontalBar);
|
||||||
|
|
||||||
const { datasets: [{ data }, highlightedData ] } = horizontal.prop('data');
|
const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data');
|
||||||
|
|
||||||
|
expect(label).toEqual(highlightedStats ? 'Non-selected' : 'Visits');
|
||||||
expect(data).toEqual(expectedData);
|
expect(data).toEqual(expectedData);
|
||||||
expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData);
|
expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData);
|
||||||
!expectedHighlightedData && expect(highlightedData).toBeUndefined();
|
!expectedHighlightedData && expect(highlightedData).toBeUndefined();
|
||||||
|
|
66
test/visits/helpers/LineChartCard.test.js
Normal file
66
test/visits/helpers/LineChartCard.test.js
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { CardHeader, DropdownItem } from 'reactstrap';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import LineChartCard from '../../../src/visits/helpers/LineChartCard';
|
||||||
|
|
||||||
|
describe('<LineCHartCard />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const createWrapper = (visits = [], highlightedVisits = []) => {
|
||||||
|
wrapper = shallow(<LineChartCard title="Cool title" visits={visits} highlightedVisits={highlightedVisits} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper && wrapper.unmount());
|
||||||
|
|
||||||
|
it('renders provided title', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const header = wrapper.find(CardHeader);
|
||||||
|
|
||||||
|
expect(header.html()).toContain('Cool title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders group menu and selects active grouping item', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const items = wrapper.find(DropdownItem);
|
||||||
|
|
||||||
|
expect(items).toHaveLength(4);
|
||||||
|
expect(items.at(0).prop('children')).toEqual('Month');
|
||||||
|
expect(items.at(0).prop('active')).toEqual(true);
|
||||||
|
expect(items.at(1).prop('children')).toEqual('Week');
|
||||||
|
expect(items.at(1).prop('active')).toEqual(false);
|
||||||
|
expect(items.at(2).prop('children')).toEqual('Day');
|
||||||
|
expect(items.at(2).prop('active')).toEqual(false);
|
||||||
|
expect(items.at(3).prop('children')).toEqual('Hour');
|
||||||
|
expect(items.at(3).prop('active')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart with expected options', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const chart = wrapper.find(Line);
|
||||||
|
|
||||||
|
expect(chart.prop('options')).toEqual({
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
legend: { display: false },
|
||||||
|
scales: {
|
||||||
|
yAxes: [
|
||||||
|
{
|
||||||
|
ticks: { beginAtZero: true, precision: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[[{}], [], 1 ],
|
||||||
|
[[{}], [{}], 2 ],
|
||||||
|
])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => {
|
||||||
|
const wrapper = createWrapper(visits, highlightedVisits);
|
||||||
|
const chart = wrapper.find(Line);
|
||||||
|
const { datasets } = chart.prop('data');
|
||||||
|
|
||||||
|
expect(datasets).toHaveLength(expectedLines);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue