Merge pull request #310 from acelaya-forks/feature/line-chart-highlights

Feature/line chart highlights
This commit is contained in:
Alejandro Celaya 2020-09-20 12:05:06 +02:00 committed by GitHub
commit 8acb7bea24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 94 additions and 47 deletions

View file

@ -43,12 +43,10 @@ jobs:
- name: 'Publish release' - name: 'Publish release'
if: tag IS present if: tag IS present
before_deploy: npm run build ${TRAVIS_TAG#?} # Before deploying, build dist file for current travis tag before_deploy: npm run build ${TRAVIS_TAG#?}
deploy: deploy:
- provider: releases provider: releases
api_key: api_key:
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM= secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip" file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true skip_cleanup: true
on:
tags: true

View file

@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
By default updates are immediately applied if real-time updates are enabled, to keep the behavior as it was. By default updates are immediately applied if real-time updates are enabled, to keep the behavior as it was.
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
#### Changed #### Changed
* [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first. * [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first.

View file

@ -0,0 +1,30 @@
import { ChangeEvent, FC } from 'react';
import { ChartData, ChartTooltipItem } from 'chart.js';
import { prettify } from './numbers';
export const pointerOnHover = ({ target }: ChangeEvent<HTMLElement>, chartElement: FC[]) => {
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
};
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
item: ChartTooltipItem,
{ datasets }: ChartData,
) => {
const { datasetIndex } = item;
const value = item[labelToPick];
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
return `${datasetLabel}: ${prettify(Number(value))}`;
};
export const renderDoughnutChartLabel = (
{ datasetIndex, index }: ChartTooltipItem,
{ labels, datasets }: ChartData,
) => {
const datasetLabel = index !== undefined && labels?.[index] || '';
const value = datasetIndex !== undefined && index !== undefined
&& datasets?.[datasetIndex]?.data?.[index]
|| '';
return `${datasetLabel}: ${prettify(Number(value))}`;
};

View file

@ -1,9 +1,7 @@
import bowser from 'bowser'; import bowser from 'bowser';
import { zipObj } from 'ramda'; import { zipObj } from 'ramda';
import { ChartData, ChartTooltipItem } from 'chart.js';
import { Empty, hasValue } from '../utils'; import { Empty, hasValue } from '../utils';
import { Stats, UserAgent } from '../../visits/types'; import { Stats, UserAgent } from '../../visits/types';
import { prettify } from './numbers';
const DEFAULT = 'Others'; const DEFAULT = 'Others';
const BROWSERS_WHITELIST = [ const BROWSERS_WHITELIST = [
@ -40,26 +38,3 @@ export const extractDomain = (url: string | Empty): string => {
export const fillTheGaps = (stats: Stats, labels: string[]): number[] => export const fillTheGaps = (stats: Stats, labels: string[]): number[] =>
Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats }); Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });
export const renderDoughnutChartLabel = (
{ datasetIndex, index }: ChartTooltipItem,
{ labels, datasets }: ChartData,
) => {
const datasetLabel = index !== undefined && labels?.[index] || '';
const value = datasetIndex !== undefined && index !== undefined
&& datasets?.[datasetIndex]?.data?.[index]
|| '';
return `${datasetLabel}: ${prettify(Number(value))}`;
};
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
item: ChartTooltipItem,
{ datasets }: ChartData,
) => {
const { datasetIndex } = item;
const value = item[labelToPick];
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
return `${datasetLabel}: ${prettify(Number(value))}`;
};

View file

@ -128,6 +128,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
visits={normalizedVisits} visits={normalizedVisits}
highlightedVisits={highlightedVisits} highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel} highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/> />
</div> </div>
<div className="col-xl-4 col-lg-6 mt-4"> <div className="col-xl-4 col-lg-6 mt-4">

View file

@ -1,11 +1,12 @@
import React, { ChangeEvent, useRef } from 'react'; import React, { useRef } from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { keys, values } from 'ramda'; import { keys, values } from 'ramda';
import classNames from 'classnames'; import classNames from 'classnames';
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js'; import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import { fillTheGaps, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/visits'; import { fillTheGaps } from '../../utils/helpers/visits';
import { Stats } from '../types'; import { Stats } from '../types';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
import './DefaultChart.scss'; import './DefaultChart.scss';
export interface DefaultChartProps { export interface DefaultChartProps {
@ -145,11 +146,7 @@ const DefaultChart = (
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel, label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
}, },
}, },
onHover: !isBarChart ? undefined : ((e: ChangeEvent<HTMLElement>, chartElement: HorizontalBar[] | Doughnut[]) => { onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
const { target } = e;
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
}) as any, // TODO Types seem to be incorrectly defined
}; };
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel); const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
const height = determineHeight(isBarChart, labels); const height = determineHeight(isBarChart, labels);

