Merge pull request #607 from acelaya-forks/feature/export-urls

Feature/export urls
This commit is contained in:
Alejandro Celaya 2022-03-17 20:36:11 +01:00 committed by GitHub
commit 56fa114f3c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 417 additions and 139 deletions

View file

@ -7,8 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
### Added
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together.
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.

View file

@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
declare module 'csvjson' {
export declare class CsvJson {
public toObject<T>(content: string): T[];
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string;
}
}

View file

@ -0,0 +1,33 @@
import { CsvJson } from 'csvjson';
import { NormalizedVisit } from '../../visits/types';
import { ExportableShortUrl } from '../../short-urls/data';
import { saveCsv } from '../../utils/helpers/files';
export class ReportExporter {
public constructor(
private readonly window: Window,
private readonly csvjson: CsvJson,
) {}
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
if (!visits.length) {
return;
}
this.exportCsv(filename, visits);
};
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
if (!shortUrls.length) {
return;
}
this.exportCsv('short_urls.csv', shortUrls);
};
private readonly exportCsv = (filename: string, rows: object[]) => {
const csv = this.csvjson.toCSV(rows, { headers: 'key', wrap: true });
saveCsv(this.window, csv, filename);
};
}

View file

@ -11,6 +11,7 @@ import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
@ -19,6 +20,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
// Components
bottle.serviceFactory('ScrollToTop', ScrollToTop);

View file

