From 3f3523b80f3131e1e0f7280755fec712c87a1516 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 11:47:23 +0100 Subject: [PATCH 1/7] Extracted helper function to generate a Csv file --- src/servers/services/ServersExporter.ts | 28 ++----------------- src/utils/helpers/csv.ts | 12 ++++++++ test/servers/services/ServersExporter.test.ts | 19 ++----------- 3 files changed, 17 insertions(+), 42 deletions(-) create mode 100644 src/utils/helpers/csv.ts diff --git a/src/servers/services/ServersExporter.ts b/src/servers/services/ServersExporter.ts index 114275ae..fb68c8b9 100644 --- a/src/servers/services/ServersExporter.ts +++ b/src/servers/services/ServersExporter.ts @@ -2,30 +2,9 @@ import { dissoc, head, keys, 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( @@ -42,10 +21,9 @@ export default class ServersExporter { headers: keys(head(servers)).join(','), }); - saveCsv(this.window, csv); + saveCsv(this.window, csv, SERVERS_FILENAME); } catch (e) { // FIXME Handle error - /* eslint no-console: "off" */ console.error(e); } }; 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/test/servers/services/ServersExporter.test.ts b/test/servers/services/ServersExporter.test.ts index 713c843f..a695373e 100644 --- a/test/servers/services/ServersExporter.test.ts +++ b/test/servers/services/ServersExporter.test.ts @@ -11,10 +11,7 @@ describe('ServersExporter', () => { }); const appendChild = jest.fn(); const removeChild = jest.fn(); - const createWindowMock = (isIe10 = true) => Mock.of({ - navigator: { - msSaveBlob: isIe10 ? jest.fn() : undefined, - }, + const createWindowMock = () => Mock.of({ document: { createElement: jest.fn(() => createLinkMock()), body: { appendChild, removeChild }, @@ -66,21 +63,9 @@ describe('ServersExporter', () => { expect(erroneousToCsv).toHaveBeenCalledTimes(1); }); - it('makes use of msSaveBlob API when available', () => { + it('makes use of download link API', () => { 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); - const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock()); const { document: { createElement } } = windowMock; exporter.exportServers(); From 03f63e3ee316011a4b65efef4b3b6ef401d1acb6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 12:49:12 +0100 Subject: [PATCH 2/7] 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(); + }); }); From 482489599e50f4bd3efa5661504eeaf9f986ef66 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 13:16:20 +0100 Subject: [PATCH 3/7] Created VisitsExporter test --- src/visits/services/VisitsExporter.ts | 19 +++--- test/servers/services/ServersExporter.test.ts | 8 +-- test/visits/services/VisitsExporter.test.ts | 58 +++++++++++++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 test/visits/services/VisitsExporter.test.ts diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts index 5f63d8bd..77eb07e5 100644 --- a/src/visits/services/VisitsExporter.ts +++ b/src/visits/services/VisitsExporter.ts @@ -1,5 +1,4 @@ import { CsvJson } from 'csvjson'; -import { head, keys } from 'ramda'; import { NormalizedVisit } from '../types'; import { saveCsv } from '../../utils/helpers/csv'; @@ -10,15 +9,15 @@ export class VisitsExporter { ) {} 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); + if (!visits.length) { + return; } + + const [ firstVisit ] = visits; + const csv = this.csvjson.toCSV(visits, { + headers: Object.keys(firstVisit).join(','), + }); + + saveCsv(this.window, csv, filename); }; } diff --git a/test/servers/services/ServersExporter.test.ts b/test/servers/services/ServersExporter.test.ts index a695373e..cf0fd386 100644 --- a/test/servers/services/ServersExporter.test.ts +++ b/test/servers/services/ServersExporter.test.ts @@ -11,9 +11,9 @@ describe('ServersExporter', () => { }); const appendChild = jest.fn(); const removeChild = jest.fn(); - const createWindowMock = () => Mock.of({ + const windowMock = Mock.of({ document: { - createElement: jest.fn(() => createLinkMock()), + createElement: jest.fn(createLinkMock), body: { appendChild, removeChild }, }, }); @@ -50,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(); @@ -64,7 +63,6 @@ describe('ServersExporter', () => { }); it('makes use of download link API', () => { - const windowMock = createWindowMock(); const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock()); const { document: { createElement } } = windowMock; diff --git a/test/visits/services/VisitsExporter.test.ts b/test/visits/services/VisitsExporter.test.ts new file mode 100644 index 00000000..a0d66acb --- /dev/null +++ b/test/visits/services/VisitsExporter.test.ts @@ -0,0 +1,58 @@ +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: 'browser,city,country,date,latitude,longitude,os,referer', + }); + }); + + it('skips execution when list of visits is empty', () => { + exporter.exportVisits('my_visits.csv', []); + + expect(toCSV).not.toHaveBeenCalled(); + }); + }); +}); From 508623f89f78496253a07146542468a706e6f596 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 13:30:50 +0100 Subject: [PATCH 4/7] Improved styling of the export visits button --- src/visits/VisitsStats.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 8ebb4f12..8ebd0fd9 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -259,17 +259,24 @@ const VisitsStats: FC = ( {visits.length > 0 && (
- - +
+ + +
)} From 843f6462648a9bbdf90dead1134932fb3195d0ca Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 13:31:58 +0100 Subject: [PATCH 5/7] Improved styling of the export visits button --- src/visits/VisitsStats.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 8ebd0fd9..6a0ac193 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -274,7 +274,7 @@ const VisitsStats: FC = ( className="btn-md-block" onClick={() => exportCsv(normalizedVisits)} > - Export + Export ({normalizedVisits.length}) From 71468379bd861535bb27f5a85d2115a6ef5dc95a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 18:12:10 +0100 Subject: [PATCH 6/7] Fixed headers when exporting visits to CSV --- shlink-web-client.d.ts | 2 +- src/servers/services/ServersExporter.ts | 6 ++---- src/visits/VisitsStats.tsx | 4 ++-- src/visits/services/VisitsExporter.ts | 5 +---- test/visits/services/VisitsExporter.test.ts | 4 +--- 5 files changed, 7 insertions(+), 14 deletions(-) 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 fb68c8b9..b6dc6679 100644 --- a/src/servers/services/ServersExporter.ts +++ b/src/servers/services/ServersExporter.ts @@ -1,4 +1,4 @@ -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'; @@ -17,9 +17,7 @@ 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, SERVERS_FILENAME); } catch (e) { diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 6a0ac193..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, faFileExport } 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'; @@ -274,7 +274,7 @@ const VisitsStats: FC = ( className="btn-md-block" onClick={() => exportCsv(normalizedVisits)} > - Export ({normalizedVisits.length}) + Export ({normalizedVisits.length}) diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts index 77eb07e5..daa8b81b 100644 --- a/src/visits/services/VisitsExporter.ts +++ b/src/visits/services/VisitsExporter.ts @@ -13,10 +13,7 @@ export class VisitsExporter { return; } - const [ firstVisit ] = visits; - const csv = this.csvjson.toCSV(visits, { - headers: Object.keys(firstVisit).join(','), - }); + const csv = this.csvjson.toCSV(visits, { headers: 'key' }); saveCsv(this.window, csv, filename); }; diff --git a/test/visits/services/VisitsExporter.test.ts b/test/visits/services/VisitsExporter.test.ts index a0d66acb..e21c923a 100644 --- a/test/visits/services/VisitsExporter.test.ts +++ b/test/visits/services/VisitsExporter.test.ts @@ -44,9 +44,7 @@ describe('VisitsExporter', () => { exporter.exportVisits('my_visits.csv', visits); - expect(toCSV).toHaveBeenCalledWith(visits, { - headers: 'browser,city,country,date,latitude,longitude,os,referer', - }); + expect(toCSV).toHaveBeenCalledWith(visits, { headers: 'key' }); }); it('skips execution when list of visits is empty', () => { From ff1d2f63c8e5f59f8e181eb7c1cbb5476fda6145 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 Mar 2021 18:14:10 +0100 Subject: [PATCH 7/7] Updated changelog --- CHANGELOG.md | 1 + src/servers/services/ServersExporter.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/src/servers/services/ServersExporter.ts b/src/servers/services/ServersExporter.ts index b6dc6679..a54536a4 100644 --- a/src/servers/services/ServersExporter.ts +++ b/src/servers/services/ServersExporter.ts @@ -22,7 +22,7 @@ export default class ServersExporter { saveCsv(this.window, csv, SERVERS_FILENAME); } catch (e) { // FIXME Handle error - console.error(e); + console.error(e); // eslint-disable-line no-console } }; }