View file

@ -11,13 +11,14 @@ import {
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { always, cond, reverse } from 'ramda'; import { always, cond, reverse } from 'ramda';
import moment from 'moment'; import moment from 'moment';
import { ChartData, ChartDataSets, ChartOptions } from 'chart.js'; import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import { NormalizedVisit, Stats } from '../types'; import { NormalizedVisit, Stats } from '../types';
import { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits'; import { fillTheGaps } from '../../utils/helpers/visits';
import { useToggle } from '../../utils/helpers/hooks'; import { useToggle } from '../../utils/helpers/hooks';
import { rangeOf } from '../../utils/utils'; import { rangeOf } from '../../utils/utils';
import ToggleSwitch from '../../utils/ToggleSwitch'; import ToggleSwitch from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
import './LineChartCard.scss'; import './LineChartCard.scss';
interface LineChartCardProps { interface LineChartCardProps {
@ -25,6 +26,7 @@ interface LineChartCardProps {
highlightedLabel?: string; highlightedLabel?: string;
visits: NormalizedVisit[]; visits: NormalizedVisit[];
highlightedVisits: NormalizedVisit[]; highlightedVisits: NormalizedVisit[];
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
} }
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly'; type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
@ -71,13 +73,25 @@ const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visi
(acc, visit) => { (acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date); const key = STEP_TO_DATE_FORMAT[step](visit.date);
acc[key] = acc[key] ? acc[key] + 1 : 1; acc[key] = (acc[key] || 0) + 1;
return acc; return acc;
}, },
{}, {},
); );
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) => visits.reduce(
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date);
acc[key] = acc[key] ?? [];
acc[key].push(visit);
return acc;
},
{} as Record<string, NormalizedVisit[]>,
);
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => { const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const unit = STEP_TO_DATE_UNIT_MAP[step]; const unit = STEP_TO_DATE_UNIT_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step]; const formatter = STEP_TO_DATE_FORMAT[step];
@ -115,12 +129,37 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
backgroundColor: color, backgroundColor: color,
}); });
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }: LineChartCardProps) => { let selectedLabel: string | null = null;
const chartElementAtEvent = (
datasetsByPoint: Record<string, NormalizedVisit[]>,
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
if (!setSelectedVisits || !chart) {
return;
}
const { _index: index, _chart: { data } } = chart;
const { labels } = data as { labels: string[] };
if (selectedLabel === labels[index]) {
setSelectedVisits([]);
selectedLabel = null;
} else {
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] || []);
selectedLabel = labels[index] ?? null;
}
};
const LineChartCard = (
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
) => {
const [ step, setStep ] = useState<Step>( const [ step, setStep ] = useState<Step>(
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly', visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
); );
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true); const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [ step, visits ]);
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]); const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
const [ labels, groupedVisits ] = useMemo( const [ labels, groupedVisits ] = useMemo(
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits), () => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
@ -166,6 +205,7 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
label: renderNonDoughnutChartLabel('yLabel'), label: renderNonDoughnutChartLabel('yLabel'),
}, },
}, },
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
}; };
return ( return (
@ -193,7 +233,11 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
</div> </div>
</CardHeader> </CardHeader>
<CardBody className="line-chart-card__body"> <CardBody className="line-chart-card__body">
<Line data={data} options={options} /> <Line
data={data}
options={options}
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
/>
</CardBody> </CardBody>
</Card> </Card>
); );

View file

@ -53,7 +53,7 @@ describe('<LineChartCard />', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const chart = wrapper.find(Line); const chart = wrapper.find(Line);
expect(chart.prop('options')).toEqual({ expect(chart.prop('options')).toEqual(expect.objectContaining({
maintainAspectRatio: false, maintainAspectRatio: false,
legend: { display: false }, legend: { display: false },
scales: { scales: {
@ -72,7 +72,7 @@ describe('<LineChartCard />', () => {
intersect: false, intersect: false,
axis: 'x', axis: 'x',
}), }),
}); }));
}); });
it.each([ it.each([