@ -5,12 +5,12 @@ import { SimpleCard } from '../utils/SimpleCard';
import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup';
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
interface ShortUrlsListProps {
interface ShortUrlsListSettingsProps {
settings: Settings;
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
}
export const ShortUrlsListSettings: FC<ShortUrlsListProps> = (
export const ShortUrlsListSettings: FC<ShortUrlsListSettingsProps> = (
{ settings: { shortUrlsList }, setShortUrlsListSettings },
) => (
<SimpleCard title="Short URLs list" className="h-100">

View file

@ -1,7 +1,10 @@
import { FC } from 'react';
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda';
import { parseISO } from 'date-fns';
import { Row } from 'reactstrap';
import classNames from 'classnames';
import SearchField from '../utils/SearchField';
import Tag from '../tags/helpers/Tag';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
@ -11,18 +14,28 @@ import { DateRange } from '../utils/dates/types';
import { supportsAllTagsFiltering } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data';
import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch';
import { OrderDir } from '../utils/helpers/ordering';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { useShortUrlsQuery } from './helpers/hooks';
import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps {
export interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string;
shortUrlsAmount?: number;
}
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedServer }: ShortUrlsFilteringProps) => {
const ShortUrlsFilteringBar = (
colorGenerator: ColorGenerator,
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => {
const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery();
const selectedTags = tags?.split(',') ?? [];
const setDates = pipe(
({ startDate, endDate }: DateRange) => ({
startDate: formatIsoDate(startDate) ?? undefined,
@ -35,9 +48,8 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer
(search) => toFirstPage({ search }),
);
const removeTag = pipe(
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
(tags) => toFirstPage({ tags }),
(tag: string) => tags.filter((selectedTag) => selectedTag !== tag),
(updateTags) => toFirstPage({ tags: updateTags }),
);
const canChangeTagsMode = supportsAllTagsFiltering(selectedServer);
const toggleTagsMode = pipe(
@ -46,27 +58,31 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer
);
return (
<div className="short-urls-filtering-bar-container">
<div className={classNames('short-urls-filtering-bar-container', className)}>
<SearchField initialValue={search} onChange={setSearch} />
<div className="mt-3">
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(startDate),
endDate: dateOrNull(endDate),
}}
onDatesChange={setDates}
/>
</div>
<Row className="flex-column-reverse flex-lg-row">
<div className="col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
</div>
</div>
<div className="col-12 d-block d-lg-none mt-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={order} onChange={handleOrderBy} />
</div>
<div className="col-lg-8 col-xl-6 mt-3">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={{
startDate: dateOrNull(startDate),
endDate: dateOrNull(endDate),
}}
onDatesChange={setDates}
/>
</div>
</Row>
{selectedTags.length > 0 && (
{tags.length > 0 && (
<h4 className="mt-3">
{canChangeTagsMode && selectedTags.length > 1 && (
{canChangeTagsMode && tags.length > 1 && (
<div className="float-end ms-2 mt-1">
<TooltipToggleSwitch
checked={tagsMode === 'all'}
@ -78,7 +94,7 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer
</div>
)}
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon me-1" />
{selectedTags.map((tag) =>
{tags.map((tag) =>
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
</h4>
)}

View file

@ -1,8 +1,7 @@
import { pipe } from 'ramda';
import { FC, useEffect, useMemo, useState } from 'react';
import { FC, useEffect, useState } from 'react';
import { Card } from 'reactstrap';
import { useLocation, useParams } from 'react-router-dom';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
@ -14,7 +13,8 @@ import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data';
import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
interface ShortUrlsListProps {
selectedServer: SelectedServer;
@ -23,12 +23,10 @@ interface ShortUrlsListProps {
settings: Settings;
}
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteringBar: FC) => boundToMercureHub(({
listShortUrls,
shortUrlsList,
selectedServer,
settings,
}: ShortUrlsListProps) => {
const ShortUrlsList = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const { page } = useParams();
const location = useLocation();
@ -37,7 +35,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
);
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
@ -48,28 +45,31 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe(
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
(tags) => toFirstPage({ tags }),
(newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
(updatedTags) => toFirstPage({ tags: updatedTags }),
);
useEffect(() => {
listShortUrls({
page,
searchTerm: search,
tags: selectedTags,
tags,
startDate,
endDate,
orderBy: actualOrderBy,
tagsMode,
});
}, [ page, search, selectedTags, startDate, endDate, actualOrderBy, tagsMode ]);
}, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
return (
<>
<div className="mb-3"><ShortUrlsFilteringBar /></div>
<div className="d-block d-lg-none mb-3">
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={actualOrderBy} onChange={handleOrderBy} />
</div>
<ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
className="mb-3"
/>
<Card body className="pb-1">
<ShortUrlsTable
selectedServer={selectedServer}

View file

@ -63,3 +63,12 @@ export const SHORT_URLS_ORDERABLE_FIELDS = {
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
export interface ExportableShortUrl {
createdAt: string;
title: string;
shortUrl: string;
longUrl: string;
tags: string;
visits: number;
}

View file

@ -0,0 +1,61 @@
import { FC } from 'react';
import { ExportBtn } from '../../utils/ExportBtn';
import { useToggle } from '../../utils/helpers/hooks';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { isServerWithId, SelectedServer } from '../../servers/data';
import { ShortUrl } from '../data';
import { ReportExporter } from '../../common/services/ReportExporter';
import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps {
amount?: number;
}
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20;
export const ExportShortUrlsBtn = (
buildShlinkApiClient: ShlinkApiClientBuilder,
{ exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [ loading,, startLoading, stopLoading ] = useToggle();
const exportAllUrls = async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage;
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
const { data } = await listShortUrls(
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
);
if (page >= totalPages) {
return data;
}
// TODO Support paralelization
return data.concat(await loadAllUrls(page + 1));
};
startLoading();
const shortUrls = await loadAllUrls();
exportShortUrls(shortUrls.map((shortUrl) => ({
createdAt: shortUrl.dateCreated,
shortUrl: shortUrl.shortUrl,
longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '',
tags: shortUrl.tags.join(','),
visits: shortUrl.visitsCount,
})));
stopLoading();
};
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
};

View file

@ -14,7 +14,6 @@ export interface ShortUrlListRouteParams {
}
interface ShortUrlsQueryCommon {
tags?: string;
search?: string;
startDate?: string;
endDate?: string;
@ -23,10 +22,12 @@ interface ShortUrlsQueryCommon {
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string;
tags?: string;
}
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder;
tags: string[];
}
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
@ -37,16 +38,22 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const query = useMemo(
pipe(
() => parseQuery<ShortUrlsQuery>(location.search),
({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : {
...rest,
orderBy: stringToOrder<ShortUrlsOrderableFields>(orderBy),
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
},
),
[ location.search ],
);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, ...mergedQuery } = { ...query, ...extra };
const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) };
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
const normalizedQuery: ShortUrlsQuery = {
...mergedQuery,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
};
const evolvedQuery = stringifyQuery(normalizedQuery);
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;

View file

@ -16,6 +16,7 @@ import QrCodeModal from '../helpers/QrCodeModal';
import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
@ -49,8 +50,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator');
bottle.decorator('ShortUrlsFilteringBar', connect([ 'selectedServer' ]));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect([ 'selectedServer' ]));
// Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');

16
src/utils/ExportBtn.tsx Normal file
View file

@ -0,0 +1,16 @@
import { FC } from 'react';
import { Button, ButtonProps } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
import { prettify } from './helpers/numbers';
interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabled'> {
amount?: number;
loading?: boolean;
}
export const ExportBtn: FC<ExportBtnProps> = ({ amount = 0, loading = false, ...rest }) => (
<Button {...rest} outline color="primary" disabled={loading}>
<FontAwesomeIcon icon={faFileDownload} /> {loading ? 'Exporting...' : <>Export ({prettify(amount)})</>}
</Button>
);

View file

@ -28,6 +28,6 @@
.search-field__close {
@include vertical-align();
right: 15px;
right: 10px;
cursor: pointer;
}

View file

@ -47,13 +47,11 @@ const SearchField = ({ onChange, className, large = true, noBorder = false, init
/>
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
<div
className="close search-field__close"
className="close search-field__close btn-close"
hidden={searchTerm === ''}
id="search-field__close"
onClick={() => searchTermChanged('', 0)}
>
&times;
</div>
/>
</div>
);
};

View file

@ -2,9 +2,9 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import VisitsStats from './VisitsStats';
import { NormalizedVisit, VisitsInfo, VisitsParams } from './types';
import { VisitsExporter } from './services/VisitsExporter';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { NonOrphanVisitsHeader } from './NonOrphanVisitsHeader';
@ -15,7 +15,7 @@ export interface NonOrphanVisitsProps extends CommonVisitsProps {
cancelGetNonOrphanVisits: () => void;
}
export const NonOrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getNonOrphanVisits,
nonOrphanVisits,
cancelGetNonOrphanVisits,

View file

@ -2,10 +2,10 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import VisitsStats from './VisitsStats';
import { OrphanVisitsHeader } from './OrphanVisitsHeader';
import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types';
import { VisitsExporter } from './services/VisitsExporter';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
@ -19,7 +19,7 @@ export interface OrphanVisitsProps extends CommonVisitsProps {
cancelGetOrphanVisits: () => void;
}
export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getOrphanVisits,
orphanVisits,
cancelGetOrphanVisits,

View file

@ -6,10 +6,10 @@ import { parseQuery } from '../utils/helpers/query';
import { Topics } from '../mercure/helpers/Topics';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
@ -22,7 +22,7 @@ export interface ShortUrlVisitsProps extends CommonVisitsProps {
cancelGetShortUrlVisits: () => void;
}
const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({
const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
shortUrlVisits,
shortUrlDetail,
getShortUrlVisits,

View file

@ -4,10 +4,10 @@ import ColorGenerator from '../utils/services/ColorGenerator';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import TagVisitsHeader from './TagVisitsHeader';
import VisitsStats from './VisitsStats';
import { VisitsExporter } from './services/VisitsExporter';
import { NormalizedVisit } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
@ -18,7 +18,7 @@ export interface TagVisitsProps extends CommonVisitsProps {
cancelGetTagVisits: () => void;
}
const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({
const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({
getTagVisits,
tagVisits,
cancelGetTagVisits,

View file

@ -2,7 +2,7 @@ import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC, useRef } from 'react';
import { Button, Progress, Row } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons';
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons';
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { Route, Routes, Navigate } from 'react-router-dom';
import classNames from 'classnames';
@ -16,6 +16,7 @@ import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers';
import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn';
import LineChartCard from './charts/LineChartCard';
import VisitsTable from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
@ -308,14 +309,11 @@ const VisitsStats: FC<VisitsStatsProps> = ({
>
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
</Button>
<Button
outline
color="primary"
<ExportBtn
className="btn-md-block"
amount={normalizedVisits.length}
onClick={() => exportCsv(normalizedVisits)}
>
<FontAwesomeIcon icon={faFileDownload} /> Export ({prettify(normalizedVisits.length)})
</Button>
/>
</div>
</div>
)}

View file

@ -1,20 +0,0 @@
import { CsvJson } from 'csvjson';
import { NormalizedVisit } from '../types';
import { saveCsv } from '../../utils/helpers/files';
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);
};
}

View file

@ -12,31 +12,30 @@ import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrp
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, 'VisitsExporter');
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter');
bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter');
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter');
bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter');
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
bottle.decorator('OrphanVisits', connect(
[ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'VisitsExporter');
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter');
bottle.decorator('NonOrphanVisits', connect(
[ 'nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
@ -44,7 +43,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.serviceFactory('VisitsParser', () => visitsParser);
bottle.service('VisitsExporter', VisitsExporter, 'window', 'csvjson');
// Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');

View file

@ -1,20 +1,21 @@
import { Mock } from 'ts-mockery';
import { CsvJson } from 'csvjson';
import { VisitsExporter } from '../../../src/visits/services/VisitsExporter';
import { ReportExporter } from '../../../src/common/services/ReportExporter';
import { NormalizedVisit } from '../../../src/visits/types';
import { windowMock } from '../../mocks/WindowMock';
import { ExportableShortUrl } from '../../../src/short-urls/data';
describe('VisitsExporter', () => {
describe('ReportExporter', () => {
const toCSV = jest.fn();
const csvToJsonMock = Mock.of<CsvJson>({ toCSV });
let exporter: VisitsExporter;
let exporter: ReportExporter;
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);
exporter = new ReportExporter(windowMock, csvToJsonMock);
});
describe('exportVisits', () => {
@ -35,7 +36,7 @@ describe('VisitsExporter', () => {
exporter.exportVisits('my_visits.csv', visits);
expect(toCSV).toHaveBeenCalledWith(visits, { headers: 'key' });
expect(toCSV).toHaveBeenCalledWith(visits, { headers: 'key', wrap: true });
});
it('skips execution when list of visits is empty', () => {
@ -44,4 +45,29 @@ describe('VisitsExporter', () => {
expect(toCSV).not.toHaveBeenCalled();
});
});
describe('exportShortUrls', () => {
it('parses provided short URLs to CSV', () => {
const shortUrls: ExportableShortUrl[] = [
{
shortUrl: 'shortUrl',
visits: 10,
title: '',
createdAt: '',
longUrl: '',
tags: '',
},
];
exporter.exportShortUrls(shortUrls);
expect(toCSV).toHaveBeenCalledWith(shortUrls, { headers: 'key', wrap: true });
});
it('skips execution when list of visits is empty', () => {
exporter.exportShortUrls([]);
expect(toCSV).not.toHaveBeenCalled();
});
});
});

View file

@ -9,6 +9,7 @@ import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
import ColorGenerator from '../../src/utils/services/ColorGenerator';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -19,14 +20,22 @@ jest.mock('react-router-dom', () => ({
describe('<ShortUrlsFilteringBar />', () => {
let wrapper: ShallowWrapper;
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>());
const ExportShortUrlsBtn = () => null;
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>(), ExportShortUrlsBtn);
const navigate = jest.fn();
const handleOrderBy = jest.fn();
const now = new Date();
const createWrapper = (search = '', selectedServer?: SelectedServer) => {
(useLocation as any).mockReturnValue({ search });
(useNavigate as any).mockReturnValue(navigate);
wrapper = shallow(<ShortUrlsFilteringBar selectedServer={selectedServer ?? Mock.all<SelectedServer>()} />);
wrapper = shallow(
<ShortUrlsFilteringBar
selectedServer={selectedServer ?? Mock.all<SelectedServer>()}
order={{}}
handleOrderBy={handleOrderBy}
/>,
);
return wrapper;
};
@ -34,11 +43,13 @@ describe('<ShortUrlsFilteringBar />', () => {
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('renders some children components SearchField', () => {
it('renders expected children components', () => {
const wrapper = createWrapper();
expect(wrapper.find(SearchField)).toHaveLength(1);
expect(wrapper.find(DateRangeSelector)).toHaveLength(1);
expect(wrapper.find(OrderingDropdown)).toHaveLength(1);
expect(wrapper.find(ExportShortUrlsBtn)).toHaveLength(1);
});
it.each([
@ -127,4 +138,19 @@ describe('<ShortUrlsFilteringBar />', () => {
toggle.simulate('change');
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
});
it('handles order through dropdown', () => {
const wrapper = createWrapper();
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({});
wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC');
expect(handleOrderBy).toHaveBeenCalledWith('visits', 'ASC');
wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC');
expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'DESC');
wrapper.find(OrderingDropdown).simulate('change', undefined, undefined);
expect(handleOrderBy).toHaveBeenCalledWith(undefined, undefined);
});
});

View file

@ -6,7 +6,6 @@ import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList';
import { ShortUrlsOrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
import Paginator from '../../src/short-urls/Paginator';
import { ReachableServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings';
@ -34,6 +33,7 @@ describe('<ShortUrlsList />', () => {
tags: [ 'test tag' ],
}),
],
pagination: {},
},
});
const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, ShortUrlsFilteringBar);
@ -58,7 +58,6 @@ describe('<ShortUrlsList />', () => {
it('wraps expected components', () => {
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
expect(wrapper.find(OrderingDropdown)).toHaveLength(1);
expect(wrapper.find(Paginator)).toHaveLength(1);
expect(wrapper.find(ShortUrlsFilteringBar)).toHaveLength(1);
});
@ -84,39 +83,26 @@ describe('<ShortUrlsList />', () => {
expect(renderIcon('visits').props.currentOrder).toEqual({});
wrapper.find(OrderingDropdown).simulate('change', 'visits');
(wrapper.find(ShortUrlsFilteringBar).prop('handleOrderBy') as Function)('visits');
expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits' });
wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC');
(wrapper.find(ShortUrlsFilteringBar).prop('handleOrderBy') as Function)('visits', 'ASC');
expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits', dir: 'ASC' });
});
it('handles order through table', () => {
const orderByColumn: (field: ShortUrlsOrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn');
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({});
expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({});
orderByColumn('visits')();
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
orderByColumn('title')();
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'title', dir: 'ASC' });
expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field: 'title', dir: 'ASC' });
orderByColumn('shortCode')();
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' });
});
it('handles order through dropdown', () => {
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({});
wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC');
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC');
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'DESC' });
wrapper.find(OrderingDropdown).simulate('change', undefined, undefined);
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({});
expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' });
});
it.each([
@ -126,6 +112,6 @@ describe('<ShortUrlsList />', () => {
])('has expected initial ordering', (initialOrderBy, field, dir) => {
const wrapper = createWrapper(initialOrderBy);
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field, dir });
expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field, dir });
});
});

View file

@ -0,0 +1,70 @@
import { Mock } from 'ts-mockery';
import { shallow, ShallowWrapper } from 'enzyme';
import { ReportExporter } from '../../../src/common/services/ReportExporter';
import { ExportShortUrlsBtn as createExportShortUrlsBtn } from '../../../src/short-urls/helpers/ExportShortUrlsBtn';
import { NotFoundServer, ReachableServer, SelectedServer } from '../../../src/servers/data';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn().mockReturnValue(jest.fn()),
useParams: jest.fn().mockReturnValue({}),
useLocation: jest.fn().mockReturnValue({}),
}));
describe('<ExportShortUrlsBtn />', () => {
const listShortUrls = jest.fn();
const buildShlinkApiClient = jest.fn().mockReturnValue({ listShortUrls });
const exportShortUrls = jest.fn();
const reportExporter = Mock.of<ReportExporter>({ exportShortUrls });
const ExportShortUrlsBtn = createExportShortUrlsBtn(buildShlinkApiClient, reportExporter);
let wrapper: ShallowWrapper;
const createWrapper = (amount?: number, selectedServer?: SelectedServer) => {
wrapper = shallow(
<ExportShortUrlsBtn selectedServer={selectedServer ?? Mock.all<SelectedServer>()} amount={amount} />,
);
return wrapper;
};
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it.each([
[ undefined, 0 ],
[ 1, 1 ],
[ 4578, 4578 ],
])('renders expected amount', (amount, expectedAmount) => {
const wrapper = createWrapper(amount);
expect(wrapper.prop('amount')).toEqual(expectedAmount);
});
it.each([
[ null ],
[ Mock.of<NotFoundServer>() ],
])('does nothing on click if selected server is not reachable', (selectedServer) => {
const wrapper = createWrapper(0, selectedServer);
wrapper.simulate('click');
expect(listShortUrls).not.toHaveBeenCalled();
expect(exportShortUrls).not.toHaveBeenCalled();
});
it.each([
[ 10, 1 ],
[ 30, 2 ],
[ 39, 2 ],
[ 40, 2 ],
[ 41, 3 ],
[ 385, 20 ],
])('loads proper amount of pages based on the amount of results', async (amount, expectedPageLoads) => {
const wrapper = createWrapper(amount, Mock.of<ReachableServer>({ id: '123' }));
listShortUrls.mockResolvedValue({ data: [] });
await (wrapper.prop('onClick') as Function)();
expect(listShortUrls).toHaveBeenCalledTimes(expectedPageLoads);
expect(exportShortUrls).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,47 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons';
import { ExportBtn } from '../../src/utils/ExportBtn';
describe('<ExportBtn />', () => {
let wrapper: ShallowWrapper;
const createWrapper = (amount?: number, loading = false) => {
wrapper = shallow(<ExportBtn amount={amount} loading={loading} />);
return wrapper;
};
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it.each([
[ true, 'Exporting...' ],
[ false, 'Export (' ],
])('renders a button', (loading, text) => {
const wrapper = createWrapper(undefined, loading);
expect(wrapper.prop('outline')).toEqual(true);
expect(wrapper.prop('color')).toEqual('primary');
expect(wrapper.prop('disabled')).toEqual(loading);
expect(wrapper.html()).toContain(text);
});
it.each([
[ undefined, '0' ],
[ 10, '10' ],
[ 10_000, '10,000' ],
[ 10_000_000, '10,000,000' ],
])('renders expected amount', (amount, expectedRenderedAmount) => {
const wrapper = createWrapper(amount);
expect(wrapper.html()).toContain(`Export (${expectedRenderedAmount})`);
});
it('renders expected icon', () => {
const wrapper = createWrapper();
const icon = wrapper.find(FontAwesomeIcon);
expect(icon).toHaveLength(1);
expect(icon.prop('icon')).toEqual(faFileDownload);
});
});

View file

@ -6,7 +6,7 @@ import { VisitsInfo } from '../../src/visits/types';
import VisitsStats from '../../src/visits/VisitsStats';
import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader';
import { Settings } from '../../src/settings/reducers/settings';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
import { ReportExporter } from '../../src/common/services/ReportExporter';
import { SelectedServer } from '../../src/servers/data';
jest.mock('react-router-dom', () => ({
@ -20,7 +20,7 @@ describe('<NonOrphanVisits />', () => {
const getNonOrphanVisits = jest.fn();
const cancelGetNonOrphanVisits = jest.fn();
const nonOrphanVisits = Mock.all<VisitsInfo>();
const NonOrphanVisits = createNonOrphanVisits(Mock.all<VisitsExporter>());
const NonOrphanVisits = createNonOrphanVisits(Mock.all<ReportExporter>());
const wrapper = shallow(
<NonOrphanVisits

View file

@ -6,7 +6,7 @@ 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';
import { ReportExporter } from '../../src/common/services/ReportExporter';
import { SelectedServer } from '../../src/servers/data';
jest.mock('react-router-dom', () => ({
@ -20,7 +20,7 @@ describe('<OrphanVisits />', () => {
const getOrphanVisits = jest.fn();
const cancelGetOrphanVisits = jest.fn();
const orphanVisits = Mock.all<VisitsInfo>();
const OrphanVisits = createOrphanVisits(Mock.all<VisitsExporter>());
const OrphanVisits = createOrphanVisits(Mock.all<ReportExporter>());
const wrapper = shallow(
<OrphanVisits

View file

@ -7,7 +7,7 @@ import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers
import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
import VisitsStats from '../../src/visits/VisitsStats';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { VisitsExporter } from '../../src/visits/services/VisitsExporter';
import { ReportExporter } from '../../src/common/services/ReportExporter';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -19,7 +19,7 @@ jest.mock('react-router-dom', () => ({
describe('<ShortUrlVisits />', () => {
let wrapper: ShallowWrapper;
const getShortUrlVisitsMock = jest.fn();
const ShortUrlVisits = createShortUrlVisits(Mock.all<VisitsExporter>());
const ShortUrlVisits = createShortUrlVisits(Mock.all<ReportExporter>());
beforeEach(() => {
wrapper = shallow(

View file

@ -6,7 +6,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';
import { ReportExporter } from '../../src/common/services/ReportExporter';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -20,7 +20,7 @@ describe('<TagVisits />', () => {
const getTagVisitsMock = jest.fn();
beforeEach(() => {
const TagVisits = createTagVisits(Mock.all<ColorGenerator>(), Mock.all<VisitsExporter>());
const TagVisits = createTagVisits(Mock.all<ColorGenerator>(), Mock.all<ReportExporter>());
wrapper = shallow(
<TagVisits

View file

@ -1,5 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Button, Progress } from 'reactstrap';
import { Progress } from 'reactstrap';
import { sum } from 'ramda';
import { Mock } from 'ts-mockery';
import { Route } from 'react-router-dom';
@ -13,6 +13,7 @@ import { Settings } from '../../src/settings/reducers/settings';
import { SelectedServer } from '../../src/servers/data';
import { SortableBarChartCard } from '../../src/visits/charts/SortableBarChartCard';
import { DoughnutChartCard } from '../../src/visits/charts/DoughnutChartCard';
import { ExportBtn } from '../../src/utils/ExportBtn';
describe('<VisitsStats />', () => {
const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
@ -106,7 +107,7 @@ describe('<VisitsStats />', () => {
it('exports CSV when export btn is clicked', () => {
const wrapper = createComponent({ visits });
const exportBtn = wrapper.find(Button).last();
const exportBtn = wrapper.find(ExportBtn).last();
expect(exportBtn).toHaveLength(1);
exportBtn.simulate('click');