diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ae208b..baeed2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +* [#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 * [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu. diff --git a/src/utils/helpers/visits.js b/src/utils/helpers/visits.js index 8f5b9e8d..51d898f4 100644 --- a/src/utils/helpers/visits.js +++ b/src/utils/helpers/visits.js @@ -1,4 +1,5 @@ import bowser from 'bowser'; +import { zipObj } from 'ramda'; import { hasValue } from '../utils'; const DEFAULT = 'Others'; @@ -35,3 +36,5 @@ export const extractDomain = (url) => { return domain.split(':')[0]; }; + +export const fillTheGaps = (stats, labels) => Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats }); diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index b05c25c1..c1161e6a 100644 --- a/src/visits/GraphCard.js +++ b/src/visits/GraphCard.js @@ -2,7 +2,8 @@ import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap'; import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import PropTypes from 'prop-types'; import React from 'react'; -import { keys, values, zipObj } from 'ramda'; +import { keys, values } from 'ramda'; +import { fillTheGaps } from '../utils/helpers/visits'; import './GraphCard.scss'; const propTypes = { @@ -20,7 +21,7 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) => datasets: [ { title, - label: highlightedData && 'Non-selected', + label: highlightedData ? 'Non-selected' : 'Visits', data, backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ '#97BBCD', @@ -70,9 +71,7 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) = return acc; }, { ...stats })); - const highlightedData = hasHighlightedStats && values( - { ...zipObj(labels, labels.map(() => 0)), ...highlightedStats } - ); + const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels); const options = { 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 }) => ( - + {typeof title === 'function' ? title() : title} {renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)} {footer && {footer}} diff --git a/src/visits/VisitsStats.js b/src/visits/VisitsStats.js index 6b584b75..99329cb2 100644 --- a/src/visits/VisitsStats.js +++ b/src/visits/VisitsStats.js @@ -11,6 +11,7 @@ import { formatDate } from '../utils/helpers/date'; import { useToggle } from '../utils/helpers/hooks'; import SortableBarGraph from './SortableBarGraph'; import GraphCard from './GraphCard'; +import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; import { VisitsInfoType } from './types'; @@ -109,13 +110,16 @@ const VisitsStats = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBt return (
-
+
+ +
+
-
+
-
+
-
+
-
+
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 ( + + + {title} +
+ + + Group by + + + {steps.map(({ menuText, value }) => ( + setStep(value)}> + {menuText} + + ))} + + +
+
+ + + +
+ ); +}; + +LineChartCard.propTypes = propTypes; + +export default LineChartCard; diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js index e924c699..25400289 100644 --- a/test/visits/GraphCard.test.js +++ b/test/visits/GraphCard.test.js @@ -82,8 +82,9 @@ describe('', () => { wrapper = shallow(); 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); expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData); !expectedHighlightedData && expect(highlightedData).toBeUndefined(); diff --git a/test/visits/helpers/LineChartCard.test.js b/test/visits/helpers/LineChartCard.test.js new file mode 100644 index 00000000..1c6bf376 --- /dev/null +++ b/test/visits/helpers/LineChartCard.test.js @@ -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('', () => { + let wrapper; + const createWrapper = (visits = [], highlightedVisits = []) => { + wrapper = shallow(); + + 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); + }); +});