diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6b0cb4..c2162c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added * [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility. -* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together. +* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0. * [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0. +* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV. ### Changed * [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section. diff --git a/shlink-web-client.d.ts b/shlink-web-client.d.ts index dbc6e0fd..a1713f06 100644 --- a/shlink-web-client.d.ts +++ b/shlink-web-client.d.ts @@ -10,7 +10,7 @@ declare module 'event-source-polyfill' { declare module 'csvjson' { export declare class CsvJson { public toObject(content: string): T[]; - public toCSV(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string; + public toCSV(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string; } } diff --git a/src/common/services/ReportExporter.ts b/src/common/services/ReportExporter.ts new file mode 100644 index 00000000..a80cdded --- /dev/null +++ b/src/common/services/ReportExporter.ts @@ -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); + }; +} diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index c085aeaf..05a20a0f 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -11,6 +11,7 @@ import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar'; import { ImageDownloader } from './ImageDownloader'; +import { ReportExporter } from './ReportExporter'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services @@ -19,6 +20,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.constant('axios', axios); bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); + bottle.service('ReportExporter', ReportExporter, 'window', 'csvjson'); // Components bottle.serviceFactory('ScrollToTop', ScrollToTop); diff --git a/src/settings/ShortUrlsListSettings.tsx b/src/settings/ShortUrlsListSettings.tsx index d38622cc..bb5f6f8c 100644 --- a/src/settings/ShortUrlsListSettings.tsx +++ b/src/settings/ShortUrlsListSettings.tsx @@ -5,12 +5,12 @@ import { SimpleCard } from '../utils/SimpleCard'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings'; -interface ShortUrlsListProps { +interface ShortUrlsListSettingsProps { settings: Settings; setShortUrlsListSettings: (settings: ShortUrlsSettings) => void; } -export const ShortUrlsListSettings: FC = ( +export const ShortUrlsListSettings: FC = ( { settings: { shortUrlsList }, setShortUrlsListSettings }, ) => ( diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 96643fe1..71082df2 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -1,7 +1,10 @@ +import { FC } from 'react'; import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEmpty, pipe } from 'ramda'; import { parseISO } from 'date-fns'; +import { Row } from 'reactstrap'; +import classNames from 'classnames'; import SearchField from '../utils/SearchField'; import Tag from '../tags/helpers/Tag'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; @@ -11,18 +14,28 @@ import { DateRange } from '../utils/dates/types'; import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch'; +import { OrderDir } from '../utils/helpers/ordering'; +import { OrderingDropdown } from '../utils/OrderingDropdown'; import { useShortUrlsQuery } from './helpers/hooks'; +import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; +import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import './ShortUrlsFilteringBar.scss'; -interface ShortUrlsFilteringProps { +export interface ShortUrlsFilteringProps { selectedServer: SelectedServer; + order: ShortUrlsOrder; + handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; + className?: string; + shortUrlsAmount?: number; } const dateOrNull = (date?: string) => date ? parseISO(date) : null; -const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedServer }: ShortUrlsFilteringProps) => { +const ShortUrlsFilteringBar = ( + colorGenerator: ColorGenerator, + ExportShortUrlsBtn: FC, +): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); - const selectedTags = tags?.split(',') ?? []; const setDates = pipe( ({ startDate, endDate }: DateRange) => ({ startDate: formatIsoDate(startDate) ?? undefined, @@ -35,9 +48,8 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer (search) => toFirstPage({ search }), ); const removeTag = pipe( - (tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag), - (tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','), - (tags) => toFirstPage({ tags }), + (tag: string) => tags.filter((selectedTag) => selectedTag !== tag), + (updateTags) => toFirstPage({ tags: updateTags }), ); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const toggleTagsMode = pipe( @@ -46,27 +58,31 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer ); return ( -
+
-
-
-
- -
+ +
+
-
+
+ +
+
+ +
+ - {selectedTags.length > 0 && ( + {tags.length > 0 && (

- {canChangeTagsMode && selectedTags.length > 1 && ( + {canChangeTagsMode && tags.length > 1 && (
({ selectedSer
)} - {selectedTags.map((tag) => + {tags.map((tag) => removeTag(tag)} />)}

)} diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 4e966653..363c8e18 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,8 +1,7 @@ import { pipe } from 'ramda'; -import { FC, useEffect, useMemo, useState } from 'react'; +import { FC, useEffect, useState } from 'react'; import { Card } from 'reactstrap'; import { useLocation, useParams } from 'react-router-dom'; -import { OrderingDropdown } from '../utils/OrderingDropdown'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { getServerId, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; @@ -14,7 +13,8 @@ import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { useShortUrlsQuery } from './helpers/hooks'; -import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data'; +import { ShortUrlsOrderableFields } from './data'; +import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar'; interface ShortUrlsListProps { selectedServer: SelectedServer; @@ -23,12 +23,10 @@ interface ShortUrlsListProps { settings: Settings; } -const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteringBar: FC) => boundToMercureHub(({ - listShortUrls, - shortUrlsList, - selectedServer, - settings, -}: ShortUrlsListProps) => { +const ShortUrlsList = ( + ShortUrlsTable: FC, + ShortUrlsFilteringBar: FC, +) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); const { page } = useParams(); const location = useLocation(); @@ -37,7 +35,6 @@ const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteri // This separated state handling is needed to be able to fall back to settings value, but only once when loaded orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, ); - const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); const { pagination } = shortUrlsList?.shortUrls ?? {}; const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { toFirstPage({ orderBy: { field, dir } }); @@ -48,28 +45,31 @@ const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteri const renderOrderIcon = (field: ShortUrlsOrderableFields) => ; const addTag = pipe( - (newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','), - (tags) => toFirstPage({ tags }), + (newTag: string) => [ ...new Set([ ...tags, newTag ]) ], + (updatedTags) => toFirstPage({ tags: updatedTags }), ); useEffect(() => { listShortUrls({ page, searchTerm: search, - tags: selectedTags, + tags, startDate, endDate, orderBy: actualOrderBy, tagsMode, }); - }, [ page, search, selectedTags, startDate, endDate, actualOrderBy, tagsMode ]); + }, [ page, search, tags, startDate, endDate, actualOrderBy, tagsMode ]); return ( <> -
-
- -
+ ; + +export interface ExportableShortUrl { + createdAt: string; + title: string; + shortUrl: string; + longUrl: string; + tags: string; + visits: number; +} diff --git a/src/short-urls/helpers/ExportShortUrlsBtn.tsx b/src/short-urls/helpers/ExportShortUrlsBtn.tsx new file mode 100644 index 00000000..be5490c2 --- /dev/null +++ b/src/short-urls/helpers/ExportShortUrlsBtn.tsx @@ -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 => ({ 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 => { + 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 ; +}; diff --git a/src/short-urls/helpers/hooks.ts b/src/short-urls/helpers/hooks.ts index 9e55e026..7505db47 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/src/short-urls/helpers/hooks.ts @@ -14,7 +14,6 @@ export interface ShortUrlListRouteParams { } interface ShortUrlsQueryCommon { - tags?: string; search?: string; startDate?: string; endDate?: string; @@ -23,10 +22,12 @@ interface ShortUrlsQueryCommon { interface ShortUrlsQuery extends ShortUrlsQueryCommon { orderBy?: string; + tags?: string; } interface ShortUrlsFiltering extends ShortUrlsQueryCommon { orderBy?: ShortUrlsOrder; + tags: string[]; } export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { @@ -37,16 +38,22 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { const query = useMemo( pipe( () => parseQuery(location.search), - ({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : { - ...rest, - orderBy: stringToOrder(orderBy), + ({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { + const parsedOrderBy = orderBy ? stringToOrder(orderBy) : undefined; + const parsedTags = tags?.split(',') ?? []; + + return { ...rest, orderBy: parsedOrderBy, tags: parsedTags }; }, ), [ location.search ], ); const toFirstPageWithExtra = (extra: Partial) => { - const { orderBy, ...mergedQuery } = { ...query, ...extra }; - const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) }; + const { orderBy, tags, ...mergedQuery } = { ...query, ...extra }; + const normalizedQuery: ShortUrlsQuery = { + ...mergedQuery, + orderBy: orderBy && orderToString(orderBy), + tags: tags.length > 0 ? tags.join(',') : undefined, + }; const evolvedQuery = stringifyQuery(normalizedQuery); const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 82e40f4e..165cf91d 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -16,6 +16,7 @@ import QrCodeModal from '../helpers/QrCodeModal'; import { ShortUrlForm } from '../ShortUrlForm'; import { EditShortUrl } from '../EditShortUrl'; import { getShortUrlDetail } from '../reducers/shortUrlDetail'; +import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components @@ -49,8 +50,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion'); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); - bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator'); - bottle.decorator('ShortUrlsFilteringBar', connect([ 'selectedServer' ])); + bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn'); + + bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); + bottle.decorator('ExportShortUrlsBtn', connect([ 'selectedServer' ])); // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); diff --git a/src/utils/ExportBtn.tsx b/src/utils/ExportBtn.tsx new file mode 100644 index 00000000..2a0a78c9 --- /dev/null +++ b/src/utils/ExportBtn.tsx @@ -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 { + amount?: number; + loading?: boolean; +} + +export const ExportBtn: FC = ({ amount = 0, loading = false, ...rest }) => ( + +); diff --git a/src/utils/SearchField.scss b/src/utils/SearchField.scss index 5877b9ce..513844b4 100644 --- a/src/utils/SearchField.scss +++ b/src/utils/SearchField.scss @@ -28,6 +28,6 @@ .search-field__close { @include vertical-align(); - right: 15px; + right: 10px; cursor: pointer; } diff --git a/src/utils/SearchField.tsx b/src/utils/SearchField.tsx index e76fa1ee..e3997e83 100644 --- a/src/utils/SearchField.tsx +++ b/src/utils/SearchField.tsx @@ -47,13 +47,11 @@ const SearchField = ({ onChange, className, large = true, noBorder = false, init /> + />
); }; diff --git a/src/visits/NonOrphanVisits.tsx b/src/visits/NonOrphanVisits.tsx index e6dc6a26..b5e4a1eb 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/src/visits/NonOrphanVisits.tsx @@ -2,9 +2,9 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; +import { ReportExporter } from '../common/services/ReportExporter'; import VisitsStats from './VisitsStats'; import { NormalizedVisit, VisitsInfo, VisitsParams } from './types'; -import { VisitsExporter } from './services/VisitsExporter'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { NonOrphanVisitsHeader } from './NonOrphanVisitsHeader'; @@ -15,7 +15,7 @@ export interface NonOrphanVisitsProps extends CommonVisitsProps { cancelGetNonOrphanVisits: () => void; } -export const NonOrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ +export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({ getNonOrphanVisits, nonOrphanVisits, cancelGetNonOrphanVisits, diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index a659f5ea..e2e20393 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -2,10 +2,10 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; +import { ReportExporter } from '../common/services/ReportExporter'; import VisitsStats from './VisitsStats'; import { OrphanVisitsHeader } from './OrphanVisitsHeader'; import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types'; -import { VisitsExporter } from './services/VisitsExporter'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; @@ -19,7 +19,7 @@ export interface OrphanVisitsProps extends CommonVisitsProps { cancelGetOrphanVisits: () => void; } -export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ +export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({ getOrphanVisits, orphanVisits, cancelGetOrphanVisits, diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index 5956006f..153da858 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -6,10 +6,10 @@ import { parseQuery } from '../utils/helpers/query'; import { Topics } from '../mercure/helpers/Topics'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import { useGoBack } from '../utils/helpers/hooks'; +import { ReportExporter } from '../common/services/ReportExporter'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import VisitsStats from './VisitsStats'; -import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; @@ -22,7 +22,7 @@ export interface ShortUrlVisitsProps extends CommonVisitsProps { cancelGetShortUrlVisits: () => void; } -const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ +const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({ shortUrlVisits, shortUrlDetail, getShortUrlVisits, diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index 7305aaaf..993edca7 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -4,10 +4,10 @@ import ColorGenerator from '../utils/services/ColorGenerator'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; +import { ReportExporter } from '../common/services/ReportExporter'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import TagVisitsHeader from './TagVisitsHeader'; import VisitsStats from './VisitsStats'; -import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; @@ -18,7 +18,7 @@ export interface TagVisitsProps extends CommonVisitsProps { cancelGetTagVisits: () => void; } -const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExporter) => boundToMercureHub(({ +const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({ getTagVisits, tagVisits, cancelGetTagVisits, diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 5dfaaad3..cb0b8e77 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -2,7 +2,7 @@ import { isEmpty, propEq, values } from 'ramda'; import { useState, useEffect, useMemo, FC, useRef } from 'react'; import { Button, Progress, Row } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons'; +import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons'; import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { Route, Routes, Navigate } from 'react-router-dom'; import classNames from 'classnames'; @@ -16,6 +16,7 @@ import { SelectedServer } from '../servers/data'; import { supportsBotVisits } from '../utils/helpers/features'; import { prettify } from '../utils/helpers/numbers'; import { NavPillItem, NavPills } from '../utils/NavPills'; +import { ExportBtn } from '../utils/ExportBtn'; import LineChartCard from './charts/LineChartCard'; import VisitsTable from './VisitsTable'; import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types'; @@ -308,14 +309,11 @@ const VisitsStats: FC = ({ > Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})} - + />
)} diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts deleted file mode 100644 index ff863e8b..00000000 --- a/src/visits/services/VisitsExporter.ts +++ /dev/null @@ -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); - }; -} diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 626d1ee1..90f02dda 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -12,31 +12,30 @@ import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrp import { ConnectDecorator } from '../../container/types'; import { loadVisitsOverview } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; -import { VisitsExporter } from './VisitsExporter'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('MapModal', () => MapModal); - bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsExporter'); + bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter'); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], )); - bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'VisitsExporter'); + bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter'); bottle.decorator('TagVisits', connect( [ 'tagVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], )); - bottle.serviceFactory('OrphanVisits', OrphanVisits, 'VisitsExporter'); + bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); bottle.decorator('OrphanVisits', connect( [ 'orphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], )); - bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'VisitsExporter'); + bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter'); bottle.decorator('NonOrphanVisits', connect( [ 'nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], [ 'getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], @@ -44,7 +43,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services bottle.serviceFactory('VisitsParser', () => visitsParser); - bottle.service('VisitsExporter', VisitsExporter, 'window', 'csvjson'); // Actions bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); diff --git a/test/visits/services/VisitsExporter.test.ts b/test/common/services/ReportExporter.test.ts similarity index 56% rename from test/visits/services/VisitsExporter.test.ts rename to test/common/services/ReportExporter.test.ts index 40a34600..300a2168 100644 --- a/test/visits/services/VisitsExporter.test.ts +++ b/test/common/services/ReportExporter.test.ts @@ -1,20 +1,21 @@ import { Mock } from 'ts-mockery'; import { CsvJson } from 'csvjson'; -import { VisitsExporter } from '../../../src/visits/services/VisitsExporter'; +import { ReportExporter } from '../../../src/common/services/ReportExporter'; import { NormalizedVisit } from '../../../src/visits/types'; import { windowMock } from '../../mocks/WindowMock'; +import { ExportableShortUrl } from '../../../src/short-urls/data'; -describe('VisitsExporter', () => { +describe('ReportExporter', () => { const toCSV = jest.fn(); const csvToJsonMock = Mock.of({ toCSV }); - let exporter: VisitsExporter; + let exporter: ReportExporter; beforeEach(jest.clearAllMocks); beforeEach(() => { (global as any).Blob = class Blob {}; // eslint-disable-line @typescript-eslint/no-extraneous-class (global as any).URL = { createObjectURL: () => '' }; - exporter = new VisitsExporter(windowMock, csvToJsonMock); + exporter = new ReportExporter(windowMock, csvToJsonMock); }); describe('exportVisits', () => { @@ -35,7 +36,7 @@ describe('VisitsExporter', () => { exporter.exportVisits('my_visits.csv', visits); - expect(toCSV).toHaveBeenCalledWith(visits, { headers: 'key' }); + expect(toCSV).toHaveBeenCalledWith(visits, { headers: 'key', wrap: true }); }); it('skips execution when list of visits is empty', () => { @@ -44,4 +45,29 @@ describe('VisitsExporter', () => { expect(toCSV).not.toHaveBeenCalled(); }); }); + + describe('exportShortUrls', () => { + it('parses provided short URLs to CSV', () => { + const shortUrls: ExportableShortUrl[] = [ + { + shortUrl: 'shortUrl', + visits: 10, + title: '', + createdAt: '', + longUrl: '', + tags: '', + }, + ]; + + exporter.exportShortUrls(shortUrls); + + expect(toCSV).toHaveBeenCalledWith(shortUrls, { headers: 'key', wrap: true }); + }); + + it('skips execution when list of visits is empty', () => { + exporter.exportShortUrls([]); + + expect(toCSV).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index e8718216..4e0dbbe6 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -9,6 +9,7 @@ import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch'; +import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -19,14 +20,22 @@ jest.mock('react-router-dom', () => ({ describe('', () => { let wrapper: ShallowWrapper; - const ShortUrlsFilteringBar = filteringBarCreator(Mock.all()); + const ExportShortUrlsBtn = () => null; + const ShortUrlsFilteringBar = filteringBarCreator(Mock.all(), ExportShortUrlsBtn); const navigate = jest.fn(); + const handleOrderBy = jest.fn(); const now = new Date(); const createWrapper = (search = '', selectedServer?: SelectedServer) => { (useLocation as any).mockReturnValue({ search }); (useNavigate as any).mockReturnValue(navigate); - wrapper = shallow(()} />); + wrapper = shallow( + ()} + order={{}} + handleOrderBy={handleOrderBy} + />, + ); return wrapper; }; @@ -34,11 +43,13 @@ describe('', () => { afterEach(jest.clearAllMocks); afterEach(() => wrapper?.unmount()); - it('renders some children components SearchField', () => { + it('renders expected children components', () => { const wrapper = createWrapper(); expect(wrapper.find(SearchField)).toHaveLength(1); expect(wrapper.find(DateRangeSelector)).toHaveLength(1); + expect(wrapper.find(OrderingDropdown)).toHaveLength(1); + expect(wrapper.find(ExportShortUrlsBtn)).toHaveLength(1); }); it.each([ @@ -127,4 +138,19 @@ describe('', () => { toggle.simulate('change'); expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); }); + + it('handles order through dropdown', () => { + const wrapper = createWrapper(); + + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); + + wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); + expect(handleOrderBy).toHaveBeenCalledWith('visits', 'ASC'); + + wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC'); + expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'DESC'); + + wrapper.find(OrderingDropdown).simulate('change', undefined, undefined); + expect(handleOrderBy).toHaveBeenCalledWith(undefined, undefined); + }); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 8ce2ffbc..dae43287 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -6,7 +6,6 @@ import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList'; import { ShortUrlsOrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; -import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { Settings } from '../../src/settings/reducers/settings'; @@ -34,6 +33,7 @@ describe('', () => { tags: [ 'test tag' ], }), ], + pagination: {}, }, }); const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, ShortUrlsFilteringBar); @@ -58,7 +58,6 @@ describe('', () => { it('wraps expected components', () => { expect(wrapper.find(ShortUrlsTable)).toHaveLength(1); - expect(wrapper.find(OrderingDropdown)).toHaveLength(1); expect(wrapper.find(Paginator)).toHaveLength(1); expect(wrapper.find(ShortUrlsFilteringBar)).toHaveLength(1); }); @@ -84,39 +83,26 @@ describe('', () => { expect(renderIcon('visits').props.currentOrder).toEqual({}); - wrapper.find(OrderingDropdown).simulate('change', 'visits'); + (wrapper.find(ShortUrlsFilteringBar).prop('handleOrderBy') as Function)('visits'); expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits' }); - wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); + (wrapper.find(ShortUrlsFilteringBar).prop('handleOrderBy') as Function)('visits', 'ASC'); expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits', dir: 'ASC' }); }); it('handles order through table', () => { const orderByColumn: (field: ShortUrlsOrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); + expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({}); orderByColumn('visits')(); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); + expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); orderByColumn('title')(); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'title', dir: 'ASC' }); + expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field: 'title', dir: 'ASC' }); orderByColumn('shortCode')(); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); - }); - - it('handles order through dropdown', () => { - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); - - wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); - - wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC'); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'DESC' }); - - wrapper.find(OrderingDropdown).simulate('change', undefined, undefined); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); + expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); }); it.each([ @@ -126,6 +112,6 @@ describe('', () => { ])('has expected initial ordering', (initialOrderBy, field, dir) => { const wrapper = createWrapper(initialOrderBy); - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field, dir }); + expect(wrapper.find(ShortUrlsFilteringBar).prop('order')).toEqual({ field, dir }); }); }); diff --git a/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx b/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx new file mode 100644 index 00000000..b08a11c4 --- /dev/null +++ b/test/short-urls/helpers/ExportShortUrlsBtn.test.tsx @@ -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('', () => { + const listShortUrls = jest.fn(); + const buildShlinkApiClient = jest.fn().mockReturnValue({ listShortUrls }); + const exportShortUrls = jest.fn(); + const reportExporter = Mock.of({ exportShortUrls }); + const ExportShortUrlsBtn = createExportShortUrlsBtn(buildShlinkApiClient, reportExporter); + let wrapper: ShallowWrapper; + const createWrapper = (amount?: number, selectedServer?: SelectedServer) => { + wrapper = shallow( + ()} 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() ], + ])('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({ id: '123' })); + + listShortUrls.mockResolvedValue({ data: [] }); + + await (wrapper.prop('onClick') as Function)(); + + expect(listShortUrls).toHaveBeenCalledTimes(expectedPageLoads); + expect(exportShortUrls).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/utils/ExportBtn.test.tsx b/test/utils/ExportBtn.test.tsx new file mode 100644 index 00000000..97d27eee --- /dev/null +++ b/test/utils/ExportBtn.test.tsx @@ -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('', () => { + let wrapper: ShallowWrapper; + const createWrapper = (amount?: number, loading = false) => { + wrapper = shallow(); + + 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); + }); +}); diff --git a/test/visits/NonOrphanVisits.test.tsx b/test/visits/NonOrphanVisits.test.tsx index c1af476b..db6e7666 100644 --- a/test/visits/NonOrphanVisits.test.tsx +++ b/test/visits/NonOrphanVisits.test.tsx @@ -6,7 +6,7 @@ import { VisitsInfo } from '../../src/visits/types'; import VisitsStats from '../../src/visits/VisitsStats'; import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader'; import { Settings } from '../../src/settings/reducers/settings'; -import { VisitsExporter } from '../../src/visits/services/VisitsExporter'; +import { ReportExporter } from '../../src/common/services/ReportExporter'; import { SelectedServer } from '../../src/servers/data'; jest.mock('react-router-dom', () => ({ @@ -20,7 +20,7 @@ describe('', () => { const getNonOrphanVisits = jest.fn(); const cancelGetNonOrphanVisits = jest.fn(); const nonOrphanVisits = Mock.all(); - const NonOrphanVisits = createNonOrphanVisits(Mock.all()); + const NonOrphanVisits = createNonOrphanVisits(Mock.all()); const wrapper = shallow( ({ @@ -20,7 +20,7 @@ describe('', () => { const getOrphanVisits = jest.fn(); const cancelGetOrphanVisits = jest.fn(); const orphanVisits = Mock.all(); - const OrphanVisits = createOrphanVisits(Mock.all()); + const OrphanVisits = createOrphanVisits(Mock.all()); const wrapper = shallow( ({ ...jest.requireActual('react-router-dom'), @@ -19,7 +19,7 @@ jest.mock('react-router-dom', () => ({ describe('', () => { let wrapper: ShallowWrapper; const getShortUrlVisitsMock = jest.fn(); - const ShortUrlVisits = createShortUrlVisits(Mock.all()); + const ShortUrlVisits = createShortUrlVisits(Mock.all()); beforeEach(() => { wrapper = shallow( diff --git a/test/visits/TagVisits.test.tsx b/test/visits/TagVisits.test.tsx index 10d60d6a..b95e7be4 100644 --- a/test/visits/TagVisits.test.tsx +++ b/test/visits/TagVisits.test.tsx @@ -6,7 +6,7 @@ import ColorGenerator from '../../src/utils/services/ColorGenerator'; import { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits'; import VisitsStats from '../../src/visits/VisitsStats'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import { VisitsExporter } from '../../src/visits/services/VisitsExporter'; +import { ReportExporter } from '../../src/common/services/ReportExporter'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -20,7 +20,7 @@ describe('', () => { const getTagVisitsMock = jest.fn(); beforeEach(() => { - const TagVisits = createTagVisits(Mock.all(), Mock.all()); + const TagVisits = createTagVisits(Mock.all(), Mock.all()); wrapper = shallow( ', () => { const visits = [ Mock.all(), Mock.all(), Mock.all() ]; @@ -106,7 +107,7 @@ describe('', () => { it('exports CSV when export btn is clicked', () => { const wrapper = createComponent({ visits }); - const exportBtn = wrapper.find(Button).last(); + const exportBtn = wrapper.find(ExportBtn).last(); expect(exportBtn).toHaveLength(1); exportBtn.simulate('click');