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] ## [Unreleased]
### Added ### 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. * [#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. * [#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 ### Changed
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section. * [#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' { 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: '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 { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar'; import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader'; import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
@ -19,6 +20,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.constant('axios', axios); bottle.constant('axios', axios);
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson');
// Components // Components
bottle.serviceFactory('ScrollToTop', ScrollToTop); bottle.serviceFactory('ScrollToTop', ScrollToTop);

View file

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

View file

@ -1,8 +1,7 @@
import { pipe } from 'ramda'; import { pipe } from 'ramda';
import { FC, useEffect, useMemo, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { OrderingDropdown } from '../utils/OrderingDropdown';
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
import { getServerId, SelectedServer } from '../servers/data'; import { getServerId, SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
@ -14,7 +13,8 @@ import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsTableProps } from './ShortUrlsTable'; import { ShortUrlsTableProps } from './ShortUrlsTable';
import Paginator from './Paginator'; import Paginator from './Paginator';
import { useShortUrlsQuery } from './helpers/hooks'; import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data'; import { ShortUrlsOrderableFields } from './data';
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
interface ShortUrlsListProps { interface ShortUrlsListProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
@ -23,12 +23,10 @@ interface ShortUrlsListProps {
settings: Settings; settings: Settings;
} }
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteringBar: FC) => boundToMercureHub(({ const ShortUrlsList = (
listShortUrls, ShortUrlsTable: FC<ShortUrlsTableProps>,
shortUrlsList, ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
selectedServer, ) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
settings,
}: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer); const serverId = getServerId(selectedServer);
const { page } = useParams(); const { page } = useParams();
const location = useLocation(); 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 // 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, orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
); );
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
const { pagination } = shortUrlsList?.shortUrls ?? {}; const { pagination } = shortUrlsList?.shortUrls ?? {};
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } }); toFirstPage({ orderBy: { field, dir } });
@ -48,28 +45,31 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteri
const renderOrderIcon = (field: ShortUrlsOrderableFields) => const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />; <TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe( const addTag = pipe(
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','), (newTag: string) => [ ...new Set([ ...tags, newTag ]) ],
(tags) => toFirstPage({ tags }), (updatedTags) => toFirstPage({ tags: updatedTags }),
); );
useEffect(() => { useEffect(() => {
listShortUrls({ listShortUrls({
page, page,
searchTerm: search, searchTerm: search,
tags: selectedTags, tags,
startDate, startDate,
endDate, endDate,
orderBy: actualOrderBy, orderBy: actualOrderBy,
tagsMode, tagsMode,
}); });
}, [ page, search, selectedTags, startDate, endDate, actualOrderBy, tagsMode ]); }, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]);
return ( return (
<> <>
<div className="mb-3"><ShortUrlsFilteringBar /></div> <ShortUrlsFilteringBar
<div className="d-block d-lg-none mb-3"> selectedServer={selectedServer}
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={actualOrderBy} onChange={handleOrderBy} /> shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
</div> order={actualOrderBy}
handleOrderBy={handleOrderBy}
className="mb-3"
/>
<Card body className="pb-1"> <Card body className="pb-1">
<ShortUrlsTable <ShortUrlsTable
selectedServer={selectedServer} 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 ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>; 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 { interface ShortUrlsQueryCommon {
tags?: string;
search?: string; search?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
@ -23,10 +22,12 @@ interface ShortUrlsQueryCommon {
interface ShortUrlsQuery extends ShortUrlsQueryCommon { interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string; orderBy?: string;
tags?: string;
} }
interface ShortUrlsFiltering extends ShortUrlsQueryCommon { interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder; orderBy?: ShortUrlsOrder;
tags: string[];
} }
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
@ -37,16 +38,22 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const query = useMemo( const query = useMemo(
pipe( pipe(
() => parseQuery<ShortUrlsQuery>(location.search), () => parseQuery<ShortUrlsQuery>(location.search),
({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : { ({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
...rest, const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
orderBy: stringToOrder<ShortUrlsOrderableFields>(orderBy), const parsedTags = tags?.split(',') ?? [];
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
}, },
), ),
[ location.search ], [ location.search ],
); );
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => { const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const { orderBy, ...mergedQuery } = { ...query, ...extra }; const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) }; const normalizedQuery: ShortUrlsQuery = {
...mergedQuery,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
};
const evolvedQuery = stringifyQuery(normalizedQuery); const evolvedQuery = stringifyQuery(normalizedQuery);
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`; const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;

View file

@ -16,6 +16,7 @@ import QrCodeModal from '../helpers/QrCodeModal';
import { ShortUrlForm } from '../ShortUrlForm'; import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl'; import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
@ -49,8 +50,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion'); bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator'); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn');
bottle.decorator('ShortUrlsFilteringBar', connect([ 'selectedServer' ]));
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect([ 'selectedServer' ]));
// Actions // Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); 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 { .search-field__close {
@include vertical-align(); @include vertical-align();
right: 15px; right: 10px;
cursor: pointer; cursor: pointer;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC, useRef } from 'react'; import { useState, useEffect, useMemo, FC, useRef } from 'react';
import { Button, Progress, Row } from 'reactstrap'; import { Button, Progress, Row } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { Route, Routes, Navigate } from 'react-router-dom'; import { Route, Routes, Navigate } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
@ -16,6 +16,7 @@ import { SelectedServer } from '../servers/data';
import { supportsBotVisits } from '../utils/helpers/features'; import { supportsBotVisits } from '../utils/helpers/features';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { NavPillItem, NavPills } from '../utils/NavPills'; import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn';
import LineChartCard from './charts/LineChartCard'; import LineChartCard from './charts/LineChartCard';
import VisitsTable from './VisitsTable'; import VisitsTable from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types'; import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
@ -308,14 +309,11 @@ const VisitsStats: FC<VisitsStatsProps> = ({
> >
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>} Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
</Button> </Button>
<Button <ExportBtn
outline
color="primary"
className="btn-md-block" className="btn-md-block"
amount={normalizedVisits.length}
onClick={() => exportCsv(normalizedVisits)} onClick={() => exportCsv(normalizedVisits)}
> />
<FontAwesomeIcon icon={faFileDownload} /> Export ({prettify(normalizedVisits.length)})
</Button>
</div> </div>
</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 { 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, 'VisitsExporter'); bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter');
bottle.decorator('ShortUrlVisits', connect( bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ], [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter'); bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter');
bottle.decorator('TagVisits', connect( bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter'); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
bottle.decorator('OrphanVisits', connect( bottle.decorator('OrphanVisits', connect(
[ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
)); ));
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'VisitsExporter'); bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter');
bottle.decorator('NonOrphanVisits', connect( bottle.decorator('NonOrphanVisits', connect(
[ 'nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer' ],
[ 'getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], [ 'getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
@ -44,7 +43,6 @@ 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

@ -1,20 +1,21 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { CsvJson } from 'csvjson'; 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 { NormalizedVisit } from '../../../src/visits/types';
import { windowMock } from '../../mocks/WindowMock'; import { windowMock } from '../../mocks/WindowMock';
import { ExportableShortUrl } from '../../../src/short-urls/data';
describe('VisitsExporter', () => { describe('ReportExporter', () => {
const toCSV = jest.fn(); const toCSV = jest.fn();
const csvToJsonMock = Mock.of<CsvJson>({ toCSV }); const csvToJsonMock = Mock.of<CsvJson>({ toCSV });
let exporter: VisitsExporter; let exporter: ReportExporter;
beforeEach(jest.clearAllMocks); beforeEach(jest.clearAllMocks);
beforeEach(() => { beforeEach(() => {
(global as any).Blob = class Blob {}; // eslint-disable-line @typescript-eslint/no-extraneous-class (global as any).Blob = class Blob {}; // eslint-disable-line @typescript-eslint/no-extraneous-class
(global as any).URL = { createObjectURL: () => '' }; (global as any).URL = { createObjectURL: () => '' };
exporter = new VisitsExporter(windowMock, csvToJsonMock); exporter = new ReportExporter(windowMock, csvToJsonMock);
}); });
describe('exportVisits', () => { describe('exportVisits', () => {
@ -35,7 +36,7 @@ describe('VisitsExporter', () => {
exporter.exportVisits('my_visits.csv', visits); 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', () => { it('skips execution when list of visits is empty', () => {
@ -44,4 +45,29 @@ describe('VisitsExporter', () => {
expect(toCSV).not.toHaveBeenCalled(); 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 ColorGenerator from '../../src/utils/services/ColorGenerator';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch'; import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -19,14 +20,22 @@ jest.mock('react-router-dom', () => ({
describe('<ShortUrlsFilteringBar />', () => { describe('<ShortUrlsFilteringBar />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>()); const ExportShortUrlsBtn = () => null;
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>(), ExportShortUrlsBtn);
const navigate = jest.fn(); const navigate = jest.fn();
const handleOrderBy = jest.fn();
const now = new Date(); const now = new Date();
const createWrapper = (search = '', selectedServer?: SelectedServer) => { const createWrapper = (search = '', selectedServer?: SelectedServer) => {
(useLocation as any).mockReturnValue({ search }); (useLocation as any).mockReturnValue({ search });
(useNavigate as any).mockReturnValue(navigate); (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; return wrapper;
}; };
@ -34,11 +43,13 @@ describe('<ShortUrlsFilteringBar />', () => {
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount()); afterEach(() => wrapper?.unmount());
it('renders some children components SearchField', () => { it('renders expected children components', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
expect(wrapper.find(SearchField)).toHaveLength(1); expect(wrapper.find(SearchField)).toHaveLength(1);
expect(wrapper.find(DateRangeSelector)).toHaveLength(1); expect(wrapper.find(DateRangeSelector)).toHaveLength(1);
expect(wrapper.find(OrderingDropdown)).toHaveLength(1);
expect(wrapper.find(ExportShortUrlsBtn)).toHaveLength(1);
}); });
it.each([ it.each([
@ -127,4 +138,19 @@ describe('<ShortUrlsFilteringBar />', () => {
toggle.simulate('change'); toggle.simulate('change');
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); 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 { ShortUrlsOrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
import Paginator from '../../src/short-urls/Paginator'; import Paginator from '../../src/short-urls/Paginator';
import { ReachableServer } from '../../src/servers/data'; import { ReachableServer } from '../../src/servers/data';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
@ -34,6 +33,7 @@ describe('<ShortUrlsList />', () => {
tags: [ 'test tag' ], tags: [ 'test tag' ],
}), }),
], ],
pagination: {},
}, },
}); });
const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, ShortUrlsFilteringBar); const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, ShortUrlsFilteringBar);
@ -58,7 +58,6 @@ describe('<ShortUrlsList />', () => {
it('wraps expected components', () => { it('wraps expected components', () => {
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1); expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
expect(wrapper.find(OrderingDropdown)).toHaveLength(1);
expect(wrapper.find(Paginator)).toHaveLength(1); expect(wrapper.find(Paginator)).toHaveLength(1);
expect(wrapper.find(ShortUrlsFilteringBar)).toHaveLength(1); expect(wrapper.find(ShortUrlsFilteringBar)).toHaveLength(1);
}); });
@ -84,39 +83,26 @@ describe('<ShortUrlsList />', () => {
expect(renderIcon('visits').props.currentOrder).toEqual({}); 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' }); 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' }); expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits', dir: 'ASC' });
}); });
it('handles order through table', () => { it('handles order through table', () => {
const orderByColumn: (field: ShortUrlsOrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); 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')(); 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')(); 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')(); orderByColumn('shortCode')();
expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); expect(wrapper.find(ShortUrlsFilteringBar).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({});
}); });
it.each([ it.each([
@ -126,6 +112,6 @@ describe('<ShortUrlsList />', () => {
])('has expected initial ordering', (initialOrderBy, field, dir) => { ])('has expected initial ordering', (initialOrderBy, field, dir) => {
const wrapper = createWrapper(initialOrderBy); 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 VisitsStats from '../../src/visits/VisitsStats';
import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader'; import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader';
import { Settings } from '../../src/settings/reducers/settings'; 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'; import { SelectedServer } from '../../src/servers/data';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
@ -20,7 +20,7 @@ describe('<NonOrphanVisits />', () => {
const getNonOrphanVisits = jest.fn(); const getNonOrphanVisits = jest.fn();
const cancelGetNonOrphanVisits = jest.fn(); const cancelGetNonOrphanVisits = jest.fn();
const nonOrphanVisits = Mock.all<VisitsInfo>(); const nonOrphanVisits = Mock.all<VisitsInfo>();
const NonOrphanVisits = createNonOrphanVisits(Mock.all<VisitsExporter>()); const NonOrphanVisits = createNonOrphanVisits(Mock.all<ReportExporter>());
const wrapper = shallow( const wrapper = shallow(
<NonOrphanVisits <NonOrphanVisits

View file

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

View file

@ -7,7 +7,7 @@ import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers
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'; import { ReportExporter } from '../../src/common/services/ReportExporter';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -19,7 +19,7 @@ jest.mock('react-router-dom', () => ({
describe('<ShortUrlVisits />', () => { describe('<ShortUrlVisits />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const getShortUrlVisitsMock = jest.fn(); const getShortUrlVisitsMock = jest.fn();
const ShortUrlVisits = createShortUrlVisits(Mock.all<VisitsExporter>()); const ShortUrlVisits = createShortUrlVisits(Mock.all<ReportExporter>());
beforeEach(() => { beforeEach(() => {
wrapper = shallow( 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 { 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'; import { ReportExporter } from '../../src/common/services/ReportExporter';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -20,7 +20,7 @@ describe('<TagVisits />', () => {
const getTagVisitsMock = jest.fn(); const getTagVisitsMock = jest.fn();
beforeEach(() => { beforeEach(() => {
const TagVisits = createTagVisits(Mock.all<ColorGenerator>(), Mock.all<VisitsExporter>()); const TagVisits = createTagVisits(Mock.all<ColorGenerator>(), Mock.all<ReportExporter>());
wrapper = shallow( wrapper = shallow(
<TagVisits <TagVisits

View file

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