From 03f63e3ee316011a4b65efef4b3b6ef401d1acb6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 12:49:12 +0100 Subject: [PATCH] Added button to export visits as CSV --- src/visits/OrphanVisits.tsx | 32 +++++++++++++++----------- src/visits/ShortUrlVisits.tsx | 9 +++++++- src/visits/TagVisits.tsx | 6 ++++- src/visits/VisitsStats.tsx | 8 +++++-- src/visits/services/VisitsExporter.ts | 24 +++++++++++++++++++ src/visits/services/provideServices.ts | 8 ++++--- test/visits/OrphanVisits.test.tsx | 4 +++- test/visits/ShortUrlVisits.test.tsx | 4 +++- test/visits/TagVisits.test.tsx | 3 ++- test/visits/VisitsStats.test.tsx | 13 ++++++++++- 10 files changed, 87 insertions(+), 24 deletions(-) create mode 100644 src/visits/services/VisitsExporter.ts 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(); + }); });