Added button to export visits as CSV

This commit is contained in:
Alejandro Celaya 2021-03-14 12:49:12 +01:00
parent 3f3523b80f
commit 03f63e3ee3
10 changed files with 87 additions and 24 deletions

View file

@ -5,7 +5,8 @@ import { Topics } from '../mercure/helpers/Topics';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { OrphanVisitsHeader } from './OrphanVisitsHeader';
import { VisitsInfo } from './types'; import { NormalizedVisit, VisitsInfo } from './types';
import { VisitsExporter } from './services/VisitsExporter';
export interface OrphanVisitsProps extends RouteComponentProps { export interface OrphanVisitsProps extends RouteComponentProps {
getOrphanVisits: (params: ShlinkVisitsParams) => void; getOrphanVisits: (params: ShlinkVisitsParams) => void;
@ -14,21 +15,26 @@ export interface OrphanVisitsProps extends RouteComponentProps {
settings: Settings; settings: Settings;
} }
export const OrphanVisits = boundToMercureHub(({ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
history: { goBack }, history: { goBack },
match: { url }, match: { url },
getOrphanVisits, getOrphanVisits,
orphanVisits, orphanVisits,
cancelGetOrphanVisits, cancelGetOrphanVisits,
settings, settings,
}: OrphanVisitsProps) => ( }: OrphanVisitsProps) => {
<VisitsStats const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
getVisits={getOrphanVisits}
cancelGetVisits={cancelGetOrphanVisits} return (
visitsInfo={orphanVisits} <VisitsStats
baseUrl={url} getVisits={getOrphanVisits}
settings={settings} cancelGetVisits={cancelGetOrphanVisits}
> visitsInfo={orphanVisits}
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} /> baseUrl={url}
</VisitsStats> settings={settings}
), () => [ Topics.orphanVisits() ]); exportCsv={exportCsv}
>
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
</VisitsStats>
);
}, () => [ Topics.orphanVisits() ]);

View file

