Repository: shlinkio/shlink-web-client (ts)

Issue:
Split DefaultChart into BarChart and DoughnutChart
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

There's a lot of logic inside DefaultChart which is conditional based on the `isBarChart` flag prop. Probably splitting into 2 components would simplify the logc.

Pull request:
Feature/split charts
Closes #486 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b36d99e..c1e09e6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ### Changed
 * [#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
 * *Nothing*
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index 533658fe..ca255348 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -15,15 +15,15 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
 import { Settings } from '../settings/reducers/settings';
 import { SelectedServer } from '../servers/data';
 import { supportsBotVisits } from '../utils/helpers/features';
-import SortableBarGraph from './helpers/SortableBarGraph';
-import GraphCard from './helpers/GraphCard';
-import LineChartCard from './helpers/LineChartCard';
+import LineChartCard from './charts/LineChartCard';
 import VisitsTable from './VisitsTable';
 import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
 import OpenMapModalBtn from './helpers/OpenMapModalBtn';
 import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
 import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
 import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
+import { DoughnutChartCard } from './charts/DoughnutChartCard';
+import { SortableBarChartCard } from './charts/SortableBarChartCard';
 import './VisitsStats.scss';
 
 export interface VisitsStatsProps {
@@ -173,13 +173,13 @@ const VisitsStats: FC<VisitsStatsProps> = ({
 
             <Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
               <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 className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
-                <GraphCard title="Browsers" stats={browsers} />
+                <DoughnutChartCard title="Browsers" stats={browsers} />
               </div>
               <div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
-                <SortableBarGraph
+                <SortableBarChartCard
                   title="Referrers"
                   stats={referrers}
                   withPagination={false}
@@ -194,7 +194,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
               </div>
               {isOrphanVisits && (
                 <div className="mt-3 col-lg-6">
-                  <SortableBarGraph
+                  <SortableBarChartCard
                     title="Visited URLs"
                     stats={visitedUrls}
                     highlightedLabel={highlightedLabel}
@@ -211,7 +211,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
 
             <Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
               <div className="col-lg-6 mt-3">
-                <SortableBarGraph
+                <SortableBarChartCard
                   title="Countries"
                   stats={countries}
                   highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
@@ -224,7 +224,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
                 />
               </div>
               <div className="col-lg-6 mt-3">
-                <SortableBarGraph
+                <SortableBarChartCard
                   title="Cities"
                   stats={cities}
                   highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
diff --git a/src/visits/helpers/GraphCard.scss b/src/visits/charts/ChartCard.scss
similarity index 53%
rename from src/visits/helpers/GraphCard.scss
rename to src/visits/charts/ChartCard.scss
index d429901c..f3220ebf 100644
--- a/src/visits/helpers/GraphCard.scss
+++ b/src/visits/charts/ChartCard.scss
@@ -1,4 +1,4 @@
-.graph-card__footer--sticky {
+.chart-card__footer--sticky {
   position: sticky;
   bottom: 0;
 }
diff --git a/src/visits/charts/ChartCard.tsx b/src/visits/charts/ChartCard.tsx
new file mode 100644
index 00000000..9c25acc8
--- /dev/null
+++ b/src/visits/charts/ChartCard.tsx
@@ -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>
+);
diff --git a/src/visits/charts/DoughnutChart.tsx b/src/visits/charts/DoughnutChart.tsx
new file mode 100644
index 00000000..4fbb6f44
--- /dev/null
+++ b/src/visits/charts/DoughnutChart.tsx
@@ -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>
+  );
+});
diff --git a/src/visits/charts/DoughnutChartCard.tsx b/src/visits/charts/DoughnutChartCard.tsx
new file mode 100644
index 00000000..3febaacc
--- /dev/null
+++ b/src/visits/charts/DoughnutChartCard.tsx
@@ -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>
+);
diff --git a/src/visits/helpers/PieChartLegend.scss b/src/visits/charts/DoughnutChartLegend.scss
similarity index 69%
rename from src/visits/helpers/PieChartLegend.scss
rename to src/visits/charts/DoughnutChartLegend.scss
index 2d4956f6..3dabac25 100644
--- a/src/visits/helpers/PieChartLegend.scss
+++ b/src/visits/charts/DoughnutChartLegend.scss
@@ -1,6 +1,6 @@
 @import '../../utils/base';
 
-.pie-chart-legend {
+.doughnut-chart-legend {
   list-style-type: none;
   padding: 0;
   margin: 0;
@@ -10,11 +10,11 @@
   }
 }
 
-.pie-chart-legend__item:not(:first-child) {
+.doughnut-chart-legend__item:not(:first-child) {
   margin-top: .3rem;
 }
 
-.pie-chart-legend__item-color {
+.doughnut-chart-legend__item-color {
   width: 20px;
   min-width: 20px;
   height: 20px;
@@ -22,7 +22,7 @@
   border-radius: 10px;
 }
 
-.pie-chart-legend__item-text {
+.doughnut-chart-legend__item-text {
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
diff --git a/src/visits/helpers/PieChartLegend.tsx b/src/visits/charts/DoughnutChartLegend.tsx
similarity index 54%
rename from src/visits/helpers/PieChartLegend.tsx
rename to src/visits/charts/DoughnutChartLegend.tsx
index e6e51d27..cd843031 100644
--- a/src/visits/helpers/PieChartLegend.tsx
+++ b/src/visits/charts/DoughnutChartLegend.tsx
@@ -1,26 +1,26 @@
 import { FC } from 'react';
 import { Chart } from 'chart.js';
-import './PieChartLegend.scss';
+import './DoughnutChartLegend.scss';
 
-interface PieChartLegendProps {
+interface DoughnutChartLegendProps {
   chart: Chart;
 }
 
-export const PieChartLegend: FC<PieChartLegendProps> = ({ 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="pie-chart-legend">
+    <ul className="doughnut-chart-legend">
       {(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
-            className="pie-chart-legend__item-color"
+            className="doughnut-chart-legend__item-color"
             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>
       ))}
     </ul>
diff --git a/src/visits/charts/HorizontalBarChart.tsx b/src/visits/charts/HorizontalBarChart.tsx
new file mode 100644
index 00000000..05bca0f0
--- /dev/null
+++ b/src/visits/charts/HorizontalBarChart.tsx
@@ -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')}
+    </>
+  );
+};
diff --git a/src/visits/helpers/LineChartCard.scss b/src/visits/charts/LineChartCard.scss
similarity index 100%
rename from src/visits/helpers/LineChartCard.scss
rename to src/visits/charts/LineChartCard.scss
diff --git a/src/visits/helpers/LineChartCard.tsx b/src/visits/charts/LineChartCard.tsx
similarity index 100%
rename from src/visits/helpers/LineChartCard.tsx
rename to src/visits/charts/LineChartCard.tsx
diff --git a/src/visits/helpers/SortableBarGraph.tsx b/src/visits/charts/SortableBarChartCard.tsx
similarity index 90%
rename from src/visits/helpers/SortableBarGraph.tsx
rename to src/visits/charts/SortableBarChartCard.tsx
index 9a4bd429..e0f1bee5 100644
--- a/src/visits/helpers/SortableBarGraph.tsx
+++ b/src/visits/charts/SortableBarChartCard.tsx
@@ -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 SortingDropdown from '../../utils/SortingDropdown';
-import PaginationDropdown from '../../utils/PaginationDropdown';
 import { OrderDir, rangeOf } from '../../utils/utils';
-import { roundTen } from '../../utils/helpers/numbers';
 import SimplePaginator from '../../common/SimplePaginator';
+import { roundTen } from '../../utils/helpers/numbers';
+import SortingDropdown from '../../utils/SortingDropdown';
+import PaginationDropdown from '../../utils/PaginationDropdown';
 import { Stats, StatsRow } from '../types';
-import GraphCard from './GraphCard';
-import { DefaultChartProps } from './DefaultChart';
+import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
+import { ChartCard } from './ChartCard';
 
-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;
-
-interface SortableBarGraphProps extends DefaultChartProps {
+interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
   title: Function | string;
   sortingItems: Record<string, string>;
   withPagination?: boolean;
   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,
   highlightedStats,
   title,
@@ -28,7 +28,7 @@ const SortableBarGraph = ({
   extraHeaderContent,
   withPagination = true,
   ...rest
-}: SortableBarGraphProps) => {
+}) => {
   const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
     orderField: undefined,
     orderDir: undefined,
@@ -132,16 +132,11 @@ const SortableBarGraph = ({
   );
 
   return (
-    <GraphCard
-      isBarChart
+    <ChartCard
       title={computeTitle}
-      stats={currentPageStats}
-      highlightedStats={currentPageHighlightedStats}
       footer={pagination}
-      max={max}
-      {...rest}
-    />
+    >
+      <HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
+    </ChartCard>
   );
 };
-
-export default SortableBarGraph;
diff --git a/src/visits/helpers/DefaultChart.tsx b/src/visits/helpers/DefaultChart.tsx
deleted file mode 100644
index 73107748..00000000
--- a/src/visits/helpers/DefaultChart.tsx
+++ /dev/null
@@ -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;
diff --git a/src/visits/helpers/GraphCard.tsx b/src/visits/helpers/GraphCard.tsx
deleted file mode 100644
index 57388835..00000000
--- a/src/visits/helpers/GraphCard.tsx
+++ /dev/null
@@ -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;


Test patch:
diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx
index c67bff66..32f1e1f3 100644
--- a/test/visits/VisitsStats.test.tsx
+++ b/test/visits/VisitsStats.test.tsx
@@ -3,14 +3,14 @@ import { Button, Progress } from 'reactstrap';
 import { Mock } from 'ts-mockery';
 import VisitStats from '../../src/visits/VisitsStats';
 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 LineChartCard from '../../src/visits/helpers/LineChartCard';
+import LineChartCard from '../../src/visits/charts/LineChartCard';
 import VisitsTable from '../../src/visits/VisitsTable';
 import { Result } from '../../src/utils/Result';
 import { Settings } from '../../src/settings/reducers/settings';
 import { SelectedServer } from '../../src/servers/data';
+import { SortableBarChartCard } from '../../src/visits/charts/SortableBarChartCard';
+import { DoughnutChartCard } from '../../src/visits/charts/DoughnutChartCard';
 
 describe('<VisitStats />', () => {
   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  :(');
   });
 
-  it('renders expected amount of graphics', () => {
+  it('renders expected amount of charts', () => {
     const wrapper = createComponent({ loading: false, error: false, visits });
-    const graphs = wrapper.find(GraphCard);
-    const sortableBarGraphs = wrapper.find(SortableBarGraph);
+    const charts = wrapper.find(DoughnutChartCard);
+    const sortableCharts = wrapper.find(SortableBarChartCard);
     const lineChart = wrapper.find(LineChartCard);
     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);
   });
 
-  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 citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]');
-    const extraHeaderContent = citiesGraph.prop('extraHeaderContent');
+    const citiesChart = wrapper.find(SortableBarChartCard).find('[title="Cities"]');
+    const extraHeaderContent = citiesChart.prop('extraHeaderContent');
 
     expect(extraHeaderContent).toHaveLength(1);
     expect(typeof extraHeaderContent).toEqual('function');
diff --git a/test/visits/helpers/GraphCard.test.tsx b/test/visits/charts/ChartCard.test.tsx
similarity index 80%
rename from test/visits/helpers/GraphCard.test.tsx
rename to test/visits/charts/ChartCard.test.tsx
index d465150e..8a1e23f1 100644
--- a/test/visits/helpers/GraphCard.test.tsx
+++ b/test/visits/charts/ChartCard.test.tsx
@@ -1,13 +1,12 @@
 import { ReactNode } from 'react';
 import { shallow, ShallowWrapper } from 'enzyme';
 import { Card, CardBody, CardHeader, CardFooter } from 'reactstrap';
-import GraphCard from '../../../src/visits/helpers/GraphCard';
-import DefaultChart from '../../../src/visits/helpers/DefaultChart';
+import { ChartCard } from '../../../src/visits/charts/ChartCard';
 
-describe('<GraphCard />', () => {
+describe('<ChartCard />', () => {
   let wrapper: ShallowWrapper;
   const createWrapper = (title: Function | string = '', footer?: ReactNode) => {
-    wrapper = shallow(<GraphCard title={title} footer={footer} stats={{}} />);
+    wrapper = shallow(<ChartCard title={title} footer={footer} />);
 
     return wrapper;
   };
@@ -19,13 +18,11 @@ describe('<GraphCard />', () => {
     const card = wrapper.find(Card);
     const header = wrapper.find(CardHeader);
     const body = wrapper.find(CardBody);
-    const chart = wrapper.find(DefaultChart);
     const footer = wrapper.find(CardFooter);
 
     expect(card).toHaveLength(1);
     expect(header).toHaveLength(1);
     expect(body).toHaveLength(1);
-    expect(chart).toHaveLength(1);
     expect(footer).toHaveLength(0);
   });
 
diff --git a/test/visits/charts/DoughnutChart.test.tsx b/test/visits/charts/DoughnutChart.test.tsx
new file mode 100644
index 00000000..5868aeb3
--- /dev/null
+++ b/test/visits/charts/DoughnutChart.test.tsx
@@ -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);
+  });
+});
diff --git a/test/visits/helpers/PieChartLegend.test.tsx b/test/visits/charts/DoughnutChartLegend.test.tsx
similarity index 69%
rename from test/visits/helpers/PieChartLegend.test.tsx
rename to test/visits/charts/DoughnutChartLegend.test.tsx
index eebf9365..22d4b8c0 100644
--- a/test/visits/helpers/PieChartLegend.test.tsx
+++ b/test/visits/charts/DoughnutChartLegend.test.tsx
@@ -1,9 +1,9 @@
 import { shallow } from 'enzyme';
 import { Mock } from 'ts-mockery';
 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 colors = [ 'foo_color', 'bar_color', 'baz_color' ];
   const defaultColor = 'red';
@@ -16,7 +16,7 @@ describe('<PieChartLegend />', () => {
   });
 
   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');
 
     expect.assertions(labels.length * 2 + 1);
@@ -24,10 +24,10 @@ describe('<PieChartLegend />', () => {
     labels.forEach((label, 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,
       });
-      expect(item.find('.pie-chart-legend__item-text').text()).toEqual(label);
+      expect(item.find('.doughnut-chart-legend__item-text').text()).toEqual(label);
     });
   });
 });
diff --git a/test/visits/helpers/DefaultChart.test.tsx b/test/visits/charts/HorizontalBarChart.test.tsx
similarity index 53%
rename from test/visits/helpers/DefaultChart.test.tsx
rename to test/visits/charts/HorizontalBarChart.test.tsx
index fc7a6488..d830e4fc 100644
--- a/test/visits/helpers/DefaultChart.test.tsx
+++ b/test/visits/charts/HorizontalBarChart.test.tsx
@@ -1,11 +1,10 @@
 import { shallow, ShallowWrapper } from 'enzyme';
-import { Doughnut, Bar } from 'react-chartjs-2';
-import { keys, values } from 'ramda';
-import DefaultChart from '../../../src/visits/helpers/DefaultChart';
+import { Bar } from 'react-chartjs-2';
 import { prettify } from '../../../src/utils/helpers/numbers';
 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;
   const stats = {
     foo: 123,
@@ -14,48 +13,11 @@ describe('<DefaultChart />', () => {
 
   afterEach(() => wrapper?.unmount());
 
-  it('renders Doughnut when is not a bar chart', () => {
-    wrapper = shallow(<DefaultChart stats={stats} />);
-    const doughnut = wrapper.find(Doughnut);
+  it('renders Bar with expected properties', () => {
+    wrapper = shallow(<HorizontalBarChart stats={stats} />);
     const horizontal = wrapper.find(Bar);
     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);
 
     const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data');
@@ -85,7 +47,7 @@ describe('<DefaultChart />', () => {
     [{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]],
     [ undefined, [ 123, 456 ], undefined ],
   ])('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 { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data');
diff --git a/test/visits/helpers/LineChartCard.test.tsx b/test/visits/charts/LineChartCard.test.tsx
similarity index 98%
rename from test/visits/helpers/LineChartCard.test.tsx
rename to test/visits/charts/LineChartCard.test.tsx
index 0ef89866..5e83c54d 100644
--- a/test/visits/helpers/LineChartCard.test.tsx
+++ b/test/visits/charts/LineChartCard.test.tsx
@@ -3,7 +3,7 @@ import { CardHeader, DropdownItem } from 'reactstrap';
 import { Line } from 'react-chartjs-2';
 import { formatISO, subDays, subMonths, subYears } from 'date-fns';
 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 { NormalizedVisit } from '../../../src/visits/types';
 import { prettify } from '../../../src/utils/helpers/numbers';
diff --git a/test/visits/helpers/SortableBarGraph.test.tsx b/test/visits/charts/SortableBarChartCard.tsx
similarity index 87%
rename from test/visits/helpers/SortableBarGraph.test.tsx
rename to test/visits/charts/SortableBarChartCard.tsx
index 1be93944..24ff1ad5 100644
--- a/test/visits/helpers/SortableBarGraph.test.tsx
+++ b/test/visits/charts/SortableBarChartCard.tsx
@@ -1,13 +1,13 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 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 PaginationDropdown from '../../../src/utils/PaginationDropdown';
 import { OrderDir, rangeOf } from '../../../src/utils/utils';
 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;
   const sortingItems = {
     name: 'Name',
@@ -19,7 +19,7 @@ describe('<SortableBarGraph />', () => {
   };
   const createWrapper = (withPagination = false, extraStats = {}) => {
     wrapper = shallow(
-      <SortableBarGraph
+      <SortableBarChartCard
         title="Foo"
         stats={{ ...stats, ...extraStats }}
         sortingItems={sortingItems}
@@ -34,9 +34,9 @@ describe('<SortableBarGraph />', () => {
 
   it('renders stats unchanged when no ordering is set', () => {
     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', () => {
@@ -49,7 +49,7 @@ describe('<SortableBarGraph />', () => {
       assert = (sortName: string, sortDir: OrderDir, keys: string[], values: number[], done: Function) => {
         dropdown.prop('onChange')(sortName, sortDir);
         setImmediate(() => {
-          const stats = wrapper.find(GraphCard).prop('stats');
+          const stats = wrapper.find(HorizontalBarChart).prop('stats');
 
           expect(Object.keys(stats)).toEqual(keys);
           expect(Object.values(stats)).toEqual(values);
@@ -78,7 +78,7 @@ describe('<SortableBarGraph />', () => {
       assert = (itemsPerPage: number, expectedStats: string[], done: Function) => {
         dropdown.prop('setValue')(itemsPerPage);
         setImmediate(() => {
-          const stats = wrapper.find(GraphCard).prop('stats');
+          const stats = wrapper.find(HorizontalBarChart).prop('stats');
 
           expect(Object.keys(stats)).toEqual(expectedStats);
           done();
@@ -97,7 +97,7 @@ describe('<SortableBarGraph />', () => {
   it('renders extra header content', () => {
     const wrapper = shallow(
       <span>
-        <SortableBarGraph
+        <SortableBarChartCard
           title="Foo"
           stats={stats}
           sortingItems={sortingItems}
@@ -109,7 +109,7 @@ describe('<SortableBarGraph />', () => {
           )}
         />
       </span>,
-    ).find(SortableBarGraph);
+    ).find(SortableBarChartCard);
     const header = wrapper.renderProp('extraHeaderContent')();
 
     expect(header.find('.foo-span')).toHaveLength(1);