Create src folder for shlink-web-component

This commit is contained in:
Alejandro Celaya
2023-08-02 08:23:48 +02:00
parent b7d57a53f2
commit c48facc863
294 changed files with 347 additions and 347 deletions

View File

@@ -0,0 +1,4 @@
.chart-card__footer--sticky {
position: sticky;
bottom: 0;
}

View 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>
);

View 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>
);
});

View 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>
);

View File

@@ -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;
}

View File

@@ -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>
);
};

View 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)}
</>
);
};

View File

@@ -0,0 +1,9 @@
@import '../../../../src/utils/base';
.line-chart-card__body canvas {
height: 300px !important;
@media (min-width: $mdMin) {
height: 400px !important;
}
}

View 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>
);
};

View 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>
);
};