From 7fd360495ba18861ef449cb74e302137ff962057 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Mar 2022 20:51:30 +0100 Subject: [PATCH 1/9] Created button to use when anything needs to be exported --- src/settings/ShortUrlsListSettings.tsx | 4 +-- src/short-urls/ShortUrlsFilteringBar.tsx | 40 +++++++++++++--------- src/short-urls/ShortUrlsList.tsx | 13 ++++--- src/short-urls/services/provideServices.ts | 1 - src/utils/ExportBtn.tsx | 17 +++++++++ src/visits/VisitsStats.tsx | 12 +++---- test/visits/VisitsStats.test.tsx | 5 +-- 7 files changed, 57 insertions(+), 35 deletions(-) create mode 100644 src/utils/ExportBtn.tsx 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..7c89009f 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,16 +14,20 @@ import { DateRange } from '../utils/dates/types'; import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch'; +import { ExportBtn } from '../utils/ExportBtn'; import { useShortUrlsQuery } from './helpers/hooks'; import './ShortUrlsFilteringBar.scss'; -interface ShortUrlsFilteringProps { +export interface ShortUrlsFilteringProps { selectedServer: SelectedServer; + className?: string; } const dateOrNull = (date?: string) => date ? parseISO(date) : null; -const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedServer }: ShortUrlsFilteringProps) => { +const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC => ( + { selectedServer, className }, +) => { const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); const selectedTags = tags?.split(',') ?? []; const setDates = pipe( @@ -46,23 +53,24 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => ({ selectedSer ); return ( -
+
-
-
-
- -
+ +
+ {}} />
-
+
+ +
+ {selectedTags.length > 0 && (

diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 4e966653..d4828c7a 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -15,6 +15,7 @@ import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { useShortUrlsQuery } from './helpers/hooks'; import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data'; +import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar'; interface ShortUrlsListProps { selectedServer: SelectedServer; @@ -23,12 +24,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(); @@ -66,7 +65,7 @@ const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteri return ( <> -
+
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 82e40f4e..425a04a8 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -50,7 +50,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator'); - bottle.decorator('ShortUrlsFilteringBar', 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..f4f83cd8 --- /dev/null +++ b/src/utils/ExportBtn.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { Button } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFileDownload } from '@fortawesome/free-solid-svg-icons'; +import { prettify } from './helpers/numbers'; + +interface ExportBtnProps { + onClick: () => void; + amount?: number; + className?: string; +} + +export const ExportBtn: FC = ({ onClick, className, amount = 0 }) => ( + +); 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/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx index 1fe1f5c5..ed752d8e 100644 --- a/test/visits/VisitsStats.test.tsx +++ b/test/visits/VisitsStats.test.tsx @@ -1,5 +1,5 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import { Button, Progress } from 'reactstrap'; +import { Progress } from 'reactstrap'; import { sum } from 'ramda'; import { Mock } from 'ts-mockery'; import { Route } from 'react-router-dom'; @@ -13,6 +13,7 @@ import { Settings } from '../../src/settings/reducers/settings'; import { SelectedServer } from '../../src/servers/data'; import { SortableBarChartCard } from '../../src/visits/charts/SortableBarChartCard'; import { DoughnutChartCard } from '../../src/visits/charts/DoughnutChartCard'; +import { ExportBtn } from '../../src/utils/ExportBtn'; describe('', () => { 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'); From ef8db5e2cdb989e7fb3382a2f63deb72ea04c773 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Mar 2022 10:32:27 +0100 Subject: [PATCH 2/9] Moved short URL ordering dropdown to ShortUrlsFilteringBar to simplify positioning --- src/short-urls/ShortUrlsFilteringBar.tsx | 13 +++++++-- src/short-urls/ShortUrlsList.tsx | 13 +++++---- .../short-urls/ShortUrlsFilteringBar.test.tsx | 28 ++++++++++++++++-- test/short-urls/ShortUrlsList.test.tsx | 29 +++++-------------- 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 7c89009f..e7511cf2 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -15,18 +15,23 @@ import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch'; import { ExportBtn } from '../utils/ExportBtn'; +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 './ShortUrlsFilteringBar.scss'; export interface ShortUrlsFilteringProps { selectedServer: SelectedServer; + order: ShortUrlsOrder; + handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; className?: string; } const dateOrNull = (date?: string) => date ? parseISO(date) : null; const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC => ( - { selectedServer, className }, + { selectedServer, className, order, handleOrderBy }, ) => { const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); const selectedTags = tags?.split(',') ?? []; @@ -73,7 +78,7 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC {selectedTags.length > 0 && ( -

+

{canChangeTagsMode && selectedTags.length > 1 && (
removeTag(tag)} />)}

)} + +
+ +
); }; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index d4828c7a..1eddadc6 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -2,7 +2,6 @@ import { pipe } from 'ramda'; import { FC, useEffect, useMemo, 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,7 @@ 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 { @@ -65,10 +64,12 @@ const ShortUrlsList = ( return ( <> - -
- -
+ ({ ...jest.requireActual('react-router-dom'), @@ -21,12 +22,19 @@ describe('', () => { let wrapper: ShallowWrapper; const ShortUrlsFilteringBar = filteringBarCreator(Mock.all()); 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 +42,12 @@ 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); }); it.each([ @@ -127,4 +136,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..0184629e 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'; @@ -58,7 +57,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 +82,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 +111,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 }); }); }); From a26019ca785c760ad275175b3f4151c9dc5d8505 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Mar 2022 10:43:57 +0100 Subject: [PATCH 3/9] Re-positioned components in short urls list for consistency with other sections --- src/short-urls/ShortUrlsFilteringBar.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index e7511cf2..915ce4e7 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -61,10 +61,13 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC - +
{}} />
+
+ +
{selectedTags.length > 0 && ( -

+

{canChangeTagsMode && selectedTags.length > 1 && (
removeTag(tag)} />)}

)} - -
- -
); }; From 47d30aaa34caf2210a808f15a4880791e0776f51 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Mar 2022 11:00:45 +0100 Subject: [PATCH 4/9] Created ExportBtn test --- test/utils/ExportBtn.test.tsx | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/utils/ExportBtn.test.tsx diff --git a/test/utils/ExportBtn.test.tsx b/test/utils/ExportBtn.test.tsx new file mode 100644 index 00000000..7ee7b705 --- /dev/null +++ b/test/utils/ExportBtn.test.tsx @@ -0,0 +1,57 @@ +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 onClick = jest.fn(); + const createWrapper = (className?: string, amount?: number) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it.each([ + [ undefined ], + [ 'foo' ], + [ 'bar' ], + ])('renders a button', (className) => { + const wrapper = createWrapper(className); + + expect(wrapper.prop('outline')).toEqual(true); + expect(wrapper.prop('color')).toEqual('primary'); + expect(wrapper.prop('onClick')).toEqual(onClick); + expect(wrapper.prop('className')).toEqual(className); + }); + + it.each([ + [ undefined, '0' ], + [ 10, '10' ], + [ 10_000, '10,000' ], + [ 10_000_000, '10,000,000' ], + ])('renders expected amount', (amount, expectedRenderedAmount) => { + const wrapper = createWrapper(undefined, 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); + }); + + it('invokes callback onClick', () => { + const wrapper = createWrapper(); + + expect(onClick).not.toHaveBeenCalled(); + wrapper.simulate('click'); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); From e632c5b04f513a49d223917944de398e46d81b57 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Mar 2022 11:14:30 +0100 Subject: [PATCH 5/9] Abstracted logic to parse tags from string to array and back for the query --- src/short-urls/ShortUrlsFilteringBar.tsx | 12 +++++------- src/short-urls/ShortUrlsList.tsx | 11 +++++------ src/short-urls/helpers/hooks.ts | 19 +++++++++++++------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 915ce4e7..610203cb 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -34,7 +34,6 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC { const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); - const selectedTags = tags?.split(',') ?? []; const setDates = pipe( ({ startDate, endDate }: DateRange) => ({ startDate: formatIsoDate(startDate) ?? undefined, @@ -47,9 +46,8 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC 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( @@ -80,9 +78,9 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC
- {selectedTags.length > 0 && ( + {tags.length > 0 && (

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

)} diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 1eddadc6..fb1c2115 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,5 +1,5 @@ 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 { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; @@ -35,7 +35,6 @@ const ShortUrlsList = ( // 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 } }); @@ -46,21 +45,21 @@ const ShortUrlsList = ( 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 ( <> 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}`; From 92ddcad7535d8fd62d520554877910ab20cd39df Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Mar 2022 18:56:42 +0100 Subject: [PATCH 6/9] Implemented short URLs exporting --- shlink-web-client.d.ts | 2 +- src/common/services/ReportExporter.ts | 33 ++++++++++ src/common/services/provideServices.ts | 2 + src/short-urls/ShortUrlsFilteringBar.tsx | 12 ++-- src/short-urls/ShortUrlsList.tsx | 1 + src/short-urls/data/index.ts | 9 +++ src/short-urls/helpers/ExportShortUrlsBtn.tsx | 66 +++++++++++++++++++ src/short-urls/services/provideServices.ts | 6 +- src/utils/ExportBtn.tsx | 13 ++-- src/visits/NonOrphanVisits.tsx | 4 +- src/visits/OrphanVisits.tsx | 4 +- src/visits/ShortUrlVisits.tsx | 4 +- src/visits/TagVisits.tsx | 4 +- src/visits/services/VisitsExporter.ts | 20 ------ src/visits/services/provideServices.ts | 10 ++- .../services/ReportExporter.test.ts} | 10 +-- .../short-urls/ShortUrlsFilteringBar.test.tsx | 4 +- test/short-urls/ShortUrlsList.test.tsx | 1 + test/utils/ExportBtn.test.tsx | 28 +++----- test/visits/NonOrphanVisits.test.tsx | 4 +- test/visits/OrphanVisits.test.tsx | 4 +- test/visits/ShortUrlVisits.test.tsx | 4 +- test/visits/TagVisits.test.tsx | 4 +- 23 files changed, 168 insertions(+), 81 deletions(-) create mode 100644 src/common/services/ReportExporter.ts create mode 100644 src/short-urls/helpers/ExportShortUrlsBtn.tsx delete mode 100644 src/visits/services/VisitsExporter.ts rename test/{visits/services/VisitsExporter.test.ts => common/services/ReportExporter.test.ts} (84%) 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/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 610203cb..71082df2 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -14,11 +14,11 @@ import { DateRange } from '../utils/dates/types'; import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch'; -import { ExportBtn } from '../utils/ExportBtn'; 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'; export interface ShortUrlsFilteringProps { @@ -26,13 +26,15 @@ export interface ShortUrlsFilteringProps { order: ShortUrlsOrder; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; className?: string; + shortUrlsAmount?: number; } const dateOrNull = (date?: string) => date ? parseISO(date) : null; -const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC => ( - { selectedServer, className, order, handleOrderBy }, -) => { +const ShortUrlsFilteringBar = ( + colorGenerator: ColorGenerator, + ExportShortUrlsBtn: FC, +): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage ] = useShortUrlsQuery(); const setDates = pipe( ({ startDate, endDate }: DateRange) => ({ @@ -61,7 +63,7 @@ const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator): FC
- {}} /> +
diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index fb1c2115..363c8e18 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -65,6 +65,7 @@ const ShortUrlsList = ( <> ; + +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..03b79584 --- /dev/null +++ b/src/short-urls/helpers/ExportShortUrlsBtn.tsx @@ -0,0 +1,66 @@ +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 = 10; + +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 = () => { + 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(); + loadAllUrls() + .then((shortUrls) => { + exportShortUrls(shortUrls.map((shortUrl) => ({ + createdAt: shortUrl.dateCreated, + shortUrl: shortUrl.shortUrl, + longUrl: shortUrl.longUrl, + title: shortUrl.title ?? '', + tags: shortUrl.tags.join(','), + visits: shortUrl.visitsCount, + }))); + stopLoading(); + }) + .catch((e) => { + // TODO Handle error properly + console.error('An error occurred while exporting short URLs', e); // eslint-disable-line no-console + }); + }; + + return ; +}; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 425a04a8..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,7 +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.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 index f4f83cd8..2a0a78c9 100644 --- a/src/utils/ExportBtn.tsx +++ b/src/utils/ExportBtn.tsx @@ -1,17 +1,16 @@ import { FC } from 'react'; -import { Button } from 'reactstrap'; +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 { - onClick: () => void; +interface ExportBtnProps extends Omit { amount?: number; - className?: string; + loading?: boolean; } -export const ExportBtn: FC = ({ onClick, className, amount = 0 }) => ( - ); 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/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 84% rename from test/visits/services/VisitsExporter.test.ts rename to test/common/services/ReportExporter.test.ts index 40a34600..f8848bc3 100644 --- a/test/visits/services/VisitsExporter.test.ts +++ b/test/common/services/ReportExporter.test.ts @@ -1,20 +1,20 @@ 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'; -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 +35,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', () => { diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index 3d479c15..4e0dbbe6 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -20,7 +20,8 @@ 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(); @@ -48,6 +49,7 @@ describe('', () => { 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([ diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 0184629e..dae43287 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -33,6 +33,7 @@ describe('', () => { tags: [ 'test tag' ], }), ], + pagination: {}, }, }); const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, ShortUrlsFilteringBar); diff --git a/test/utils/ExportBtn.test.tsx b/test/utils/ExportBtn.test.tsx index 7ee7b705..97d27eee 100644 --- a/test/utils/ExportBtn.test.tsx +++ b/test/utils/ExportBtn.test.tsx @@ -5,9 +5,8 @@ import { ExportBtn } from '../../src/utils/ExportBtn'; describe('', () => { let wrapper: ShallowWrapper; - const onClick = jest.fn(); - const createWrapper = (className?: string, amount?: number) => { - wrapper = shallow(); + const createWrapper = (amount?: number, loading = false) => { + wrapper = shallow(); return wrapper; }; @@ -16,16 +15,15 @@ describe('', () => { afterEach(() => wrapper?.unmount()); it.each([ - [ undefined ], - [ 'foo' ], - [ 'bar' ], - ])('renders a button', (className) => { - const wrapper = createWrapper(className); + [ 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('onClick')).toEqual(onClick); - expect(wrapper.prop('className')).toEqual(className); + expect(wrapper.prop('disabled')).toEqual(loading); + expect(wrapper.html()).toContain(text); }); it.each([ @@ -34,7 +32,7 @@ describe('', () => { [ 10_000, '10,000' ], [ 10_000_000, '10,000,000' ], ])('renders expected amount', (amount, expectedRenderedAmount) => { - const wrapper = createWrapper(undefined, amount); + const wrapper = createWrapper(amount); expect(wrapper.html()).toContain(`Export (${expectedRenderedAmount})`); }); @@ -46,12 +44,4 @@ describe('', () => { expect(icon).toHaveLength(1); expect(icon.prop('icon')).toEqual(faFileDownload); }); - - it('invokes callback onClick', () => { - const wrapper = createWrapper(); - - expect(onClick).not.toHaveBeenCalled(); - wrapper.simulate('click'); - expect(onClick).toHaveBeenCalledTimes(1); - }); }); 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( Date: Sun, 13 Mar 2022 19:07:33 +0100 Subject: [PATCH 7/9] Enhanced ReportExporter test --- src/utils/SearchField.scss | 2 +- src/utils/SearchField.tsx | 6 ++--- test/common/services/ReportExporter.test.ts | 26 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) 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/test/common/services/ReportExporter.test.ts b/test/common/services/ReportExporter.test.ts index f8848bc3..300a2168 100644 --- a/test/common/services/ReportExporter.test.ts +++ b/test/common/services/ReportExporter.test.ts @@ -3,6 +3,7 @@ import { CsvJson } from 'csvjson'; 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('ReportExporter', () => { const toCSV = jest.fn(); @@ -44,4 +45,29 @@ describe('ReportExporter', () => { 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(); + }); + }); }); From ea7345b8727da3fd0c0c12e2e4b6fd8cdeff8c07 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Mar 2022 19:09:06 +0100 Subject: [PATCH 8/9] Updated changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 0a57390c4644ec9d38ff2eb0be86302f6e1a2df0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 17 Mar 2022 20:28:47 +0100 Subject: [PATCH 9/9] Created ExportShortUrlsBtn test --- src/short-urls/helpers/ExportShortUrlsBtn.tsx | 31 ++++---- .../helpers/ExportShortUrlsBtn.test.tsx | 70 +++++++++++++++++++ 2 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 test/short-urls/helpers/ExportShortUrlsBtn.test.tsx diff --git a/src/short-urls/helpers/ExportShortUrlsBtn.tsx b/src/short-urls/helpers/ExportShortUrlsBtn.tsx index 03b79584..be5490c2 100644 --- a/src/short-urls/helpers/ExportShortUrlsBtn.tsx +++ b/src/short-urls/helpers/ExportShortUrlsBtn.tsx @@ -15,7 +15,7 @@ interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps { selectedServer: SelectedServer; } -const itemsPerPage = 10; +const itemsPerPage = 20; export const ExportShortUrlsBtn = ( buildShlinkApiClient: ShlinkApiClientBuilder, @@ -23,7 +23,7 @@ export const ExportShortUrlsBtn = ( ): FC => ({ amount = 0, selectedServer }) => { const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery(); const [ loading,, startLoading, stopLoading ] = useToggle(); - const exportAllUrls = () => { + const exportAllUrls = async () => { if (!isServerWithId(selectedServer)) { return; } @@ -44,22 +44,17 @@ export const ExportShortUrlsBtn = ( }; startLoading(); - loadAllUrls() - .then((shortUrls) => { - exportShortUrls(shortUrls.map((shortUrl) => ({ - createdAt: shortUrl.dateCreated, - shortUrl: shortUrl.shortUrl, - longUrl: shortUrl.longUrl, - title: shortUrl.title ?? '', - tags: shortUrl.tags.join(','), - visits: shortUrl.visitsCount, - }))); - stopLoading(); - }) - .catch((e) => { - // TODO Handle error properly - console.error('An error occurred while exporting short URLs', e); // eslint-disable-line no-console - }); + 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/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); + }); +});