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

Issue:
Export traffic data as csv/excel/ods document
<!--
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.

With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->

#### Summary

<!-- Describe the new feature you would like to request. -->

Shlink it fantastic, but i think it would greatly benefit from a tabellar export. 

### **Context**

many people who use url shorteners regularly, for business reasons, need to present data in a "digestible" format. A Pdf export would be a quick answer to that, but it's feasible only for a single "project" and the data is "static" and cannot be further analyzed.

An export of data in tabellar form is quite more useful, because you can further process it in a Spreadsheet (excel, open office, google docs, etc.).

### What should be the format of the export

This is just a feature proposal, not an order :) 

In its simplest form, the client could simply output the "table" as a file, with the fields enclosed by double quotes, to prevent import issues. (the export in excels or open office, should prevent this case).

The current selected date should work as filter.

![image](https://user-images.githubusercontent.com/3459602/102059015-a2572e80-3df0-11eb-9e27-f9a53b695f96.png)

Most people skilled in spreadsheet could then filter them and pivot to obtain the data desired.

### optional 

In a separate sheet (so it's a feature only for excel or open office), the client may output a list of hits sorted by date to replicate the main chart seen on screen. 

I've seen it happens in the Google search console export feature, it's probably used to generate the chart quickly. 

That's some ideas i've thought so far, thanks for this nice shortener!




Pull request:
Feature/export stats
Closes #349 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45b03681..c00a28dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0.
 * [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0.
 * [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages.
+* [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV.
 
 ### Changed
 * [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.
diff --git a/shlink-web-client.d.ts b/shlink-web-client.d.ts
index 58d3c5df..dbc6e0fd 100644
--- a/shlink-web-client.d.ts
+++ b/shlink-web-client.d.ts
@@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
 declare module 'csvjson' {
   export declare class CsvJson {
     public toObject<T>(content: string): T[];
-    public toCSV<T>(data: T[], options: { headers: string }): string;
+    public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
   }
 }
 
diff --git a/src/servers/services/ServersExporter.ts b/src/servers/services/ServersExporter.ts
index 114275ae..a54536a4 100644
--- a/src/servers/services/ServersExporter.ts
+++ b/src/servers/services/ServersExporter.ts
@@ -1,31 +1,10 @@
-import { dissoc, head, keys, values } from 'ramda';
+import { dissoc, values } from 'ramda';
 import { CsvJson } from 'csvjson';
 import LocalStorage from '../../utils/services/LocalStorage';
 import { ServersMap } from '../data';
+import { saveCsv } from '../../utils/helpers/csv';
 
-const saveCsv = (window: Window, csv: string) => {
-  const { navigator, document } = window;
-  const filename = 'shlink-servers.csv';
-  const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
-
-  // IE10 and IE11
-  if (navigator.msSaveBlob) {
-    navigator.msSaveBlob(blob, filename);
-
-    return;
-  }
-
-  // Modern browsers
-  const link = document.createElement('a');
-  const url = URL.createObjectURL(blob);
-
-  link.setAttribute('href', url);
-  link.setAttribute('download', filename);
-  link.style.visibility = 'hidden';
-  document.body.appendChild(link);
-  link.click();
-  document.body.removeChild(link);
-};
+const SERVERS_FILENAME = 'shlink-servers.csv';
 
 export default class ServersExporter {
   public constructor(
@@ -38,15 +17,12 @@ export default class ServersExporter {
     const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(dissoc('id'));
 
     try {
-      const csv = this.csvjson.toCSV(servers, {
-        headers: keys(head(servers)).join(','),
-      });
+      const csv = this.csvjson.toCSV(servers, { headers: 'key' });
 
-      saveCsv(this.window, csv);
+      saveCsv(this.window, csv, SERVERS_FILENAME);
     } catch (e) {
       // FIXME Handle error
-      /* eslint no-console: "off" */
-      console.error(e);
+      console.error(e); // eslint-disable-line no-console
     }
   };
 }
diff --git a/src/utils/helpers/csv.ts b/src/utils/helpers/csv.ts
new file mode 100644
index 00000000..08c895a0
--- /dev/null
+++ b/src/utils/helpers/csv.ts
@@ -0,0 +1,12 @@
+export const saveCsv = ({ document }: Window, csv: string, filename: string) => {
+  const link = document.createElement('a');
+  const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
+  const url = URL.createObjectURL(blob);
+
+  link.setAttribute('href', url);
+  link.setAttribute('download', filename);
+  link.style.visibility = 'hidden';
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
+};
diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx
index 04d421eb..54197a83 100644
--- a/src/visits/OrphanVisits.tsx
+++ b/src/visits/OrphanVisits.tsx
@@ -5,7 +5,8 @@ import { Topics } from '../mercure/helpers/Topics';
 import { Settings } from '../settings/reducers/settings';
 import VisitsStats from './VisitsStats';
 import { OrphanVisitsHeader } from './OrphanVisitsHeader';
-import { VisitsInfo } from './types';
+import { NormalizedVisit, VisitsInfo } from './types';
+import { VisitsExporter } from './services/VisitsExporter';
 
 export interface OrphanVisitsProps extends RouteComponentProps {
   getOrphanVisits: (params: ShlinkVisitsParams) => void;
@@ -14,21 +15,26 @@ export interface OrphanVisitsProps extends RouteComponentProps {
   settings: Settings;
 }
 
-export const OrphanVisits = boundToMercureHub(({
+export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
   history: { goBack },
   match: { url },
   getOrphanVisits,
   orphanVisits,
   cancelGetOrphanVisits,
   settings,
-}: OrphanVisitsProps) => (
-  <VisitsStats
-    getVisits={getOrphanVisits}
-    cancelGetVisits={cancelGetOrphanVisits}
-    visitsInfo={orphanVisits}
-    baseUrl={url}
-    settings={settings}
-  >
-    <OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
-  </VisitsStats>
-), () => [ Topics.orphanVisits() ]);
+}: OrphanVisitsProps) => {
+  const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
+
+  return (
+    <VisitsStats
+      getVisits={getOrphanVisits}
+      cancelGetVisits={cancelGetOrphanVisits}
+      visitsInfo={orphanVisits}
+      baseUrl={url}
+      settings={settings}
+      exportCsv={exportCsv}
+    >
+      <OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
+    </VisitsStats>
+  );
+}, () => [ Topics.orphanVisits() ]);
diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx
index d4894b0a..eac8b953 100644
--- a/src/visits/ShortUrlVisits.tsx
+++ b/src/visits/ShortUrlVisits.tsx
@@ -9,6 +9,8 @@ import { Settings } from '../settings/reducers/settings';
 import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
 import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
 import VisitsStats from './VisitsStats';
+import { VisitsExporter } from './services/VisitsExporter';
+import { NormalizedVisit } from './types';
 
 export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> {
   getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void;
@@ -19,7 +21,7 @@ export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: st
   settings: Settings;
 }
 
-const ShortUrlVisits = boundToMercureHub(({
+const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
   history: { goBack },
   match: { params, url },
   location: { search },
@@ -33,6 +35,10 @@ const ShortUrlVisits = boundToMercureHub(({
   const { shortCode } = params;
   const { domain } = parseQuery<{ domain?: string }>(search);
   const loadVisits = (params: Partial<ShlinkVisitsParams>) => getShortUrlVisits(shortCode, { ...params, domain });
+  const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
+    `short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
+    visits,
+  );
 
   useEffect(() => {
     getShortUrlDetail(shortCode, domain);
@@ -46,6 +52,7 @@ const ShortUrlVisits = boundToMercureHub(({
       baseUrl={url}
       domain={domain}
       settings={settings}
+      exportCsv={exportCsv}
     >
       <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
     </VisitsStats>
diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx
index 0f687382..b7dd573b 100644
--- a/src/visits/TagVisits.tsx
+++ b/src/visits/TagVisits.tsx
@@ -7,6 +7,8 @@ import { Settings } from '../settings/reducers/settings';
 import { TagVisits as TagVisitsState } from './reducers/tagVisits';
 import TagVisitsHeader from './TagVisitsHeader';
 import VisitsStats from './VisitsStats';
+import { VisitsExporter } from './services/VisitsExporter';
+import { NormalizedVisit } from './types';
 
 export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> {
   getTagVisits: (tag: string, query: any) => void;
@@ -15,7 +17,7 @@ export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> {
   settings: Settings;
 }
 
-const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
+const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({
   history: { goBack },
   match: { params, url },
   getTagVisits,
@@ -25,6 +27,7 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
 }: TagVisitsProps) => {
   const { tag } = params;
   const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params);
+  const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
 
   return (
     <VisitsStats
@@ -33,6 +36,7 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
       visitsInfo={tagVisits}
       baseUrl={url}
       settings={settings}
+      exportCsv={exportCsv}
     >
       <TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
     </VisitsStats>
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index c8d80918..09271d2d 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -2,7 +2,7 @@ import { isEmpty, propEq, values } from 'ramda';
 import { useState, useEffect, useMemo, FC } from 'react';
 import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons';
+import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons';
 import { IconDefinition } from '@fortawesome/fontawesome-common-types';
 import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom';
 import { Location } from 'history';
@@ -30,6 +30,7 @@ export interface VisitsStatsProps {
   cancelGetVisits: () => void;
   baseUrl: string;
   domain?: string;
+  exportCsv: (visits: NormalizedVisit[]) => void;
 }
 
 interface VisitsNavLinkProps {
@@ -76,7 +77,7 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
 );
 
 const VisitsStats: FC<VisitsStatsProps> = (
-  { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings },
+  { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv },
 ) => {
   const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
   const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
@@ -258,14 +259,24 @@ const VisitsStats: FC<VisitsStatsProps> = (
           </div>
           {visits.length > 0 && (
             <div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
-              <Button
-                outline
-                disabled={highlightedVisits.length === 0}
-                className="btn-md-block"
-                onClick={() => setSelectedVisits([])}
-              >
-                Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
-              </Button>
+              <div className="d-flex">
+                <Button
+                  outline
+                  disabled={highlightedVisits.length === 0}
+                  className="btn-md-block mr-2"
+                  onClick={() => setSelectedVisits([])}
+                >
+                  Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
+                </Button>
+                <Button
+                  outline
+                  color="primary"
+                  className="btn-md-block"
+                  onClick={() => exportCsv(normalizedVisits)}
+                >
+                  <FontAwesomeIcon icon={faFileDownload} /> Export ({normalizedVisits.length})
+                </Button>
+              </div>
             </div>
           )}
         </div>
diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts
new file mode 100644
index 00000000..daa8b81b
--- /dev/null
+++ b/src/visits/services/VisitsExporter.ts
@@ -0,0 +1,20 @@
+import { CsvJson } from 'csvjson';
+import { NormalizedVisit } from '../types';
+import { saveCsv } from '../../utils/helpers/csv';
+
+export class VisitsExporter {
+  public constructor(
+    private readonly window: Window,
+    private readonly csvjson: CsvJson,
+  ) {}
+
+  public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
+    if (!visits.length) {
+      return;
+    }
+
+    const csv = this.csvjson.toCSV(visits, { headers: 'key' });
+
+    saveCsv(this.window, csv, filename);
+  };
+}
diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts
index 3ebb8c08..2bf91ab9 100644
--- a/src/visits/services/provideServices.ts
+++ b/src/visits/services/provideServices.ts
@@ -11,24 +11,25 @@ import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits
 import { ConnectDecorator } from '../../container/types';
 import { loadVisitsOverview } from '../reducers/visitsOverview';
 import * as visitsParser from './VisitsParser';
+import { VisitsExporter } from './VisitsExporter';
 
 const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   // Components
   bottle.serviceFactory('MapModal', () => MapModal);
 
-  bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits);
+  bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter');
   bottle.decorator('ShortUrlVisits', connect(
     [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
     [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
   ));
 
-  bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator');
+  bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter');
   bottle.decorator('TagVisits', connect(
     [ 'tagVisits', 'mercureInfo', 'settings' ],
     [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
   ));
 
-  bottle.serviceFactory('OrphanVisits', () => OrphanVisits);
+  bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter');
   bottle.decorator('OrphanVisits', connect(
     [ 'orphanVisits', 'mercureInfo', 'settings' ],
     [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
@@ -36,6 +37,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
 
   // Services
   bottle.serviceFactory('VisitsParser', () => visitsParser);
+  bottle.service('VisitsExporter', VisitsExporter, 'window', 'csvjson');
 
   // Actions
   bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');


Test patch:
diff --git a/test/servers/services/ServersExporter.test.ts b/test/servers/services/ServersExporter.test.ts
index 713c843f..cf0fd386 100644
--- a/test/servers/services/ServersExporter.test.ts
+++ b/test/servers/services/ServersExporter.test.ts
@@ -11,12 +11,9 @@ describe('ServersExporter', () => {
   });
   const appendChild = jest.fn();
   const removeChild = jest.fn();
-  const createWindowMock = (isIe10 = true) => Mock.of<Window>({
-    navigator: {
-      msSaveBlob: isIe10 ? jest.fn() : undefined,
-    },
+  const windowMock = Mock.of<Window>({
     document: {
-      createElement: jest.fn(() => createLinkMock()),
+      createElement: jest.fn(createLinkMock),
       body: { appendChild, removeChild },
     },
   });
@@ -53,12 +50,11 @@ describe('ServersExporter', () => {
     });
     afterEach(() => {
       global.console = originalConsole;
-      jest.clearAllMocks();
     });
 
     it('logs an error if something fails', () => {
       const csvjsonMock = createCsvjsonMock(true);
-      const exporter = new ServersExporter(storageMock, createWindowMock(), csvjsonMock);
+      const exporter = new ServersExporter(storageMock, windowMock, csvjsonMock);
 
       exporter.exportServers();
 
@@ -66,20 +62,7 @@ describe('ServersExporter', () => {
       expect(erroneousToCsv).toHaveBeenCalledTimes(1);
     });
 
-    it('makes use of msSaveBlob API when available', () => {
-      const windowMock = createWindowMock();
-      const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock());
-      const { navigator: { msSaveBlob }, document: { createElement } } = windowMock;
-
-      exporter.exportServers();
-
-      expect(storageMock.get).toHaveBeenCalledTimes(1);
-      expect(msSaveBlob).toHaveBeenCalledTimes(1);
-      expect(createElement).not.toHaveBeenCalled();
-    });
-
-    it('makes use of download link API when available', () => {
-      const windowMock = createWindowMock(false);
+    it('makes use of download link API', () => {
       const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock());
       const { document: { createElement } } = windowMock;
 
diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx
index ad953bbc..020d54f9 100644
--- a/test/visits/OrphanVisits.test.tsx
+++ b/test/visits/OrphanVisits.test.tsx
@@ -2,12 +2,13 @@ import { shallow } from 'enzyme';
 import { Mock } from 'ts-mockery';
 import { History, Location } from 'history';
 import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars
-import { OrphanVisits } from '../../src/visits/OrphanVisits';
+import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisits';
 import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
 import { VisitsInfo } from '../../src/visits/types';
 import VisitsStats from '../../src/visits/VisitsStats';
 import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
 import { Settings } from '../../src/settings/reducers/settings';
+import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
 
 describe('<OrphanVisits />', () => {
   it('wraps visits stats and header', () => {
@@ -15,6 +16,7 @@ describe('<OrphanVisits />', () => {
     const getOrphanVisits = jest.fn();
     const cancelGetOrphanVisits = jest.fn();
     const orphanVisits = Mock.all<VisitsInfo>();
+    const OrphanVisits = createOrphanVisits(Mock.all<VisitsExporter>());
 
     const wrapper = shallow(
       <OrphanVisits
diff --git a/test/visits/ShortUrlVisits.test.tsx b/test/visits/ShortUrlVisits.test.tsx
index 2a3be38e..a3d2db5b 100644
--- a/test/visits/ShortUrlVisits.test.tsx
+++ b/test/visits/ShortUrlVisits.test.tsx
@@ -3,12 +3,13 @@ import { identity } from 'ramda';
 import { Mock } from 'ts-mockery';
 import { History, Location } from 'history';
 import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars
-import ShortUrlVisits, { ShortUrlVisitsProps } from '../../src/visits/ShortUrlVisits';
+import createShortUrlVisits, { ShortUrlVisitsProps } from '../../src/visits/ShortUrlVisits';
 import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader';
 import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers/shortUrlVisits';
 import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
 import VisitsStats from '../../src/visits/VisitsStats';
 import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
+import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
 
 describe('<ShortUrlVisits />', () => {
   let wrapper: ShallowWrapper;
@@ -20,6 +21,7 @@ describe('<ShortUrlVisits />', () => {
   const history = Mock.of<History>({
     goBack: jest.fn(),
   });
+  const ShortUrlVisits = createShortUrlVisits(Mock.all<VisitsExporter>());
 
   beforeEach(() => {
     wrapper = shallow(
diff --git a/test/visits/TagVisits.test.tsx b/test/visits/TagVisits.test.tsx
index 5ac1c594..ee1f501a 100644
--- a/test/visits/TagVisits.test.tsx
+++ b/test/visits/TagVisits.test.tsx
@@ -8,6 +8,7 @@ import ColorGenerator from '../../src/utils/services/ColorGenerator';
 import { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits';
 import VisitsStats from '../../src/visits/VisitsStats';
 import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
+import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
 
 describe('<TagVisits />', () => {
   let wrapper: ShallowWrapper;
@@ -20,7 +21,7 @@ describe('<TagVisits />', () => {
   });
 
   beforeEach(() => {
-    const TagVisits = createTagVisits(Mock.of<ColorGenerator>());
+    const TagVisits = createTagVisits(Mock.all<ColorGenerator>(), Mock.all<VisitsExporter>());
 
     wrapper = shallow(
       <TagVisits
diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx
index d66255cb..2edb4fbe 100644
--- a/test/visits/VisitsStats.test.tsx
+++ b/test/visits/VisitsStats.test.tsx
@@ -1,5 +1,5 @@
 import { shallow, ShallowWrapper } from 'enzyme';
-import { Progress } from 'reactstrap';
+import { Button, Progress } from 'reactstrap';
 import { Mock } from 'ts-mockery';
 import VisitStats from '../../src/visits/VisitsStats';
 import Message from '../../src/utils/Message';
@@ -16,6 +16,7 @@ describe('<VisitStats />', () => {
 
   let wrapper: ShallowWrapper;
   const getVisitsMock = jest.fn();
+  const exportCsv = jest.fn();
 
   const createComponent = (visitsInfo: Partial<VisitsInfo>) => {
     wrapper = shallow(
@@ -25,6 +26,7 @@ describe('<VisitStats />', () => {
         cancelGetVisits={() => {}}
         baseUrl={''}
         settings={Mock.all<Settings>()}
+        exportCsv={exportCsv}
       />,
     );
 
@@ -89,4 +91,13 @@ describe('<VisitStats />', () => {
     expect(extraHeaderContent).toHaveLength(1);
     expect(typeof extraHeaderContent).toEqual('function');
   });
+
+  it('exports CSV when export btn is clicked', () => {
+    const wrapper = createComponent({ visits });
+    const exportBtn = wrapper.find(Button).last();
+
+    expect(exportBtn).toHaveLength(1);
+    exportBtn.simulate('click');
+    expect(exportCsv).toHaveBeenCalled();
+  });
 });
diff --git a/test/visits/services/VisitsExporter.test.ts b/test/visits/services/VisitsExporter.test.ts
new file mode 100644
index 00000000..e21c923a
--- /dev/null
+++ b/test/visits/services/VisitsExporter.test.ts
@@ -0,0 +1,56 @@
+import { Mock } from 'ts-mockery';
+import { CsvJson } from 'csvjson';
+import { VisitsExporter } from '../../../src/visits/services/VisitsExporter';
+import { NormalizedVisit } from '../../../src/visits/types';
+
+describe('VisitsExporter', () => {
+  const createLinkMock = () => ({
+    setAttribute: jest.fn(),
+    click: jest.fn(),
+    style: {},
+  });
+  const windowMock = Mock.of<Window>({
+    document: {
+      createElement: jest.fn(createLinkMock),
+      body: { appendChild: jest.fn(), removeChild: jest.fn() },
+    },
+  });
+  const toCSV = jest.fn();
+  const csvToJsonMock = Mock.of<CsvJson>({ toCSV });
+  let exporter: VisitsExporter;
+
+  beforeEach(jest.clearAllMocks);
+  beforeEach(() => {
+    (global as any).Blob = class Blob {}; // eslint-disable-line @typescript-eslint/no-extraneous-class
+    (global as any).URL = { createObjectURL: () => '' };
+
+    exporter = new VisitsExporter(windowMock, csvToJsonMock);
+  });
+
+  describe('exportVisits', () => {
+    it('parses provided visits to CSV', () => {
+      const visits: NormalizedVisit[] = [
+        {
+          browser: 'browser',
+          city: 'city',
+          country: 'country',
+          date: 'date',
+          latitude: 0,
+          longitude: 0,
+          os: 'os',
+          referer: 'referer',
+        },
+      ];
+
+      exporter.exportVisits('my_visits.csv', visits);
+
+      expect(toCSV).toHaveBeenCalledWith(visits, { headers: 'key' });
+    });
+
+    it('skips execution when list of visits is empty', () => {
+      exporter.exportVisits('my_visits.csv', []);
+
+      expect(toCSV).not.toHaveBeenCalled();
+    });
+  });
+});