mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +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'
|
||||
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:
|
||||
- provider: releases
|
||||
provider: releases
|
||||
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=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
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.
|
||||
|
||||
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
|
||||
|
||||
#### 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.
|
||||
|
|
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 { zipObj } from 'ramda';
|
||||
import { ChartData, ChartTooltipItem } from 'chart.js';
|
||||
import { Empty, hasValue } from '../utils';
|
||||
import { Stats, UserAgent } from '../../visits/types';
|
||||
import { prettify } from './numbers';
|
||||
|
||||
const DEFAULT = 'Others';
|
||||
const BROWSERS_WHITELIST = [
|
||||
|
@ -40,26 +38,3 @@ export const extractDomain = (url: string | Empty): string => {
|
|||
|
||||
export const fillTheGaps = (stats: Stats, labels: string[]): number[] =>
|
||||
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}
|
||||
highlightedVisits={highlightedVisits}
|
||||
highlightedLabel={highlightedLabel}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
/>
|
||||
</div>
|
||||
<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 { keys, values } from 'ramda';
|
||||
import classNames from 'classnames';
|
||||
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 { prettify } from '../../utils/helpers/numbers';
|
||||
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
||||
import './DefaultChart.scss';
|
||||
|
||||
export interface DefaultChartProps {
|
||||
|
@ -145,11 +146,7 @@ const DefaultChart = (
|
|||
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
|
||||
},
|
||||
},
|
||||
onHover: !isBarChart ? undefined : ((e: ChangeEvent<HTMLElement>, chartElement: HorizontalBar[] | Doughnut[]) => {
|
||||
const { target } = e;
|
||||
|
||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
||||
}) as any, // TODO Types seem to be incorrectly defined
|
||||
onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
||||
};
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
|
||||
const height = determineHeight(isBarChart, labels);
|
||||
|
|
|
@ -11,13 +11,14 @@ import {
|
|||
import { Line } from 'react-chartjs-2';
|
||||
import { always, cond, reverse } from 'ramda';
|
||||
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 { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { rangeOf } from '../../utils/utils';
|
||||
import ToggleSwitch from '../../utils/ToggleSwitch';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import { pointerOnHover, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
||||
import './LineChartCard.scss';
|
||||
|
||||
interface LineChartCardProps {
|
||||
|
@ -25,6 +26,7 @@ interface LineChartCardProps {
|
|||
highlightedLabel?: string;
|
||||
visits: NormalizedVisit[];
|
||||
highlightedVisits: NormalizedVisit[];
|
||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
|
||||
}
|
||||
|
||||
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
|
||||
|
@ -71,13 +73,25 @@ const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visi
|
|||
(acc, visit) => {
|
||||
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;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
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 unit = STEP_TO_DATE_UNIT_MAP[step];
|
||||
const formatter = STEP_TO_DATE_FORMAT[step];
|
||||
|
@ -115,12 +129,37 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
|
|||
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>(
|
||||
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
|
||||
);
|
||||
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
|
||||
|
||||
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [ step, visits ]);
|
||||
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
|
||||
const [ labels, groupedVisits ] = useMemo(
|
||||
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
|
||||
|
@ -166,6 +205,7 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
|
|||
label: renderNonDoughnutChartLabel('yLabel'),
|
||||
},
|
||||
},
|
||||
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -193,7 +233,11 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="line-chart-card__body">
|
||||
<Line data={data} options={options} />
|
||||
<Line
|
||||
data={data}
|
||||
options={options}
|
||||
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
@ -53,7 +53,7 @@ describe('<LineChartCard />', () => {
|
|||
const wrapper = createWrapper();
|
||||
const chart = wrapper.find(Line);
|
||||
|
||||
expect(chart.prop('options')).toEqual({
|
||||
expect(chart.prop('options')).toEqual(expect.objectContaining({
|
||||
maintainAspectRatio: false,
|
||||
legend: { display: false },
|
||||
scales: {
|
||||
|
@ -72,7 +72,7 @@ describe('<LineChartCard />', () => {
|
|||
intersect: false,
|
||||
axis: 'x',
|
||||
}),
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
Loading…
Reference in a new issue