mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-15 20:13:48 +00:00
Create src folder for shlink-web-component
This commit is contained in:
4
shlink-web-component/src/visits/charts/ChartCard.scss
Normal file
4
shlink-web-component/src/visits/charts/ChartCard.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.chart-card__footer--sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
16
shlink-web-component/src/visits/charts/ChartCard.tsx
Normal file
16
shlink-web-component/src/visits/charts/ChartCard.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Card, CardBody, CardFooter, CardHeader } from 'reactstrap';
|
||||
import './ChartCard.scss';
|
||||
|
||||
type ChartCardProps = PropsWithChildren<{
|
||||
title: Function | string;
|
||||
footer?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
|
||||
<Card role="document">
|
||||
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>{children}</CardBody>
|
||||
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
73
shlink-web-component/src/visits/charts/DoughnutChart.tsx
Normal file
73
shlink-web-component/src/visits/charts/DoughnutChart.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||
import { keys, values } from 'ramda';
|
||||
import type { FC } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../../../src/utils/theme';
|
||||
import { renderPieChartLabel } from '../../utils/helpers/charts';
|
||||
import type { Stats } from '../types';
|
||||
import { DoughnutChartLegend } from './DoughnutChartLegend';
|
||||
|
||||
interface DoughnutChartProps {
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
const generateChartDatasets = (data: number[]): ChartDataset[] => [
|
||||
{
|
||||
data,
|
||||
backgroundColor: [
|
||||
'#97BBCD',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#57A773',
|
||||
'#414066',
|
||||
'#08B2E3',
|
||||
'#B6C454',
|
||||
'#DCDCDC',
|
||||
'#463730',
|
||||
],
|
||||
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
||||
borderWidth: 2,
|
||||
},
|
||||
];
|
||||
const generateChartData = (labels: string[], data: number[]): ChartData => ({
|
||||
labels,
|
||||
datasets: generateChartDatasets(data),
|
||||
});
|
||||
|
||||
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
|
||||
const [chartRef, setChartRef] = useState<Chart | undefined>(); // Cannot use useRef here
|
||||
const labels = keys(stats);
|
||||
const data = values(stats);
|
||||
|
||||
const options: ChartOptions = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: true,
|
||||
callbacks: { label: renderPieChartLabel },
|
||||
},
|
||||
},
|
||||
};
|
||||
const chartData = generateChartData(labels, data);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12 col-md-7">
|
||||
<Doughnut
|
||||
height={300}
|
||||
data={chartData as any}
|
||||
options={options as any}
|
||||
ref={(element) => {
|
||||
setChartRef(element ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-12 col-md-5">
|
||||
{chartRef && <DoughnutChartLegend chart={chartRef} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
15
shlink-web-component/src/visits/charts/DoughnutChartCard.tsx
Normal file
15
shlink-web-component/src/visits/charts/DoughnutChartCard.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FC } from 'react';
|
||||
import type { Stats } from '../types';
|
||||
import { ChartCard } from './ChartCard';
|
||||
import { DoughnutChart } from './DoughnutChart';
|
||||
|
||||
interface DoughnutChartCardProps {
|
||||
title: string;
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
|
||||
<ChartCard title={title}>
|
||||
<DoughnutChart stats={stats} />
|
||||
</ChartCard>
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
@import '../../../../src/utils/base';
|
||||
|
||||
.doughnut-chart-legend {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.doughnut-chart-legend__item:not(:first-child) {
|
||||
margin-top: .3rem;
|
||||
}
|
||||
|
||||
.doughnut-chart-legend__item-color {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.doughnut-chart-legend__item-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Chart } from 'chart.js';
|
||||
import type { FC } from 'react';
|
||||
import './DoughnutChartLegend.scss';
|
||||
|
||||
interface DoughnutChartLegendProps {
|
||||
chart: Chart;
|
||||
}
|
||||
|
||||
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
|
||||
const { config } = chart;
|
||||
const { labels = [], datasets = [] } = config.data ?? {};
|
||||
const [{ backgroundColor: colors }] = datasets;
|
||||
const { defaultColor } = config.options ?? {} as any;
|
||||
|
||||
return (
|
||||
<ul className="doughnut-chart-legend">
|
||||
{(labels as string[]).map((label, index) => (
|
||||
<li key={label} className="doughnut-chart-legend__item d-flex">
|
||||
<div
|
||||
className="doughnut-chart-legend__item-color"
|
||||
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
|
||||
/>
|
||||
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
134
shlink-web-component/src/visits/charts/HorizontalBarChart.tsx
Normal file
134
shlink-web-component/src/visits/charts/HorizontalBarChart.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
|
||||
import { keys, values } from 'ramda';
|
||||
import type { FC, MutableRefObject } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Bar, getElementAtEvent } from 'react-chartjs-2';
|
||||
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../../src/utils/theme';
|
||||
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import type { Stats } from '../types';
|
||||
import { fillTheGaps } from '../utils';
|
||||
|
||||
export interface HorizontalBarChartProps {
|
||||
stats: Stats;
|
||||
max?: number;
|
||||
highlightedStats?: Stats;
|
||||
highlightedLabel?: string;
|
||||
onClick?: (label: string) => void;
|
||||
}
|
||||
|
||||
const dropLabelIfHidden = (label: string) => (label.startsWith('hidden') ? '' : label);
|
||||
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
||||
const determineHeight = (labels: string[]): number | undefined => (labels.length > 20 ? labels.length * 10 : undefined);
|
||||
|
||||
const generateChartDatasets = (
|
||||
data: number[],
|
||||
highlightedData: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartDataset[] => {
|
||||
const mainDataset: ChartDataset = {
|
||||
data,
|
||||
label: highlightedLabel ? 'Non-selected' : 'Visits',
|
||||
backgroundColor: MAIN_COLOR_ALPHA,
|
||||
borderColor: MAIN_COLOR,
|
||||
borderWidth: 2,
|
||||
};
|
||||
|
||||
if (highlightedData.every((value) => value === 0)) {
|
||||
return [mainDataset];
|
||||
}
|
||||
|
||||
const highlightedDataset: ChartDataset = {
|
||||
label: highlightedLabel ?? 'Selected',
|
||||
data: highlightedData,
|
||||
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
|
||||
borderColor: HIGHLIGHTED_COLOR,
|
||||
borderWidth: 2,
|
||||
};
|
||||
|
||||
return [mainDataset, highlightedDataset];
|
||||
};
|
||||
const generateChartData = (
|
||||
labels: string[],
|
||||
data: number[],
|
||||
highlightedData: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartData => ({
|
||||
labels,
|
||||
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
|
||||
});
|
||||
|
||||
const chartElementAtEvent = (labels: string[], [chart]: InteractionItem[], onClick?: (label: string) => void) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick(labels[chart.index]);
|
||||
};
|
||||
|
||||
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
|
||||
{ stats, highlightedStats, highlightedLabel, onClick, max },
|
||||
) => {
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
const data = values(
|
||||
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
||||
if (acc[highlightedKey]) {
|
||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { ...stats }),
|
||||
);
|
||||
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
|
||||
const refWithStats = useRef(null);
|
||||
const refWithoutStats = useRef(null);
|
||||
|
||||
const options: ChartOptions = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'y',
|
||||
// Do not show tooltip on items with empty label when in a bar chart
|
||||
filter: ({ label }) => label !== '',
|
||||
callbacks: { label: renderChartLabel },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
stacked: true,
|
||||
max,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
y: { stacked: true },
|
||||
},
|
||||
onHover: pointerOnHover,
|
||||
indexAxis: 'y',
|
||||
};
|
||||
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
|
||||
const height = determineHeight(labels);
|
||||
|
||||
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
|
||||
const renderChartComponent = (customKey: string, theRef: MutableRefObject<any>) => (
|
||||
<Bar
|
||||
ref={theRef}
|
||||
key={`${height}_${customKey}`}
|
||||
data={chartData as any}
|
||||
options={options as any}
|
||||
height={height}
|
||||
onClick={(e) => chartElementAtEvent(labels, getElementAtEvent(theRef.current, e), onClick)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||
{highlightedStats !== undefined && renderChartComponent('with_stats', refWithStats)}
|
||||
{highlightedStats === undefined && renderChartComponent('without_stats', refWithoutStats)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
@import '../../../../src/utils/base';
|
||||
|
||||
.line-chart-card__body canvas {
|
||||
height: 300px !important;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
height: 400px !important;
|
||||
}
|
||||
}
|
||||
275
shlink-web-component/src/visits/charts/LineChartCard.tsx
Normal file
275
shlink-web-component/src/visits/charts/LineChartCard.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
|
||||
import {
|
||||
add,
|
||||
differenceInDays,
|
||||
differenceInHours,
|
||||
differenceInMonths,
|
||||
differenceInWeeks,
|
||||
endOfISOWeek,
|
||||
format,
|
||||
parseISO,
|
||||
startOfISOWeek,
|
||||
} from 'date-fns';
|
||||
import { always, cond, countBy, reverse } from 'ramda';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { getElementAtEvent, Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
UncontrolledDropdown,
|
||||
} from 'reactstrap';
|
||||
import { ToggleSwitch, useToggle } from '../../../../shlink-frontend-kit/src';
|
||||
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../../../src/utils/theme';
|
||||
import { formatInternational } from '../../utils/dates/helpers/date';
|
||||
import { rangeOf } from '../../utils/helpers';
|
||||
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import type { NormalizedVisit, Stats } from '../types';
|
||||
import { fillTheGaps } from '../utils';
|
||||
import './LineChartCard.scss';
|
||||
|
||||
interface LineChartCardProps {
|
||||
title: string;
|
||||
highlightedLabel?: string;
|
||||
visits: NormalizedVisit[];
|
||||
highlightedVisits: NormalizedVisit[];
|
||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
|
||||
}
|
||||
|
||||
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
|
||||
|
||||
const STEPS_MAP: Record<Step, string> = {
|
||||
monthly: 'Month',
|
||||
weekly: 'Week',
|
||||
daily: 'Day',
|
||||
hourly: 'Hour',
|
||||
};
|
||||
|
||||
const STEP_TO_DURATION_MAP: Record<Step, (amount: number) => Duration> = {
|
||||
hourly: (hours: number) => ({ hours }),
|
||||
daily: (days: number) => ({ days }),
|
||||
weekly: (weeks: number) => ({ weeks }),
|
||||
monthly: (months: number) => ({ months }),
|
||||
};
|
||||
|
||||
const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => number> = {
|
||||
hourly: differenceInHours,
|
||||
daily: differenceInDays,
|
||||
weekly: differenceInWeeks,
|
||||
monthly: differenceInMonths,
|
||||
};
|
||||
|
||||
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
|
||||
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
|
||||
// TODO Fix formatInternational return type
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
daily: (date) => formatInternational(date)!,
|
||||
weekly(date) {
|
||||
// TODO Fix formatInternational return type
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const firstWeekDay = formatInternational(startOfISOWeek(date))!;
|
||||
// TODO Fix formatInternational return type
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const lastWeekDay = formatInternational(endOfISOWeek(date))!;
|
||||
|
||||
return `${firstWeekDay} - ${lastWeekDay}`;
|
||||
},
|
||||
monthly: (date) => format(date, 'yyyy-MM'),
|
||||
};
|
||||
|
||||
const determineInitialStep = (oldestVisitDate: string): Step => {
|
||||
const now = new Date();
|
||||
const oldestDate = parseISO(oldestVisitDate);
|
||||
const matcher = cond<never, Step | undefined>([
|
||||
[() => differenceInDays(now, oldestDate) <= 2, always<Step>('hourly')], // Less than 2 days
|
||||
[() => differenceInMonths(now, oldestDate) <= 1, always<Step>('daily')], // Between 2 days and 1 month
|
||||
[() => differenceInMonths(now, oldestDate) <= 6, always<Step>('weekly')], // Between 1 and 6 months
|
||||
]);
|
||||
|
||||
return matcher() ?? 'monthly';
|
||||
};
|
||||
|
||||
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
|
||||
(visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)),
|
||||
visits,
|
||||
);
|
||||
|
||||
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
|
||||
visits.reduce<Record<string, NormalizedVisit[]>>(
|
||||
(acc, visit) => {
|
||||
const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date));
|
||||
|
||||
acc[key] = acc[key] ?? [];
|
||||
acc[key].push(visit);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
|
||||
const diffFunc = STEP_TO_DIFF_FUNC_MAP[step];
|
||||
const formatter = STEP_TO_DATE_FORMAT[step];
|
||||
const newerDate = parseISO(visits[0].date);
|
||||
const oldestDate = parseISO(visits[visits.length - 1].date);
|
||||
const size = diffFunc(newerDate, oldestDate);
|
||||
const duration = STEP_TO_DURATION_MAP[step];
|
||||
|
||||
return [
|
||||
formatter(oldestDate),
|
||||
...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))),
|
||||
];
|
||||
};
|
||||
|
||||
const generateLabelsAndGroupedVisits = (
|
||||
visits: NormalizedVisit[],
|
||||
groupedVisitsWithGaps: Stats,
|
||||
step: Step,
|
||||
skipNoElements: boolean,
|
||||
): [string[], number[]] => {
|
||||
if (skipNoElements) {
|
||||
return [Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps)];
|
||||
}
|
||||
|
||||
const labels = generateLabels(step, visits);
|
||||
|
||||
return [labels, fillTheGaps(groupedVisitsWithGaps, labels)];
|
||||
};
|
||||
|
||||
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
|
||||
label,
|
||||
data,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
});
|
||||
|
||||
let selectedLabel: string | null = null;
|
||||
|
||||
const chartElementAtEvent = (
|
||||
labels: string[],
|
||||
datasetsByPoint: Record<string, NormalizedVisit[]>,
|
||||
[chart]: InteractionItem[],
|
||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
|
||||
) => {
|
||||
if (!setSelectedVisits || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { index } = chart;
|
||||
|
||||
if (selectedLabel === labels[index]) {
|
||||
setSelectedVisits([]);
|
||||
selectedLabel = null;
|
||||
} else {
|
||||
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] ? datasetsByPoint[labels[index]] : []);
|
||||
selectedLabel = labels[index] ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
export 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 refWithHighlightedVisits = useRef(null);
|
||||
const refWithoutHighlightedVisits = useRef(null);
|
||||
|
||||
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),
|
||||
[visits, step, skipNoVisits],
|
||||
);
|
||||
const groupedHighlighted = useMemo(
|
||||
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
||||
[highlightedVisits, step, labels],
|
||||
);
|
||||
const generateChartDatasets = (): ChartDataset[] => {
|
||||
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
|
||||
|
||||
if (highlightedVisits.length === 0) {
|
||||
return [mainDataset];
|
||||
}
|
||||
|
||||
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
|
||||
|
||||
return [mainDataset, highlightedDataset];
|
||||
};
|
||||
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
|
||||
|
||||
const options: ChartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: false,
|
||||
axis: 'x',
|
||||
callbacks: { label: renderChartLabel },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: STEPS_MAP[step] },
|
||||
},
|
||||
},
|
||||
onHover: pointerOnHover,
|
||||
};
|
||||
const renderLineChart = (theRef: MutableRefObject<any>) => (
|
||||
<Line
|
||||
ref={theRef}
|
||||
data={generateChartData() as any}
|
||||
options={options as any}
|
||||
onClick={(e) =>
|
||||
chartElementAtEvent(labels, datasetsByPoint, getElementAtEvent(theRef.current, e), setSelectedVisits)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader role="heading">
|
||||
{title}
|
||||
<div className="float-end">
|
||||
<UncontrolledDropdown>
|
||||
<DropdownToggle caret color="link" className="btn-sm p-0">
|
||||
Group by
|
||||
</DropdownToggle>
|
||||
<DropdownMenu end>
|
||||
{Object.entries(STEPS_MAP).map(([value, menuText]) => (
|
||||
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
|
||||
{menuText}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
</div>
|
||||
<div className="float-end me-2">
|
||||
<ToggleSwitch checked={skipNoVisits} onChange={toggleSkipNoVisits}>
|
||||
<small>Skip dates with no visits</small>
|
||||
</ToggleSwitch>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="line-chart-card__body">
|
||||
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||
{highlightedVisits.length > 0 && renderLineChart(refWithHighlightedVisits)}
|
||||
{highlightedVisits.length === 0 && renderLineChart(refWithoutHighlightedVisits)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
141
shlink-web-component/src/visits/charts/SortableBarChartCard.tsx
Normal file
141
shlink-web-component/src/visits/charts/SortableBarChartCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { Order } from '../../../../shlink-frontend-kit/src';
|
||||
import { OrderingDropdown } from '../../../../shlink-frontend-kit/src';
|
||||
import { PaginationDropdown } from '../../utils/components/PaginationDropdown';
|
||||
import { SimplePaginator } from '../../utils/components/SimplePaginator';
|
||||
import { rangeOf } from '../../utils/helpers';
|
||||
import { roundTen } from '../../utils/helpers/numbers';
|
||||
import type { Stats, StatsRow } from '../types';
|
||||
import { ChartCard } from './ChartCard';
|
||||
import type { HorizontalBarChartProps } from './HorizontalBarChart';
|
||||
import { HorizontalBarChart } from './HorizontalBarChart';
|
||||
|
||||
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
|
||||
title: Function | string;
|
||||
sortingItems: Record<string, string>;
|
||||
withPagination?: boolean;
|
||||
extraHeaderContent?: (activeCities?: string[]) => ReactNode;
|
||||
}
|
||||
|
||||
const toLowerIfString = (value: any) => (type(value) === 'String' ? toLower(value) : value);
|
||||
const pickKeyFromPair = ([key]: StatsRow) => key;
|
||||
const pickValueFromPair = ([, value]: StatsRow) => value;
|
||||
|
||||
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||
stats,
|
||||
highlightedStats,
|
||||
title,
|
||||
sortingItems,
|
||||
extraHeaderContent,
|
||||
withPagination = true,
|
||||
...rest
|
||||
}) => {
|
||||
const [order, setOrder] = useState<Order<string>>({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(50);
|
||||
|
||||
const getSortedPairsForStats = (statsToSort: Stats, sorting: Record<string, string>) => {
|
||||
const pairs = toPairs(statsToSort);
|
||||
const sortedPairs = !order.field ? pairs : sortBy(
|
||||
pipe<StatsRow[], string | number, string | number>(
|
||||
order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair,
|
||||
toLowerIfString,
|
||||
),
|
||||
pairs,
|
||||
);
|
||||
|
||||
return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
};
|
||||
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
|
||||
const page = pages[currentPage - 1];
|
||||
|
||||
if (currentPage < pages.length) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const firstPageLength = pages[0].length;
|
||||
|
||||
// Using the "hidden" key, the chart will just replace the label by an empty string
|
||||
return [...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [`hidden_${i}`, 0])];
|
||||
};
|
||||
const renderPagination = (pagesCount: number) =>
|
||||
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
|
||||
const determineStats = (statsToSort: Stats, sorting: Record<string, string>, theHighlightedStats?: Stats) => {
|
||||
const sortedPairs = getSortedPairsForStats(statsToSort, sorting);
|
||||
const sortedKeys = sortedPairs.map(pickKeyFromPair);
|
||||
// The highlighted stats have to be ordered based on the regular stats, not on its own values
|
||||
const sortedHighlightedPairs = theHighlightedStats && toPairs(
|
||||
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...theHighlightedStats },
|
||||
);
|
||||
|
||||
if (sortedPairs.length <= itemsPerPage) {
|
||||
return {
|
||||
currentPageStats: fromPairs(sortedPairs),
|
||||
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
|
||||
};
|
||||
}
|
||||
|
||||
const pages = splitEvery(itemsPerPage, sortedPairs);
|
||||
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
|
||||
|
||||
return {
|
||||
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
|
||||
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
|
||||
pagination: renderPagination(pages.length),
|
||||
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
|
||||
};
|
||||
};
|
||||
|
||||
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
|
||||
stats,
|
||||
sortingItems,
|
||||
highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined,
|
||||
);
|
||||
const activeCities = Object.keys(currentPageStats);
|
||||
const computeTitle = () => (
|
||||
<>
|
||||
{title}
|
||||
<div className="float-end">
|
||||
<OrderingDropdown
|
||||
isButton={false}
|
||||
right
|
||||
items={sortingItems}
|
||||
order={order}
|
||||
onChange={(field, dir) => {
|
||||
setOrder({ field, dir });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{withPagination && Object.keys(stats).length > 50 && (
|
||||
<div className="float-end">
|
||||
<PaginationDropdown
|
||||
toggleClassName="btn-sm p-0 me-3"
|
||||
ranges={[50, 100, 200, 500]}
|
||||
value={itemsPerPage}
|
||||
setValue={(value) => {
|
||||
setItemsPerPage(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{extraHeaderContent && (
|
||||
<div className="float-end">
|
||||
{extraHeaderContent(pagination ? activeCities : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={computeTitle}
|
||||
footer={pagination}
|
||||
>
|
||||
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user