mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #403 from acelaya-forks/feature/export-stats
Feature/export stats
This commit is contained in:
commit
ea01d22369
16 changed files with 178 additions and 84 deletions
|
@ -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.
|
* [#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.
|
* [#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.
|
* [#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
|
### 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.
|
* [#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.
|
||||||
|
|
2
shlink-web-client.d.ts
vendored
2
shlink-web-client.d.ts
vendored
|
@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
|
||||||
declare module 'csvjson' {
|
declare module 'csvjson' {
|
||||||
export declare class CsvJson {
|
export declare class CsvJson {
|
||||||
public toObject<T>(content: string): T[];
|
public toObject<T>(content: string): T[];
|
||||||
public toCSV<T>(data: T[], options: { headers: string }): string;
|
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,31 +1,10 @@
|
||||||
import { dissoc, head, keys, values } from 'ramda';
|
import { dissoc, values } from 'ramda';
|
||||||
import { CsvJson } from 'csvjson';
|
import { CsvJson } from 'csvjson';
|
||||||
import LocalStorage from '../../utils/services/LocalStorage';
|
import LocalStorage from '../../utils/services/LocalStorage';
|
||||||
import { ServersMap } from '../data';
|
import { ServersMap } from '../data';
|
||||||
|
import { saveCsv } from '../../utils/helpers/csv';
|
||||||
|
|
||||||
const saveCsv = (window: Window, csv: string) => {
|
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class ServersExporter {
|
export default class ServersExporter {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
@ -38,15 +17,12 @@ export default class ServersExporter {
|
||||||
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(dissoc('id'));
|
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(dissoc('id'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = this.csvjson.toCSV(servers, {
|
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
|
||||||
headers: keys(head(servers)).join(','),
|
|
||||||
});
|
|
||||||
|
|
||||||
saveCsv(this.window, csv);
|
saveCsv(this.window, csv, SERVERS_FILENAME);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// FIXME Handle error
|
// FIXME Handle error
|
||||||
/* eslint no-console: "off" */
|
console.error(e); // eslint-disable-line no-console
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
12
src/utils/helpers/csv.ts
Normal file
12
src/utils/helpers/csv.ts
Normal file
|
@ -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);
|
||||||
|
};
|
|
@ -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() ]);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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, faFileDownload } 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));
|
||||||
|
@ -258,14 +259,24 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
</div>
|
</div>
|
||||||
{visits.length > 0 && (
|
{visits.length > 0 && (
|
||||||
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
||||||
<Button
|
<div className="d-flex">
|
||||||
outline
|
<Button
|
||||||
disabled={highlightedVisits.length === 0}
|
outline
|
||||||
className="btn-md-block"
|
disabled={highlightedVisits.length === 0}
|
||||||
onClick={() => setSelectedVisits([])}
|
className="btn-md-block mr-2"
|
||||||
>
|
onClick={() => setSelectedVisits([])}
|
||||||
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
|
>
|
||||||
</Button>
|
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
className="btn-md-block"
|
||||||
|
onClick={() => exportCsv(normalizedVisits)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFileDownload} /> Export ({normalizedVisits.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
20
src/visits/services/VisitsExporter.ts
Normal file
20
src/visits/services/VisitsExporter.ts
Normal file
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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');
|
||||||
|
|
|
@ -11,12 +11,9 @@ describe('ServersExporter', () => {
|
||||||
});
|
});
|
||||||
const appendChild = jest.fn();
|
const appendChild = jest.fn();
|
||||||
const removeChild = jest.fn();
|
const removeChild = jest.fn();
|
||||||
const createWindowMock = (isIe10 = true) => Mock.of<Window>({
|
const windowMock = Mock.of<Window>({
|
||||||
navigator: {
|
|
||||||
msSaveBlob: isIe10 ? jest.fn() : undefined,
|
|
||||||
},
|
|
||||||
document: {
|
document: {
|
||||||
createElement: jest.fn(() => createLinkMock()),
|
createElement: jest.fn(createLinkMock),
|
||||||
body: { appendChild, removeChild },
|
body: { appendChild, removeChild },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -53,12 +50,11 @@ describe('ServersExporter', () => {
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.console = originalConsole;
|
global.console = originalConsole;
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an error if something fails', () => {
|
it('logs an error if something fails', () => {
|
||||||
const csvjsonMock = createCsvjsonMock(true);
|
const csvjsonMock = createCsvjsonMock(true);
|
||||||
const exporter = new ServersExporter(storageMock, createWindowMock(), csvjsonMock);
|
const exporter = new ServersExporter(storageMock, windowMock, csvjsonMock);
|
||||||
|
|
||||||
exporter.exportServers();
|
exporter.exportServers();
|
||||||
|
|
||||||
|
@ -66,20 +62,7 @@ describe('ServersExporter', () => {
|
||||||
expect(erroneousToCsv).toHaveBeenCalledTimes(1);
|
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 exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock());
|
||||||
const { document: { createElement } } = windowMock;
|
const { document: { createElement } } = windowMock;
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
56
test/visits/services/VisitsExporter.test.ts
Normal file
56
test/visits/services/VisitsExporter.test.ts
Normal file
|
@ -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<Window>({
|
||||||
|
document: {
|
||||||
|
createElement: jest.fn(createLinkMock),
|
||||||
|
body: { appendChild: jest.fn(), removeChild: jest.fn() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const toCSV = jest.fn();
|
||||||
|
const csvToJsonMock = Mock.of<CsvJson>({ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue