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) => (
-
-
-
-), () => [ Topics.orphanVisits() ]);
+}: OrphanVisitsProps) => {
+ const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
+
+ return (
+
+
+
+ );
+}, () => [ 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) => 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}
>
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 (
boundToMercureHub(({
visitsInfo={tagVisits}
baseUrl={url}
settings={settings}
+ exportCsv={exportCsv}
>
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index c8d80918..8ebb4f12 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, faFileExport } 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 = ({ subPath, title
);
const VisitsStats: FC = (
- { 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(intervalToDateRange(initialInterval));
@@ -266,6 +267,9 @@ const VisitsStats: FC = (
>
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})>}
+
)}
diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts
new file mode 100644
index 00000000..5f63d8bd
--- /dev/null
+++ b/src/visits/services/VisitsExporter.ts
@@ -0,0 +1,24 @@
+import { CsvJson } from 'csvjson';
+import { head, keys } from 'ramda';
+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[]) => {
+ try {
+ const csv = this.csvjson.toCSV(visits, {
+ headers: keys(head(visits)).join(','),
+ });
+
+ saveCsv(this.window, csv, filename);
+ } catch (e) {
+ // FIXME Handle error
+ console.error(e);
+ }
+ };
+}
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');
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('', () => {
it('wraps visits stats and header', () => {
@@ -15,6 +16,7 @@ describe('', () => {
const getOrphanVisits = jest.fn();
const cancelGetOrphanVisits = jest.fn();
const orphanVisits = Mock.all();
+ const OrphanVisits = createOrphanVisits(Mock.all());
const wrapper = shallow(
', () => {
let wrapper: ShallowWrapper;
@@ -20,6 +21,7 @@ describe('', () => {
const history = Mock.of({
goBack: jest.fn(),
});
+ const ShortUrlVisits = createShortUrlVisits(Mock.all());
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('', () => {
let wrapper: ShallowWrapper;
@@ -20,7 +21,7 @@ describe('', () => {
});
beforeEach(() => {
- const TagVisits = createTagVisits(Mock.of());
+ const TagVisits = createTagVisits(Mock.all(), Mock.all());
wrapper = shallow(
', () => {
let wrapper: ShallowWrapper;
const getVisitsMock = jest.fn();
+ const exportCsv = jest.fn();
const createComponent = (visitsInfo: Partial) => {
wrapper = shallow(
@@ -25,6 +26,7 @@ describe('', () => {
cancelGetVisits={() => {}}
baseUrl={''}
settings={Mock.all()}
+ exportCsv={exportCsv}
/>,
);
@@ -89,4 +91,13 @@ describe('', () => {
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();
+ });
});