mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #310 from acelaya-forks/feature/line-chart-highlights
Feature/line chart highlights
This commit is contained in:
commit
8acb7bea24
8 changed files with 94 additions and 47 deletions
|
@ -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
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
30
src/utils/helpers/charts.ts
Normal file
30
src/utils/helpers/charts.ts
Normal 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))}`;
|
||||||
|
};
|
|
@ -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))}`;
|
|
||||||
};
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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([
|
||||||
|
|
Loading…
Reference in a new issue