@ -9,6 +9,8 @@ import { Settings } from '../settings/reducers/settings';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types';
export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> { export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }> {
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void;
@ -19,7 +21,7 @@ export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: st
settings: Settings; settings: Settings;
} }
const ShortUrlVisits = boundToMercureHub(({ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
history: { goBack }, history: { goBack },
match: { params, url }, match: { params, url },
location: { search }, location: { search },
@ -33,6 +35,10 @@ const ShortUrlVisits = boundToMercureHub(({
const { shortCode } = params; const { shortCode } = params;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: Partial<ShlinkVisitsParams>) => getShortUrlVisits(shortCode, { ...params, domain }); 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(() => { useEffect(() => {
getShortUrlDetail(shortCode, domain); getShortUrlDetail(shortCode, domain);
@ -46,6 +52,7 @@ const ShortUrlVisits = boundToMercureHub(({
baseUrl={url} baseUrl={url}
domain={domain} domain={domain}
settings={settings} settings={settings}
exportCsv={exportCsv}
> >
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} /> <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats> </VisitsStats>

View file

@ -7,6 +7,8 @@ import { Settings } from '../settings/reducers/settings';
import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import TagVisitsHeader from './TagVisitsHeader'; import TagVisitsHeader from './TagVisitsHeader';
import VisitsStats from './VisitsStats'; import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types';
export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> { export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> {
getTagVisits: (tag: string, query: any) => void; getTagVisits: (tag: string, query: any) => void;
@ -15,7 +17,7 @@ export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> {
settings: Settings; settings: Settings;
} }
const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({
history: { goBack }, history: { goBack },
match: { params, url }, match: { params, url },
getTagVisits, getTagVisits,
@ -25,6 +27,7 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
}: TagVisitsProps) => { }: TagVisitsProps) => {
const { tag } = params; const { tag } = params;
const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params);
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
return ( return (
<VisitsStats <VisitsStats
@ -33,6 +36,7 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
visitsInfo={tagVisits} visitsInfo={tagVisits}
baseUrl={url} baseUrl={url}
settings={settings} settings={settings}
exportCsv={exportCsv}
> >
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} /> <TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats> </VisitsStats>

View file

@ -2,7 +2,7 @@ import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC } from 'react'; import { useState, useEffect, useMemo, FC } from 'react';
import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom'; import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom';
import { Location } from 'history'; import { Location } from 'history';
@ -30,6 +30,7 @@ export interface VisitsStatsProps {
cancelGetVisits: () => void; cancelGetVisits: () => void;
baseUrl: string; baseUrl: string;
domain?: string; domain?: string;
exportCsv: (visits: NormalizedVisit[]) => void;
} }
interface VisitsNavLinkProps { interface VisitsNavLinkProps {
@ -76,7 +77,7 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
); );
const VisitsStats: FC<VisitsStatsProps> = ( 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 initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval)); const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
@ -266,6 +267,9 @@ const VisitsStats: FC<VisitsStatsProps> = (
> >
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>} Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
</Button> </Button>
<Button outline color="primary" onClick={() => exportCsv(normalizedVisits)}>
Export <FontAwesomeIcon icon={faFileExport} />
</Button>
</div> </div>
)} )}
</div> </div>

View file

@ -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);
}
};
}

View file

@ -11,24 +11,25 @@ import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { loadVisitsOverview } from '../reducers/visitsOverview'; import { loadVisitsOverview } from '../reducers/visitsOverview';
import * as visitsParser from './VisitsParser'; import * as visitsParser from './VisitsParser';
import { VisitsExporter } from './VisitsExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('MapModal', () => MapModal); bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator'); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter');
bottle.decorator('TagVisits', connect( bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo', 'settings' ], [ 'tagVisits', 'mercureInfo', 'settings' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('OrphanVisits', () => OrphanVisits); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter');
bottle.decorator('OrphanVisits', connect( bottle.decorator('OrphanVisits', connect(
[ 'orphanVisits', 'mercureInfo', 'settings' ], [ 'orphanVisits', 'mercureInfo', 'settings' ],
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
@ -36,6 +37,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
bottle.serviceFactory('VisitsParser', () => visitsParser); bottle.serviceFactory('VisitsParser', () => visitsParser);
bottle.service('VisitsExporter', VisitsExporter, 'window', 'csvjson');
// Actions // Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');

View file

@ -2,12 +2,13 @@ import { shallow } from 'enzyme';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { History, Location } from 'history'; import { History, Location } from 'history';
import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars 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 { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { VisitsInfo } from '../../src/visits/types'; import { VisitsInfo } from '../../src/visits/types';
import VisitsStats from '../../src/visits/VisitsStats'; import VisitsStats from '../../src/visits/VisitsStats';
import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
describe('<OrphanVisits />', () => { describe('<OrphanVisits />', () => {
it('wraps visits stats and header', () => { it('wraps visits stats and header', () => {
@ -15,6 +16,7 @@ describe('<OrphanVisits />', () => {
const getOrphanVisits = jest.fn(); const getOrphanVisits = jest.fn();
const cancelGetOrphanVisits = jest.fn(); const cancelGetOrphanVisits = jest.fn();
const orphanVisits = Mock.all<VisitsInfo>(); const orphanVisits = Mock.all<VisitsInfo>();
const OrphanVisits = createOrphanVisits(Mock.all<VisitsExporter>());
const wrapper = shallow( const wrapper = shallow(
<OrphanVisits <OrphanVisits

View file

@ -3,12 +3,13 @@ import { identity } from 'ramda';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { History, Location } from 'history'; import { History, Location } from 'history';
import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars 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 ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader';
import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers/shortUrlVisits'; import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers/shortUrlVisits';
import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail'; import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
import VisitsStats from '../../src/visits/VisitsStats'; import VisitsStats from '../../src/visits/VisitsStats';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
describe('<ShortUrlVisits />', () => { describe('<ShortUrlVisits />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -20,6 +21,7 @@ describe('<ShortUrlVisits />', () => {
const history = Mock.of<History>({ const history = Mock.of<History>({
goBack: jest.fn(), goBack: jest.fn(),
}); });
const ShortUrlVisits = createShortUrlVisits(Mock.all<VisitsExporter>());
beforeEach(() => { beforeEach(() => {
wrapper = shallow( wrapper = shallow(

View file

@ -8,6 +8,7 @@ import ColorGenerator from '../../src/utils/services/ColorGenerator';
import { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits'; import { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits';
import VisitsStats from '../../src/visits/VisitsStats'; import VisitsStats from '../../src/visits/VisitsStats';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
describe('<TagVisits />', () => { describe('<TagVisits />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -20,7 +21,7 @@ describe('<TagVisits />', () => {
}); });
beforeEach(() => { beforeEach(() => {
const TagVisits = createTagVisits(Mock.of<ColorGenerator>()); const TagVisits = createTagVisits(Mock.all<ColorGenerator>(), Mock.all<VisitsExporter>());
wrapper = shallow( wrapper = shallow(
<TagVisits <TagVisits

View file

@ -1,5 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Progress } from 'reactstrap'; import { Button, Progress } from 'reactstrap';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import VisitStats from '../../src/visits/VisitsStats'; import VisitStats from '../../src/visits/VisitsStats';
import Message from '../../src/utils/Message'; import Message from '../../src/utils/Message';
@ -16,6 +16,7 @@ describe('<VisitStats />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const getVisitsMock = jest.fn(); const getVisitsMock = jest.fn();
const exportCsv = jest.fn();
const createComponent = (visitsInfo: Partial<VisitsInfo>) => { const createComponent = (visitsInfo: Partial<VisitsInfo>) => {
wrapper = shallow( wrapper = shallow(
@ -25,6 +26,7 @@ describe('<VisitStats />', () => {
cancelGetVisits={() => {}} cancelGetVisits={() => {}}
baseUrl={''} baseUrl={''}
settings={Mock.all<Settings>()} settings={Mock.all<Settings>()}
exportCsv={exportCsv}
/>, />,
); );
@ -89,4 +91,13 @@ describe('<VisitStats />', () => {
expect(extraHeaderContent).toHaveLength(1); expect(extraHeaderContent).toHaveLength(1);
expect(typeof extraHeaderContent).toEqual('function'); 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();
});
}); });