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(content: string): T[]; - public toCSV(data: T[], options: { headers: string }): string; + public toCSV(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('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) => ( - - - -), () => [ 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..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 = ({ 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)); @@ -258,14 +259,24 @@ const VisitsStats: FC = ( {visits.length > 0 && (
- +
+ + +
)} 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'); 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({ - navigator: { - msSaveBlob: isIe10 ? jest.fn() : undefined, - }, + const windowMock = Mock.of({ 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('', () => { 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(); + }); }); 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({ + document: { + createElement: jest.fn(createLinkMock), + body: { appendChild: jest.fn(), removeChild: jest.fn() }, + }, + }); + const toCSV = jest.fn(); + const csvToJsonMock = Mock.of({ 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(); + }); + }); +});