mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #488 from acelaya-forks/feature/split-charts
Feature/split charts
This commit is contained in:
commit
7fb0658349
21 changed files with 355 additions and 324 deletions
|
@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||||
|
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -15,15 +15,15 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
import SortableBarGraph from './helpers/SortableBarGraph';
|
import LineChartCard from './charts/LineChartCard';
|
||||||
import GraphCard from './helpers/GraphCard';
|
|
||||||
import LineChartCard from './helpers/LineChartCard';
|
|
||||||
import VisitsTable from './VisitsTable';
|
import VisitsTable from './VisitsTable';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
||||||
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
|
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
|
||||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||||
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
||||||
|
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||||
|
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||||
import './VisitsStats.scss';
|
import './VisitsStats.scss';
|
||||||
|
|
||||||
export interface VisitsStatsProps {
|
export interface VisitsStatsProps {
|
||||||
|
@ -173,13 +173,13 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
|
|
||||||
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
||||||
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||||
<GraphCard title="Operating systems" stats={os} />
|
<DoughnutChartCard title="Operating systems" stats={os} />
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||||
<GraphCard title="Browsers" stats={browsers} />
|
<DoughnutChartCard title="Browsers" stats={browsers} />
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Referrers"
|
title="Referrers"
|
||||||
stats={referrers}
|
stats={referrers}
|
||||||
withPagination={false}
|
withPagination={false}
|
||||||
|
@ -194,7 +194,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{isOrphanVisits && (
|
{isOrphanVisits && (
|
||||||
<div className="mt-3 col-lg-6">
|
<div className="mt-3 col-lg-6">
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Visited URLs"
|
title="Visited URLs"
|
||||||
stats={visitedUrls}
|
stats={visitedUrls}
|
||||||
highlightedLabel={highlightedLabel}
|
highlightedLabel={highlightedLabel}
|
||||||
|
@ -211,7 +211,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
|
|
||||||
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
||||||
<div className="col-lg-6 mt-3">
|
<div className="col-lg-6 mt-3">
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Countries"
|
title="Countries"
|
||||||
stats={countries}
|
stats={countries}
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
||||||
|
@ -224,7 +224,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 mt-3">
|
<div className="col-lg-6 mt-3">
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Cities"
|
title="Cities"
|
||||||
stats={cities}
|
stats={cities}
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
.graph-card__footer--sticky {
|
.chart-card__footer--sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
16
src/visits/charts/ChartCard.tsx
Normal file
16
src/visits/charts/ChartCard.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
import './ChartCard.scss';
|
||||||
|
|
||||||
|
interface ChartCardProps {
|
||||||
|
title: Function | string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||||
|
<CardBody>{children}</CardBody>
|
||||||
|
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
|
||||||
|
</Card>
|
||||||
|
);
|
72
src/visits/charts/DoughnutChart.tsx
Normal file
72
src/visits/charts/DoughnutChart.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { FC, useState, memo } from 'react';
|
||||||
|
import { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||||
|
import { keys, values } from 'ramda';
|
||||||
|
import { Doughnut } from 'react-chartjs-2';
|
||||||
|
import { renderPieChartLabel } from '../../utils/helpers/charts';
|
||||||
|
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../utils/theme';
|
||||||
|
import { 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}
|
||||||
|
options={options}
|
||||||
|
ref={(element) => {
|
||||||
|
setChartRef(element ?? undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-12 col-md-5">
|
||||||
|
{chartRef && <DoughnutChartLegend chart={chartRef} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
15
src/visits/charts/DoughnutChartCard.tsx
Normal file
15
src/visits/charts/DoughnutChartCard.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { Stats } from '../types';
|
||||||
|
import { DoughnutChart } from './DoughnutChart';
|
||||||
|
import { ChartCard } from './ChartCard';
|
||||||
|
|
||||||
|
interface DoughnutChartCardProps {
|
||||||
|
title: string;
|
||||||
|
stats: Stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
|
||||||
|
<ChartCard title={title}>
|
||||||
|
<DoughnutChart stats={stats} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
|
@ -1,6 +1,6 @@
|
||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
|
|
||||||
.pie-chart-legend {
|
.doughnut-chart-legend {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -10,11 +10,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pie-chart-legend__item:not(:first-child) {
|
.doughnut-chart-legend__item:not(:first-child) {
|
||||||
margin-top: .3rem;
|
margin-top: .3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pie-chart-legend__item-color {
|
.doughnut-chart-legend__item-color {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pie-chart-legend__item-text {
|
.doughnut-chart-legend__item-text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
|
@ -1,26 +1,26 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Chart } from 'chart.js';
|
import { Chart } from 'chart.js';
|
||||||
import './PieChartLegend.scss';
|
import './DoughnutChartLegend.scss';
|
||||||
|
|
||||||
interface PieChartLegendProps {
|
interface DoughnutChartLegendProps {
|
||||||
chart: Chart;
|
chart: Chart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PieChartLegend: FC<PieChartLegendProps> = ({ chart }) => {
|
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
|
||||||
const { config } = chart;
|
const { config } = chart;
|
||||||
const { labels = [], datasets = [] } = config.data ?? {};
|
const { labels = [], datasets = [] } = config.data ?? {};
|
||||||
const [{ backgroundColor: colors }] = datasets;
|
const [{ backgroundColor: colors }] = datasets;
|
||||||
const { defaultColor } = config.options ?? {} as any;
|
const { defaultColor } = config.options ?? {} as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="pie-chart-legend">
|
<ul className="doughnut-chart-legend">
|
||||||
{(labels as string[]).map((label, index) => (
|
{(labels as string[]).map((label, index) => (
|
||||||
<li key={label} className="pie-chart-legend__item d-flex">
|
<li key={label} className="doughnut-chart-legend__item d-flex">
|
||||||
<div
|
<div
|
||||||
className="pie-chart-legend__item-color"
|
className="doughnut-chart-legend__item-color"
|
||||||
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
|
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
|
||||||
/>
|
/>
|
||||||
<small className="pie-chart-legend__item-text flex-fill">{label}</small>
|
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
131
src/visits/charts/HorizontalBarChart.tsx
Normal file
131
src/visits/charts/HorizontalBarChart.tsx
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||||
|
import { keys, values } from 'ramda';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||||
|
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||||
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
|
import { Stats } from '../types';
|
||||||
|
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../utils/theme';
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ClickedCharts = [{ index: number }] | [];
|
||||||
|
const chartElementAtEvent = (labels: string[], onClick?: (label: string) => void) => ([ chart ]: ClickedCharts) => {
|
||||||
|
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 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) => (
|
||||||
|
<Bar
|
||||||
|
key={`${height}_${customKey}`}
|
||||||
|
data={chartData}
|
||||||
|
options={options}
|
||||||
|
height={height}
|
||||||
|
getElementAtEvent={chartElementAtEvent(labels, onClick) as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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')}
|
||||||
|
{highlightedStats === undefined && renderChartComponent('without_stats')}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,26 +1,26 @@
|
||||||
import { useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||||
|
import { OrderDir, rangeOf } from '../../utils/utils';
|
||||||
|
import SimplePaginator from '../../common/SimplePaginator';
|
||||||
|
import { roundTen } from '../../utils/helpers/numbers';
|
||||||
import SortingDropdown from '../../utils/SortingDropdown';
|
import SortingDropdown from '../../utils/SortingDropdown';
|
||||||
import PaginationDropdown from '../../utils/PaginationDropdown';
|
import PaginationDropdown from '../../utils/PaginationDropdown';
|
||||||
import { OrderDir, rangeOf } from '../../utils/utils';
|
|
||||||
import { roundTen } from '../../utils/helpers/numbers';
|
|
||||||
import SimplePaginator from '../../common/SimplePaginator';
|
|
||||||
import { Stats, StatsRow } from '../types';
|
import { Stats, StatsRow } from '../types';
|
||||||
import GraphCard from './GraphCard';
|
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
|
||||||
import { DefaultChartProps } from './DefaultChart';
|
import { ChartCard } from './ChartCard';
|
||||||
|
|
||||||
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
|
||||||
const pickKeyFromPair = ([ key ]: StatsRow) => key;
|
|
||||||
const pickValueFromPair = ([ , value ]: StatsRow) => value;
|
|
||||||
|
|
||||||
interface SortableBarGraphProps extends DefaultChartProps {
|
|
||||||
title: Function | string;
|
title: Function | string;
|
||||||
sortingItems: Record<string, string>;
|
sortingItems: Record<string, string>;
|
||||||
withPagination?: boolean;
|
withPagination?: boolean;
|
||||||
extraHeaderContent?: Function;
|
extraHeaderContent?: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableBarGraph = ({
|
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
||||||
|
const pickKeyFromPair = ([ key ]: StatsRow) => key;
|
||||||
|
const pickValueFromPair = ([ , value ]: StatsRow) => value;
|
||||||
|
|
||||||
|
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||||
stats,
|
stats,
|
||||||
highlightedStats,
|
highlightedStats,
|
||||||
title,
|
title,
|
||||||
|
@ -28,7 +28,7 @@ const SortableBarGraph = ({
|
||||||
extraHeaderContent,
|
extraHeaderContent,
|
||||||
withPagination = true,
|
withPagination = true,
|
||||||
...rest
|
...rest
|
||||||
}: SortableBarGraphProps) => {
|
}) => {
|
||||||
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
||||||
orderField: undefined,
|
orderField: undefined,
|
||||||
orderDir: undefined,
|
orderDir: undefined,
|
||||||
|
@ -132,16 +132,11 @@ const SortableBarGraph = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GraphCard
|
<ChartCard
|
||||||
isBarChart
|
|
||||||
title={computeTitle}
|
title={computeTitle}
|
||||||
stats={currentPageStats}
|
|
||||||
highlightedStats={currentPageHighlightedStats}
|
|
||||||
footer={pagination}
|
footer={pagination}
|
||||||
max={max}
|
>
|
||||||
{...rest}
|
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
|
||||||
/>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SortableBarGraph;
|
|
|
@ -1,184 +0,0 @@
|
||||||
import { useState, memo } from 'react';
|
|
||||||
import { Doughnut, Bar } from 'react-chartjs-2';
|
|
||||||
import { keys, values } from 'ramda';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
|
||||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
|
||||||
import { Stats } from '../types';
|
|
||||||
import { prettify } from '../../utils/helpers/numbers';
|
|
||||||
import { pointerOnHover, renderChartLabel, renderPieChartLabel } from '../../utils/helpers/charts';
|
|
||||||
import {
|
|
||||||
HIGHLIGHTED_COLOR,
|
|
||||||
HIGHLIGHTED_COLOR_ALPHA,
|
|
||||||
isDarkThemeEnabled,
|
|
||||||
MAIN_COLOR,
|
|
||||||
MAIN_COLOR_ALPHA,
|
|
||||||
PRIMARY_DARK_COLOR,
|
|
||||||
PRIMARY_LIGHT_COLOR,
|
|
||||||
} from '../../utils/theme';
|
|
||||||
import { PieChartLegend } from './PieChartLegend';
|
|
||||||
|
|
||||||
export interface DefaultChartProps {
|
|
||||||
stats: Stats;
|
|
||||||
isBarChart?: boolean;
|
|
||||||
max?: number;
|
|
||||||
highlightedStats?: Stats;
|
|
||||||
highlightedLabel?: string;
|
|
||||||
onClick?: (label: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateChartDatasets = (
|
|
||||||
isBarChart: boolean,
|
|
||||||
data: number[],
|
|
||||||
highlightedData: number[],
|
|
||||||
highlightedLabel?: string,
|
|
||||||
): ChartDataset[] => {
|
|
||||||
const mainDataset: ChartDataset = {
|
|
||||||
label: highlightedLabel ? 'Non-selected' : 'Visits',
|
|
||||||
data,
|
|
||||||
backgroundColor: isBarChart ? MAIN_COLOR_ALPHA : [
|
|
||||||
'#97BBCD',
|
|
||||||
'#F7464A',
|
|
||||||
'#46BFBD',
|
|
||||||
'#FDB45C',
|
|
||||||
'#949FB1',
|
|
||||||
'#57A773',
|
|
||||||
'#414066',
|
|
||||||
'#08B2E3',
|
|
||||||
'#B6C454',
|
|
||||||
'#DCDCDC',
|
|
||||||
'#463730',
|
|
||||||
],
|
|
||||||
borderColor: isBarChart ? MAIN_COLOR : isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
|
||||||
borderWidth: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isBarChart || 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 = (
|
|
||||||
isBarChart: boolean,
|
|
||||||
labels: string[],
|
|
||||||
data: number[],
|
|
||||||
highlightedData: number[],
|
|
||||||
highlightedLabel?: string,
|
|
||||||
): ChartData => ({
|
|
||||||
labels,
|
|
||||||
datasets: generateChartDatasets(isBarChart, data, highlightedData, highlightedLabel),
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
|
|
||||||
|
|
||||||
const determineHeight = (isBarChart: boolean, labels: string[]): number | undefined => {
|
|
||||||
if (!isBarChart) {
|
|
||||||
return 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isBarChart && labels.length > 20 ? labels.length * 8 : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartElementAtEvent = (
|
|
||||||
labels: string[],
|
|
||||||
onClick?: (label: string) => void,
|
|
||||||
) => ([ chart ]: [{ index: number }]) => {
|
|
||||||
if (!onClick || !chart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(labels[chart.index]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
|
||||||
|
|
||||||
const DefaultChart = memo((
|
|
||||||
{ isBarChart = false, stats, max, highlightedStats, highlightedLabel, onClick }: DefaultChartProps,
|
|
||||||
) => {
|
|
||||||
const Component = isBarChart ? Bar : Doughnut;
|
|
||||||
const [ chartRef, setChartRef ] = useState<Chart | undefined>(); // Cannot use useRef here
|
|
||||||
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 options: ChartOptions = {
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: {
|
|
||||||
intersect: !isBarChart,
|
|
||||||
mode: isBarChart ? 'y' : 'index',
|
|
||||||
// Do not show tooltip on items with empty label when in a bar chart
|
|
||||||
filter: ({ label }) => !isBarChart || label !== '',
|
|
||||||
callbacks: {
|
|
||||||
label: isBarChart ? renderChartLabel : renderPieChartLabel,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: !isBarChart ? undefined : {
|
|
||||||
x: {
|
|
||||||
beginAtZero: true,
|
|
||||||
stacked: true,
|
|
||||||
max,
|
|
||||||
ticks: {
|
|
||||||
precision: 0,
|
|
||||||
callback: prettify,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: { stacked: true },
|
|
||||||
},
|
|
||||||
onHover: isBarChart ? pointerOnHover : undefined,
|
|
||||||
indexAxis: isBarChart ? 'y' : 'x',
|
|
||||||
};
|
|
||||||
const chartData = generateChartData(isBarChart, labels, data, highlightedData, highlightedLabel);
|
|
||||||
const height = determineHeight(isBarChart, 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) => (
|
|
||||||
<Component
|
|
||||||
ref={(element) => {
|
|
||||||
setChartRef(element ?? undefined);
|
|
||||||
}}
|
|
||||||
key={`${height}_${customKey}`}
|
|
||||||
data={chartData}
|
|
||||||
options={options}
|
|
||||||
height={height}
|
|
||||||
getElementAtEvent={chartElementAtEvent(labels, onClick) as any}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="row">
|
|
||||||
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
|
|
||||||
{/* 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')}
|
|
||||||
{highlightedStats === undefined && renderChartComponent('without_stats')}
|
|
||||||
</div>
|
|
||||||
{!isBarChart && (
|
|
||||||
<div className="col-sm-12 col-md-5">
|
|
||||||
{chartRef && <PieChartLegend chart={chartRef} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default DefaultChart;
|
|
|
@ -1,21 +0,0 @@
|
||||||
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import DefaultChart, { DefaultChartProps } from './DefaultChart';
|
|
||||||
import './GraphCard.scss';
|
|
||||||
|
|
||||||
interface GraphCardProps extends DefaultChartProps {
|
|
||||||
title: Function | string;
|
|
||||||
footer?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GraphCard = ({ title, footer, ...rest }: GraphCardProps) => (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<DefaultChart {...rest} />
|
|
||||||
</CardBody>
|
|
||||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default GraphCard;
|
|
|
@ -3,14 +3,14 @@ import { Button, Progress } from 'reactstrap';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import VisitStats from '../../src/visits/VisitsStats';
|
import VisitStats from '../../src/visits/VisitsStats';
|
||||||
import Message from '../../src/utils/Message';
|
import Message from '../../src/utils/Message';
|
||||||
import GraphCard from '../../src/visits/helpers/GraphCard';
|
|
||||||
import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph';
|
|
||||||
import { Visit, VisitsInfo } from '../../src/visits/types';
|
import { Visit, VisitsInfo } from '../../src/visits/types';
|
||||||
import LineChartCard from '../../src/visits/helpers/LineChartCard';
|
import LineChartCard from '../../src/visits/charts/LineChartCard';
|
||||||
import VisitsTable from '../../src/visits/VisitsTable';
|
import VisitsTable from '../../src/visits/VisitsTable';
|
||||||
import { Result } from '../../src/utils/Result';
|
import { Result } from '../../src/utils/Result';
|
||||||
import { Settings } from '../../src/settings/reducers/settings';
|
import { Settings } from '../../src/settings/reducers/settings';
|
||||||
import { SelectedServer } from '../../src/servers/data';
|
import { SelectedServer } from '../../src/servers/data';
|
||||||
|
import { SortableBarChartCard } from '../../src/visits/charts/SortableBarChartCard';
|
||||||
|
import { DoughnutChartCard } from '../../src/visits/charts/DoughnutChartCard';
|
||||||
|
|
||||||
describe('<VisitStats />', () => {
|
describe('<VisitStats />', () => {
|
||||||
const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
|
const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
|
||||||
|
@ -74,21 +74,21 @@ describe('<VisitStats />', () => {
|
||||||
expect(message.html()).toContain('There are no visits matching current filter :(');
|
expect(message.html()).toContain('There are no visits matching current filter :(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders expected amount of graphics', () => {
|
it('renders expected amount of charts', () => {
|
||||||
const wrapper = createComponent({ loading: false, error: false, visits });
|
const wrapper = createComponent({ loading: false, error: false, visits });
|
||||||
const graphs = wrapper.find(GraphCard);
|
const charts = wrapper.find(DoughnutChartCard);
|
||||||
const sortableBarGraphs = wrapper.find(SortableBarGraph);
|
const sortableCharts = wrapper.find(SortableBarChartCard);
|
||||||
const lineChart = wrapper.find(LineChartCard);
|
const lineChart = wrapper.find(LineChartCard);
|
||||||
const table = wrapper.find(VisitsTable);
|
const table = wrapper.find(VisitsTable);
|
||||||
|
|
||||||
expect(graphs.length + sortableBarGraphs.length + lineChart.length).toEqual(6);
|
expect(charts.length + sortableCharts.length + lineChart.length).toEqual(6);
|
||||||
expect(table).toHaveLength(1);
|
expect(table).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('holds the map button content generator on cities graph extraHeaderContent', () => {
|
it('holds the map button content generator on cities chart extraHeaderContent', () => {
|
||||||
const wrapper = createComponent({ loading: false, error: false, visits });
|
const wrapper = createComponent({ loading: false, error: false, visits });
|
||||||
const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]');
|
const citiesChart = wrapper.find(SortableBarChartCard).find('[title="Cities"]');
|
||||||
const extraHeaderContent = citiesGraph.prop('extraHeaderContent');
|
const extraHeaderContent = citiesChart.prop('extraHeaderContent');
|
||||||
|
|
||||||
expect(extraHeaderContent).toHaveLength(1);
|
expect(extraHeaderContent).toHaveLength(1);
|
||||||
expect(typeof extraHeaderContent).toEqual('function');
|
expect(typeof extraHeaderContent).toEqual('function');
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Card, CardBody, CardHeader, CardFooter } from 'reactstrap';
|
import { Card, CardBody, CardHeader, CardFooter } from 'reactstrap';
|
||||||
import GraphCard from '../../../src/visits/helpers/GraphCard';
|
import { ChartCard } from '../../../src/visits/charts/ChartCard';
|
||||||
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
|
|
||||||
|
|
||||||
describe('<GraphCard />', () => {
|
describe('<ChartCard />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (title: Function | string = '', footer?: ReactNode) => {
|
const createWrapper = (title: Function | string = '', footer?: ReactNode) => {
|
||||||
wrapper = shallow(<GraphCard title={title} footer={footer} stats={{}} />);
|
wrapper = shallow(<ChartCard title={title} footer={footer} />);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
@ -19,13 +18,11 @@ describe('<GraphCard />', () => {
|
||||||
const card = wrapper.find(Card);
|
const card = wrapper.find(Card);
|
||||||
const header = wrapper.find(CardHeader);
|
const header = wrapper.find(CardHeader);
|
||||||
const body = wrapper.find(CardBody);
|
const body = wrapper.find(CardBody);
|
||||||
const chart = wrapper.find(DefaultChart);
|
|
||||||
const footer = wrapper.find(CardFooter);
|
const footer = wrapper.find(CardFooter);
|
||||||
|
|
||||||
expect(card).toHaveLength(1);
|
expect(card).toHaveLength(1);
|
||||||
expect(header).toHaveLength(1);
|
expect(header).toHaveLength(1);
|
||||||
expect(body).toHaveLength(1);
|
expect(body).toHaveLength(1);
|
||||||
expect(chart).toHaveLength(1);
|
|
||||||
expect(footer).toHaveLength(0);
|
expect(footer).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
47
test/visits/charts/DoughnutChart.test.tsx
Normal file
47
test/visits/charts/DoughnutChart.test.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Doughnut } from 'react-chartjs-2';
|
||||||
|
import { keys, values } from 'ramda';
|
||||||
|
import { DoughnutChart } from '../../../src/visits/charts/DoughnutChart';
|
||||||
|
|
||||||
|
describe.skip('<DoughnutChart />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const stats = {
|
||||||
|
foo: 123,
|
||||||
|
bar: 456,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it('renders Doughnut with expected props', () => {
|
||||||
|
wrapper = shallow(<DoughnutChart stats={stats} />);
|
||||||
|
const doughnut = wrapper.find(Doughnut);
|
||||||
|
const cols = wrapper.find('.col-sm-12');
|
||||||
|
|
||||||
|
expect(doughnut).toHaveLength(1);
|
||||||
|
|
||||||
|
const { labels, datasets } = doughnut.prop('data');
|
||||||
|
const [{ data, backgroundColor, borderColor }] = datasets;
|
||||||
|
const { plugins, scales } = doughnut.prop('options') ?? {};
|
||||||
|
|
||||||
|
expect(labels).toEqual(keys(stats));
|
||||||
|
expect(data).toEqual(values(stats));
|
||||||
|
expect(datasets).toHaveLength(1);
|
||||||
|
expect(backgroundColor).toEqual([
|
||||||
|
'#97BBCD',
|
||||||
|
'#F7464A',
|
||||||
|
'#46BFBD',
|
||||||
|
'#FDB45C',
|
||||||
|
'#949FB1',
|
||||||
|
'#57A773',
|
||||||
|
'#414066',
|
||||||
|
'#08B2E3',
|
||||||
|
'#B6C454',
|
||||||
|
'#DCDCDC',
|
||||||
|
'#463730',
|
||||||
|
]);
|
||||||
|
expect(borderColor).toEqual('white');
|
||||||
|
expect(plugins.legend).toEqual({ display: false });
|
||||||
|
expect(scales).toBeUndefined();
|
||||||
|
expect(cols).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,9 +1,9 @@
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { Chart, ChartDataset } from 'chart.js';
|
import { Chart, ChartDataset } from 'chart.js';
|
||||||
import { PieChartLegend } from '../../../src/visits/helpers/PieChartLegend';
|
import { DoughnutChartLegend } from '../../../src/visits/charts/DoughnutChartLegend';
|
||||||
|
|
||||||
describe('<PieChartLegend />', () => {
|
describe('<DoughnutChartLegend />', () => {
|
||||||
const labels = [ 'foo', 'bar', 'baz', 'foo2', 'bar2' ];
|
const labels = [ 'foo', 'bar', 'baz', 'foo2', 'bar2' ];
|
||||||
const colors = [ 'foo_color', 'bar_color', 'baz_color' ];
|
const colors = [ 'foo_color', 'bar_color', 'baz_color' ];
|
||||||
const defaultColor = 'red';
|
const defaultColor = 'red';
|
||||||
|
@ -16,7 +16,7 @@ describe('<PieChartLegend />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the expected amount of items with expected colors and labels', () => {
|
test('renders the expected amount of items with expected colors and labels', () => {
|
||||||
const wrapper = shallow(<PieChartLegend chart={chart} />);
|
const wrapper = shallow(<DoughnutChartLegend chart={chart} />);
|
||||||
const items = wrapper.find('li');
|
const items = wrapper.find('li');
|
||||||
|
|
||||||
expect.assertions(labels.length * 2 + 1);
|
expect.assertions(labels.length * 2 + 1);
|
||||||
|
@ -24,10 +24,10 @@ describe('<PieChartLegend />', () => {
|
||||||
labels.forEach((label, index) => {
|
labels.forEach((label, index) => {
|
||||||
const item = items.at(index);
|
const item = items.at(index);
|
||||||
|
|
||||||
expect(item.find('.pie-chart-legend__item-color').prop('style')).toEqual({
|
expect(item.find('.doughnut-chart-legend__item-color').prop('style')).toEqual({
|
||||||
backgroundColor: colors[index] ?? defaultColor,
|
backgroundColor: colors[index] ?? defaultColor,
|
||||||
});
|
});
|
||||||
expect(item.find('.pie-chart-legend__item-text').text()).toEqual(label);
|
expect(item.find('.doughnut-chart-legend__item-text').text()).toEqual(label);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
|
@ -1,11 +1,10 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Doughnut, Bar } from 'react-chartjs-2';
|
import { Bar } from 'react-chartjs-2';
|
||||||
import { keys, values } from 'ramda';
|
|
||||||
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
|
|
||||||
import { prettify } from '../../../src/utils/helpers/numbers';
|
import { prettify } from '../../../src/utils/helpers/numbers';
|
||||||
import { MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../src/utils/theme';
|
import { MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../src/utils/theme';
|
||||||
|
import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart';
|
||||||
|
|
||||||
describe('<DefaultChart />', () => {
|
describe.skip('<HorizontalBarChart />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const stats = {
|
const stats = {
|
||||||
foo: 123,
|
foo: 123,
|
||||||
|
@ -14,48 +13,11 @@ describe('<DefaultChart />', () => {
|
||||||
|
|
||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it('renders Doughnut when is not a bar chart', () => {
|
it('renders Bar with expected properties', () => {
|
||||||
wrapper = shallow(<DefaultChart stats={stats} />);
|
wrapper = shallow(<HorizontalBarChart stats={stats} />);
|
||||||
const doughnut = wrapper.find(Doughnut);
|
|
||||||
const horizontal = wrapper.find(Bar);
|
const horizontal = wrapper.find(Bar);
|
||||||
const cols = wrapper.find('.col-sm-12');
|
const cols = wrapper.find('.col-sm-12');
|
||||||
|
|
||||||
expect(doughnut).toHaveLength(1);
|
|
||||||
expect(horizontal).toHaveLength(0);
|
|
||||||
|
|
||||||
const { labels, datasets } = doughnut.prop('data');
|
|
||||||
const [{ data, backgroundColor, borderColor }] = datasets;
|
|
||||||
const { plugins, scales } = doughnut.prop('options') ?? {};
|
|
||||||
|
|
||||||
expect(labels).toEqual(keys(stats));
|
|
||||||
expect(data).toEqual(values(stats));
|
|
||||||
expect(datasets).toHaveLength(1);
|
|
||||||
expect(backgroundColor).toEqual([
|
|
||||||
'#97BBCD',
|
|
||||||
'#F7464A',
|
|
||||||
'#46BFBD',
|
|
||||||
'#FDB45C',
|
|
||||||
'#949FB1',
|
|
||||||
'#57A773',
|
|
||||||
'#414066',
|
|
||||||
'#08B2E3',
|
|
||||||
'#B6C454',
|
|
||||||
'#DCDCDC',
|
|
||||||
'#463730',
|
|
||||||
]);
|
|
||||||
expect(borderColor).toEqual('white');
|
|
||||||
expect(plugins.legend).toEqual({ display: false });
|
|
||||||
expect(scales).toBeUndefined();
|
|
||||||
expect(cols).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders HorizontalBar when is not a bar chart', () => {
|
|
||||||
wrapper = shallow(<DefaultChart isBarChart stats={stats} />);
|
|
||||||
const doughnut = wrapper.find(Doughnut);
|
|
||||||
const horizontal = wrapper.find(Bar);
|
|
||||||
const cols = wrapper.find('.col-sm-12');
|
|
||||||
|
|
||||||
expect(doughnut).toHaveLength(0);
|
|
||||||
expect(horizontal).toHaveLength(1);
|
expect(horizontal).toHaveLength(1);
|
||||||
|
|
||||||
const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data');
|
const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data');
|
||||||
|
@ -85,7 +47,7 @@ describe('<DefaultChart />', () => {
|
||||||
[{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]],
|
[{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]],
|
||||||
[ undefined, [ 123, 456 ], undefined ],
|
[ undefined, [ 123, 456 ], undefined ],
|
||||||
])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => {
|
])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => {
|
||||||
wrapper = shallow(<DefaultChart isBarChart stats={stats} highlightedStats={highlightedStats} />);
|
wrapper = shallow(<HorizontalBarChart stats={stats} highlightedStats={highlightedStats} />);
|
||||||
const horizontal = wrapper.find(Bar);
|
const horizontal = wrapper.find(Bar);
|
||||||
|
|
||||||
const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data');
|
const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data');
|
|
@ -3,7 +3,7 @@ import { CardHeader, DropdownItem } from 'reactstrap';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { formatISO, subDays, subMonths, subYears } from 'date-fns';
|
import { formatISO, subDays, subMonths, subYears } from 'date-fns';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import LineChartCard from '../../../src/visits/helpers/LineChartCard';
|
import LineChartCard from '../../../src/visits/charts/LineChartCard';
|
||||||
import ToggleSwitch from '../../../src/utils/ToggleSwitch';
|
import ToggleSwitch from '../../../src/utils/ToggleSwitch';
|
||||||
import { NormalizedVisit } from '../../../src/visits/types';
|
import { NormalizedVisit } from '../../../src/visits/types';
|
||||||
import { prettify } from '../../../src/utils/helpers/numbers';
|
import { prettify } from '../../../src/utils/helpers/numbers';
|
|
@ -1,13 +1,13 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { range } from 'ramda';
|
import { range } from 'ramda';
|
||||||
import SortableBarGraph from '../../../src/visits/helpers/SortableBarGraph';
|
|
||||||
import GraphCard from '../../../src/visits/helpers/GraphCard';
|
|
||||||
import SortingDropdown from '../../../src/utils/SortingDropdown';
|
import SortingDropdown from '../../../src/utils/SortingDropdown';
|
||||||
import PaginationDropdown from '../../../src/utils/PaginationDropdown';
|
import PaginationDropdown from '../../../src/utils/PaginationDropdown';
|
||||||
import { OrderDir, rangeOf } from '../../../src/utils/utils';
|
import { OrderDir, rangeOf } from '../../../src/utils/utils';
|
||||||
import { Stats } from '../../../src/visits/types';
|
import { Stats } from '../../../src/visits/types';
|
||||||
|
import { SortableBarChartCard } from '../../../src/visits/charts/SortableBarChartCard';
|
||||||
|
import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart';
|
||||||
|
|
||||||
describe('<SortableBarGraph />', () => {
|
describe('<SortableBarChartCard />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const sortingItems = {
|
const sortingItems = {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
@ -19,7 +19,7 @@ describe('<SortableBarGraph />', () => {
|
||||||
};
|
};
|
||||||
const createWrapper = (withPagination = false, extraStats = {}) => {
|
const createWrapper = (withPagination = false, extraStats = {}) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Foo"
|
title="Foo"
|
||||||
stats={{ ...stats, ...extraStats }}
|
stats={{ ...stats, ...extraStats }}
|
||||||
sortingItems={sortingItems}
|
sortingItems={sortingItems}
|
||||||
|
@ -34,9 +34,9 @@ describe('<SortableBarGraph />', () => {
|
||||||
|
|
||||||
it('renders stats unchanged when no ordering is set', () => {
|
it('renders stats unchanged when no ordering is set', () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
const graphCard = wrapper.find(GraphCard);
|
const chart = wrapper.find(HorizontalBarChart);
|
||||||
|
|
||||||
expect(graphCard.prop('stats')).toEqual(stats);
|
expect(chart.prop('stats')).toEqual(stats);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renders properly ordered stats when ordering is set', () => {
|
describe('renders properly ordered stats when ordering is set', () => {
|
||||||
|
@ -49,7 +49,7 @@ describe('<SortableBarGraph />', () => {
|
||||||
assert = (sortName: string, sortDir: OrderDir, keys: string[], values: number[], done: Function) => {
|
assert = (sortName: string, sortDir: OrderDir, keys: string[], values: number[], done: Function) => {
|
||||||
dropdown.prop('onChange')(sortName, sortDir);
|
dropdown.prop('onChange')(sortName, sortDir);
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
const stats = wrapper.find(GraphCard).prop('stats');
|
const stats = wrapper.find(HorizontalBarChart).prop('stats');
|
||||||
|
|
||||||
expect(Object.keys(stats)).toEqual(keys);
|
expect(Object.keys(stats)).toEqual(keys);
|
||||||
expect(Object.values(stats)).toEqual(values);
|
expect(Object.values(stats)).toEqual(values);
|
||||||
|
@ -78,7 +78,7 @@ describe('<SortableBarGraph />', () => {
|
||||||
assert = (itemsPerPage: number, expectedStats: string[], done: Function) => {
|
assert = (itemsPerPage: number, expectedStats: string[], done: Function) => {
|
||||||
dropdown.prop('setValue')(itemsPerPage);
|
dropdown.prop('setValue')(itemsPerPage);
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
const stats = wrapper.find(GraphCard).prop('stats');
|
const stats = wrapper.find(HorizontalBarChart).prop('stats');
|
||||||
|
|
||||||
expect(Object.keys(stats)).toEqual(expectedStats);
|
expect(Object.keys(stats)).toEqual(expectedStats);
|
||||||
done();
|
done();
|
||||||
|
@ -97,7 +97,7 @@ describe('<SortableBarGraph />', () => {
|
||||||
it('renders extra header content', () => {
|
it('renders extra header content', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = shallow(
|
||||||
<span>
|
<span>
|
||||||
<SortableBarGraph
|
<SortableBarChartCard
|
||||||
title="Foo"
|
title="Foo"
|
||||||
stats={stats}
|
stats={stats}
|
||||||
sortingItems={sortingItems}
|
sortingItems={sortingItems}
|
||||||
|
@ -109,7 +109,7 @@ describe('<SortableBarGraph />', () => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>,
|
</span>,
|
||||||
).find(SortableBarGraph);
|
).find(SortableBarChartCard);
|
||||||
const header = wrapper.renderProp('extraHeaderContent')();
|
const header = wrapper.renderProp('extraHeaderContent')();
|
||||||
|
|
||||||
expect(header.find('.foo-span')).toHaveLength(1);
|
expect(header.find('.foo-span')).toHaveLength(1);
|
Loading…
Reference in a new issue