mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #607 from acelaya-forks/feature/export-urls
Feature/export urls
This commit is contained in:
commit
56fa114f3c
31 changed files with 417 additions and 139 deletions
|
@ -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.
|
||||||
|
|
2
shlink-web-client.d.ts
vendored
2
shlink-web-client.d.ts
vendored
|
@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
|
||||||
declare module 'csvjson' {
|
declare module 'csvjson' {
|
||||||
export declare class CsvJson {
|
export declare class CsvJson {
|
||||||
public toObject<T>(content: string): T[];
|
public toObject<T>(content: string): T[];
|
||||||
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
|
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
33
src/common/services/ReportExporter.ts
Normal file
33
src/common/services/ReportExporter.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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,12 +58,17 @@ 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} />
|
||||||
|
</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
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={{
|
||||||
|
@ -61,12 +78,11 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Row>
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
61
src/short-urls/helpers/ExportShortUrlsBtn.tsx
Normal file
61
src/short-urls/helpers/ExportShortUrlsBtn.tsx
Normal 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} />;
|
||||||
|
};
|
|
@ -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}`;
|
||||||
|
|
||||||
|
|
|
@ -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
16
src/utils/ExportBtn.tsx
Normal 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>
|
||||||
|
);
|
|
@ -28,6 +28,6 @@
|
||||||
.search-field__close {
|
.search-field__close {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
||||||
right: 15px;
|
right: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)}
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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');
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
70
test/short-urls/helpers/ExportShortUrlsBtn.test.tsx
Normal file
70
test/short-urls/helpers/ExportShortUrlsBtn.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
47
test/utils/ExportBtn.test.tsx
Normal file
47
test/utils/ExportBtn.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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');
|
||||||
|
|
Loading…
Reference in a new issue