From e0d43020dc0f9456e3897aaa8b9c34978098bb57 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 10:04:34 +0100 Subject: [PATCH 1/8] Extracted cards in overview to their own component --- src/common/MenuLayout.tsx | 4 +- src/servers/Overview.tsx | 61 +++++++++---------- .../HighlightCard.scss} | 6 +- src/servers/helpers/HighlightCard.tsx | 16 +++++ src/utils/helpers/features.ts | 2 + test/servers/helpers/HighlightCard.test.tsx | 59 ++++++++++++++++++ 6 files changed, 113 insertions(+), 35 deletions(-) rename src/servers/{Overview.scss => helpers/HighlightCard.scss} (66%) create mode 100644 src/servers/helpers/HighlightCard.tsx create mode 100644 test/servers/helpers/HighlightCard.test.tsx diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index b8205b2f..15c372d5 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { useSwipeable, useToggle } from '../utils/helpers/hooks'; -import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features'; +import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features'; import { isReachableServer } from '../servers/data'; import NotFound from './NotFound'; import { AsideMenuProps } from './AsideMenu'; @@ -33,6 +33,7 @@ const MenuLayout = ( } const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer); + const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); const addManageDomainsRoute = supportsDomainRedirects(selectedServer); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const swipeableProps = useSwipeable(showSidebar, hideSidebar); @@ -55,6 +56,7 @@ const MenuLayout = ( {addOrphanVisitsRoute && } + {addNonOrphanVisitsRoute && 'Non orphan'} />} {addManageDomainsRoute && } { @@ -52,40 +55,36 @@ export const Overview = ( return ( <> -
- - Visits - {loadingVisits ? 'Loading...' : prettify(visitsCount)} - +
+ + {loadingVisits ? 'Loading...' : prettify(visitsCount)} +
-
- - Orphan visits - - - {loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)} - - - Shlink 2.6 is needed - - - +
+ + + {loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)} + + + Shlink 2.6 is needed + +
-
- - Short URLs - - {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)} - - +
+ + {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)} +
-
- - Tags - {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)} - +
+ + {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)} +
+ Create a short URL diff --git a/src/servers/Overview.scss b/src/servers/helpers/HighlightCard.scss similarity index 66% rename from src/servers/Overview.scss rename to src/servers/helpers/HighlightCard.scss index 9f939a2f..ddb11b6b 100644 --- a/src/servers/Overview.scss +++ b/src/servers/helpers/HighlightCard.scss @@ -1,13 +1,13 @@ -@import '../utils/base'; +@import '../../utils/base'; -.overview__card.overview__card { +.highlight-card.highlight-card { text-align: center; border-top: 3px solid var(--brand-color); color: inherit; text-decoration: none; } -.overview__card-title { +.highlight-card__title { text-transform: uppercase; color: $textPlaceholder; } diff --git a/src/servers/helpers/HighlightCard.tsx b/src/servers/helpers/HighlightCard.tsx new file mode 100644 index 00000000..85988a7f --- /dev/null +++ b/src/servers/helpers/HighlightCard.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import { Card, CardText, CardTitle } from 'reactstrap'; +import { Link } from 'react-router-dom'; +import './HighlightCard.scss'; + +export interface HighlightCardProps { + title: string; + link?: string; +} + +export const HighlightCard: FC = ({ children, title, link }) => ( + + {title} + {children} + +); diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 8706a0eb..151fa409 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -25,3 +25,5 @@ export const supportsDomainRedirects = supportsQrErrorCorrection; export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' }); export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); + +export const supportsNonOrphanVisits = serverMatchesVersions({ minVersion: '3.0.0' }); diff --git a/test/servers/helpers/HighlightCard.test.tsx b/test/servers/helpers/HighlightCard.test.tsx new file mode 100644 index 00000000..b166c1de --- /dev/null +++ b/test/servers/helpers/HighlightCard.test.tsx @@ -0,0 +1,59 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { ReactNode } from 'react'; +import { Card, CardText, CardTitle } from 'reactstrap'; +import { Link } from 'react-router-dom'; +import { HighlightCard, HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; + +describe('', () => { + let wrapper: ShallowWrapper; + const createWrapper = (props: HighlightCardProps & { children?: ReactNode }) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + it('renders expected components', () => { + const wrapper = createWrapper({ title: 'foo' }); + + expect(wrapper.find(Card)).toHaveLength(1); + expect(wrapper.find(CardTitle)).toHaveLength(1); + expect(wrapper.find(CardText)).toHaveLength(1); + expect(wrapper.prop('tag')).not.toEqual(Link); + expect(wrapper.prop('to')).not.toBeDefined(); + }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'baz' ], + ])('renders provided title', (title) => { + const wrapper = createWrapper({ title }); + const cardTitle = wrapper.find(CardTitle); + + expect(cardTitle.html()).toContain(`>${title}<`); + }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'baz' ], + ])('renders provided children', (children) => { + const wrapper = createWrapper({ title: 'foo', children }); + const cardText = wrapper.find(CardText); + + expect(cardText.html()).toContain(`>${children}<`); + }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'baz' ], + ])('adds extra props when a link is provided', (link) => { + const wrapper = createWrapper({ title: 'foo', link }); + + expect(wrapper.prop('tag')).toEqual(Link); + expect(wrapper.prop('to')).toEqual(link); + }); +}); From 60929342fb27478053b21798635142a0f01bf3b4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 10:46:46 +0100 Subject: [PATCH 2/8] Added some feedback to know which cardsin overview pages are clickable --- src/servers/helpers/HighlightCard.scss | 7 +++++++ src/servers/helpers/HighlightCard.tsx | 7 ++++++- test/servers/helpers/HighlightCard.test.tsx | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/servers/helpers/HighlightCard.scss b/src/servers/helpers/HighlightCard.scss index ddb11b6b..beb6fe5b 100644 --- a/src/servers/helpers/HighlightCard.scss +++ b/src/servers/helpers/HighlightCard.scss @@ -7,6 +7,13 @@ text-decoration: none; } +.highlight-card__link-icon { + position: absolute; + right: 5px; + bottom: 5px; + opacity: 0.1; +} + .highlight-card__title { text-transform: uppercase; color: $textPlaceholder; diff --git a/src/servers/helpers/HighlightCard.tsx b/src/servers/helpers/HighlightCard.tsx index 85988a7f..e46782a7 100644 --- a/src/servers/helpers/HighlightCard.tsx +++ b/src/servers/helpers/HighlightCard.tsx @@ -1,6 +1,8 @@ import { FC } from 'react'; import { Card, CardText, CardTitle } from 'reactstrap'; import { Link } from 'react-router-dom'; +import { faLink as linkIcon } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import './HighlightCard.scss'; export interface HighlightCardProps { @@ -8,8 +10,11 @@ export interface HighlightCardProps { link?: string; } +const buildExtraProps = (link?: string) => !link ? {} : { tag: Link, to: link }; + export const HighlightCard: FC = ({ children, title, link }) => ( - + + {link && } {title} {children} diff --git a/test/servers/helpers/HighlightCard.test.tsx b/test/servers/helpers/HighlightCard.test.tsx index b166c1de..94426bb2 100644 --- a/test/servers/helpers/HighlightCard.test.tsx +++ b/test/servers/helpers/HighlightCard.test.tsx @@ -2,6 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { ReactNode } from 'react'; import { Card, CardText, CardTitle } from 'reactstrap'; import { Link } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { HighlightCard, HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; describe('', () => { @@ -20,6 +21,7 @@ describe('', () => { expect(wrapper.find(Card)).toHaveLength(1); expect(wrapper.find(CardTitle)).toHaveLength(1); expect(wrapper.find(CardText)).toHaveLength(1); + expect(wrapper.find(FontAwesomeIcon)).toHaveLength(0); expect(wrapper.prop('tag')).not.toEqual(Link); expect(wrapper.prop('to')).not.toBeDefined(); }); @@ -53,6 +55,7 @@ describe('', () => { ])('adds extra props when a link is provided', (link) => { const wrapper = createWrapper({ title: 'foo', link }); + expect(wrapper.find(FontAwesomeIcon)).toHaveLength(1); expect(wrapper.prop('tag')).toEqual(Link); expect(wrapper.prop('to')).toEqual(link); }); From 8fbe6bb17d4b086bd561e25e675184917ff59ac5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 13:37:49 +0100 Subject: [PATCH 3/8] Added changes to load orphan visits and fixed tests --- src/api/services/ShlinkApiClient.ts | 4 ++ src/common/MenuLayout.tsx | 3 +- src/container/types.ts | 1 + src/reducers/index.ts | 2 + src/servers/Overview.tsx | 7 +-- src/servers/helpers/HighlightCard.tsx | 4 +- test/api/services/ShlinkApiClient.test.ts | 14 ++++++ test/common/MenuLayout.test.tsx | 5 ++- test/servers/Overview.test.tsx | 50 +++++++++++---------- test/servers/helpers/HighlightCard.test.tsx | 7 ++- 10 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 4cada88b..6efea6d8 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -60,6 +60,10 @@ export default class ShlinkApiClient { this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) .then(({ data }) => data.visits); + public readonly getNonOrphanVisits = async (query?: Omit): Promise => + this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query) + .then(({ data }) => data.visits); + public readonly getVisitsOverview = async (): Promise => this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET') .then(({ data }) => data.visits); diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 15c372d5..2c1c146e 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -19,6 +19,7 @@ const MenuLayout = ( ShortUrlVisits: FC, TagVisits: FC, OrphanVisits: FC, + NonOrphanVisits: FC, ServerError: FC, Overview: FC, EditShortUrl: FC, @@ -56,7 +57,7 @@ const MenuLayout = ( {addOrphanVisitsRoute && } - {addNonOrphanVisitsRoute && 'Non orphan'} />} + {addNonOrphanVisitsRoute && } {addManageDomainsRoute && } ({ shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, orphanVisits: orphanVisitsReducer, + nonOrphanVisits: nonOrphanVisitsReducer, shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index ebb45e27..0605fc40 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -56,15 +56,12 @@ export const Overview = ( <>
- + {loadingVisits ? 'Loading...' : prettify(visitsCount)}
- + {loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)} diff --git a/src/servers/helpers/HighlightCard.tsx b/src/servers/helpers/HighlightCard.tsx index e46782a7..bf0fc408 100644 --- a/src/servers/helpers/HighlightCard.tsx +++ b/src/servers/helpers/HighlightCard.tsx @@ -7,10 +7,10 @@ import './HighlightCard.scss'; export interface HighlightCardProps { title: string; - link?: string; + link?: string | false; } -const buildExtraProps = (link?: string) => !link ? {} : { tag: Link, to: link }; +const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link }; export const HighlightCard: FC = ({ children, title, link }) => ( diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 9ce5af41..b25a3837 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -313,6 +313,20 @@ describe('ShlinkApiClient', () => { }); }); + describe('getNonOrphanVisits', () => { + it('returns non-orphan visits', async () => { + const expectedData: Visit[] = []; + const resp = { visits: expectedData }; + const axiosSpy = createAxiosMock({ data: resp }); + const { getNonOrphanVisits } = new ShlinkApiClient(axiosSpy, '', ''); + + const result = await getNonOrphanVisits(); + + expect(axiosSpy).toHaveBeenCalled(); + expect(result).toEqual(expectedData); + }); + }); + describe('editDomainRedirects', () => { it('returns the redirects', async () => { const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' }; diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx index 4ac16296..ba5ad007 100644 --- a/test/common/MenuLayout.test.tsx +++ b/test/common/MenuLayout.test.tsx @@ -11,7 +11,7 @@ import { SemVer } from '../../src/utils/helpers/version'; describe('', () => { const ServerError = jest.fn(); const C = jest.fn(); - const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C, C); + const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, C, ServerError, C, C, C); let wrapper: ShallowWrapper; const createWrapper = (selectedServer: SelectedServer) => { wrapper = shallow( @@ -52,6 +52,9 @@ describe('', () => { [ '2.5.0' as SemVer, 8 ], [ '2.6.0' as SemVer, 9 ], [ '2.7.0' as SemVer, 9 ], + [ '2.8.0' as SemVer, 10 ], + [ '2.10.0' as SemVer, 10 ], + [ '3.0.0' as SemVer, 11 ], ])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => { const selectedServer = Mock.of({ version }); const wrapper = createWrapper(selectedServer).dive(); diff --git a/test/servers/Overview.test.tsx b/test/servers/Overview.test.tsx index 14cc85b1..1e8f9322 100644 --- a/test/servers/Overview.test.tsx +++ b/test/servers/Overview.test.tsx @@ -1,8 +1,7 @@ import { FC } from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { mount, ReactWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; -import { CardText } from 'reactstrap'; -import { Link } from 'react-router-dom'; +import { Link, MemoryRouter } from 'react-router-dom'; import { ShortUrlsList as ShortUrlsListState } from '../../src/short-urls/reducers/shortUrlsList'; import { Overview as overviewCreator } from '../../src/servers/Overview'; import { TagsList } from '../../src/tags/reducers/tagsList'; @@ -10,9 +9,10 @@ import { VisitsOverview } from '../../src/visits/reducers/visitsOverview'; import { MercureInfo } from '../../src/mercure/reducers/mercureInfo'; import { ReachableServer } from '../../src/servers/data'; import { prettify } from '../../src/utils/helpers/numbers'; +import { HighlightCard } from '../../src/servers/helpers/HighlightCard'; describe('', () => { - let wrapper: ShallowWrapper; + let wrapper: ReactWrapper; const ShortUrlsTable = () => null; const CreateShortUrl = () => null; const ForServerVersion: FC = ({ children }) => <>{children}; @@ -25,20 +25,22 @@ describe('', () => { }; const serverId = '123'; const createWrapper = (loading = false) => { - wrapper = shallow( - ({ loading, shortUrls })} - tagsList={Mock.of({ loading, tags: [ 'foo', 'bar', 'baz' ] })} - visitsOverview={Mock.of({ loading, visitsCount: 3456, orphanVisitsCount: 28 })} - selectedServer={Mock.of({ id: serverId })} - createNewVisits={jest.fn()} - loadMercureInfo={jest.fn()} - mercureInfo={Mock.all()} - />, - ).dive(); // Dive is needed as this component is wrapped in a HOC + wrapper = mount( + + ({ loading, shortUrls })} + tagsList={Mock.of({ loading, tags: [ 'foo', 'bar', 'baz' ] })} + visitsOverview={Mock.of({ loading, visitsCount: 3456, orphanVisitsCount: 28 })} + selectedServer={Mock.of({ id: serverId })} + createNewVisits={jest.fn()} + loadMercureInfo={jest.fn()} + mercureInfo={Mock.all()} + /> + , + ); return wrapper; }; @@ -47,7 +49,7 @@ describe('', () => { it('displays loading messages when still loading', () => { const wrapper = createWrapper(true); - const cards = wrapper.find(CardText); + const cards = wrapper.find(HighlightCard); expect(cards).toHaveLength(4); cards.forEach((card) => expect(card.html()).toContain('Loading...')); @@ -55,7 +57,7 @@ describe('', () => { it('displays amounts in cards after finishing loading', () => { const wrapper = createWrapper(); - const cards = wrapper.find(CardText); + const cards = wrapper.find(HighlightCard); expect(cards).toHaveLength(4); expect(cards.at(0).html()).toContain(prettify(3456)); @@ -75,8 +77,10 @@ describe('', () => { const wrapper = createWrapper(); const links = wrapper.find(Link); - expect(links).toHaveLength(2); - expect(links.at(0).prop('to')).toEqual(`/server/${serverId}/create-short-url`); - expect(links.at(1).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`); + expect(links).toHaveLength(4); + expect(links.at(0).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`); + expect(links.at(1).prop('to')).toEqual(`/server/${serverId}/manage-tags`); + expect(links.at(2).prop('to')).toEqual(`/server/${serverId}/create-short-url`); + expect(links.at(3).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`); }); }); diff --git a/test/servers/helpers/HighlightCard.test.tsx b/test/servers/helpers/HighlightCard.test.tsx index 94426bb2..e365f31e 100644 --- a/test/servers/helpers/HighlightCard.test.tsx +++ b/test/servers/helpers/HighlightCard.test.tsx @@ -15,8 +15,11 @@ describe('', () => { afterEach(() => wrapper?.unmount()); - it('renders expected components', () => { - const wrapper = createWrapper({ title: 'foo' }); + it.each([ + [ undefined ], + [ false ], + ])('renders expected components', (link) => { + const wrapper = createWrapper({ title: 'foo', link: link as undefined | false }); expect(wrapper.find(Card)).toHaveLength(1); expect(wrapper.find(CardTitle)).toHaveLength(1); From 0608d3cf192cbfd99ac5d7f8e15f0ee76e36a1ba Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 13:46:24 +0100 Subject: [PATCH 4/8] Improved icon in HighlightedCard --- src/servers/helpers/HighlightCard.scss | 1 + src/servers/helpers/HighlightCard.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/servers/helpers/HighlightCard.scss b/src/servers/helpers/HighlightCard.scss index beb6fe5b..ecfda6ed 100644 --- a/src/servers/helpers/HighlightCard.scss +++ b/src/servers/helpers/HighlightCard.scss @@ -12,6 +12,7 @@ right: 5px; bottom: 5px; opacity: 0.1; + transform: rotate(-45deg); } .highlight-card__title { diff --git a/src/servers/helpers/HighlightCard.tsx b/src/servers/helpers/HighlightCard.tsx index bf0fc408..d274f4c5 100644 --- a/src/servers/helpers/HighlightCard.tsx +++ b/src/servers/helpers/HighlightCard.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { Card, CardText, CardTitle } from 'reactstrap'; import { Link } from 'react-router-dom'; -import { faLink as linkIcon } from '@fortawesome/free-solid-svg-icons'; +import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import './HighlightCard.scss'; From 4a80f224d885bca78bdc88e09712a9a83e0e024c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 13:53:07 +0100 Subject: [PATCH 5/8] Created components and reducer to handle non-orphan visits --- src/common/services/provideServices.ts | 1 + src/visits/NonOrphanVisits.tsx | 44 +++++++++++++ src/visits/NonOrphanVisitsHeader.tsx | 14 ++++ src/visits/reducers/nonOrphanVisits.ts | 88 ++++++++++++++++++++++++++ src/visits/reducers/orphanVisits.ts | 2 +- src/visits/services/provideServices.ts | 17 ++++- 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/visits/NonOrphanVisits.tsx create mode 100644 src/visits/NonOrphanVisitsHeader.tsx create mode 100644 src/visits/reducers/nonOrphanVisits.ts diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index 9d51ccdb..ab1f58b1 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -41,6 +41,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: 'ShortUrlVisits', 'TagVisits', 'OrphanVisits', + 'NonOrphanVisits', 'ServerError', 'Overview', 'EditShortUrl', diff --git a/src/visits/NonOrphanVisits.tsx b/src/visits/NonOrphanVisits.tsx new file mode 100644 index 00000000..947111ff --- /dev/null +++ b/src/visits/NonOrphanVisits.tsx @@ -0,0 +1,44 @@ +import { RouteComponentProps } from 'react-router'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; +import { ShlinkVisitsParams } from '../api/types'; +import { Topics } from '../mercure/helpers/Topics'; +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'; + +export interface NonOrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { + getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + nonOrphanVisits: VisitsInfo; + cancelGetNonOrphanVisits: () => void; +} + +export const NonOrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(({ + history: { goBack }, + match: { url }, + getNonOrphanVisits, + nonOrphanVisits, + cancelGetNonOrphanVisits, + settings, + selectedServer, +}: NonOrphanVisitsProps) => { + const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits); + const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => + getNonOrphanVisits(toApiParams(params), doIntervalFallback); + + return ( + + + + ); +}, () => [ Topics.visits ]); diff --git a/src/visits/NonOrphanVisitsHeader.tsx b/src/visits/NonOrphanVisitsHeader.tsx new file mode 100644 index 00000000..a361defe --- /dev/null +++ b/src/visits/NonOrphanVisitsHeader.tsx @@ -0,0 +1,14 @@ +import VisitsHeader from './VisitsHeader'; +import { VisitsInfo } from './types'; +import './ShortUrlVisitsHeader.scss'; + +interface NonOrphanVisitsHeaderProps { + nonOrphanVisits: VisitsInfo; + goBack: () => void; +} + +export const NonOrphanVisitsHeader = ({ nonOrphanVisits, goBack }: NonOrphanVisitsHeaderProps) => { + const { visits } = nonOrphanVisits; + + return ; +}; diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts new file mode 100644 index 00000000..2ce9fa9d --- /dev/null +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -0,0 +1,88 @@ +import { Action, Dispatch } from 'redux'; +import { + Visit, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoadProgressChangedAction, +} from '../types'; +import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import { GetState } from '../../container/types'; +import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; +import { isBetween } from '../../utils/helpers/date'; +import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; +import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; + +/* eslint-disable padding-line-between-statements */ +export const GET_NON_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_START'; +export const GET_NON_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_ERROR'; +export const GET_NON_ORPHAN_VISITS = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS'; +export const GET_NON_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_LARGE'; +export const GET_NON_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_CANCEL'; +export const GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED'; +export const GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL'; +/* eslint-enable padding-line-between-statements */ + +export interface NonOrphanVisitsAction extends Action { + visits: Visit[]; + query?: ShlinkVisitsParams; +} + +type NonOrphanVisitsCombinedAction = NonOrphanVisitsAction +& VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction +& CreateVisitsAction +& ApiErrorAction; + +const initialState: VisitsInfo = { + visits: [], + loading: false, + loadingLarge: false, + error: false, + cancelLoad: false, + progress: 0, +}; + +export default buildReducer({ + [GET_NON_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_NON_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [GET_NON_ORPHAN_VISITS]: (state, { visits, query }) => ({ ...state, visits, query, loading: false, error: false }), + [GET_NON_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), + [GET_NON_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [CREATE_VISITS]: (state, { createdVisits }) => { + const { visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = createdVisits + .filter(({ visit }) => isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [ ...newVisits, ...visits ] }; + }, +}, initialState); + +export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + query: ShlinkVisitsParams = {}, + doIntervalFallback = false, +) => async (dispatch: Dispatch, getState: GetState) => { + const { getNonOrphanVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => + getNonOrphanVisits({ ...query, page, itemsPerPage }); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getNonOrphanVisits); + const shouldCancel = () => getState().orphanVisits.cancelLoad; + const extraFinishActionData: Partial = { query }; + const actionMap = { + start: GET_NON_ORPHAN_VISITS_START, + large: GET_NON_ORPHAN_VISITS_LARGE, + finish: GET_NON_ORPHAN_VISITS, + error: GET_NON_ORPHAN_VISITS_ERROR, + progress: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, + fallbackToInterval: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, + }; + + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); +}; + +export const cancelGetNonOrphanVisits = buildActionCreator(GET_NON_ORPHAN_VISITS_CANCEL); diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index bcb06d8a..03a93cf6 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -59,7 +59,7 @@ export default buildReducer({ const { visits, query = {} } = state; const { startDate, endDate } = query; const newVisits = createdVisits - .filter(({ visit }) => isBetween(visit.date, startDate, endDate)) + .filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)) .map(({ visit }) => visit); return { ...state, visits: [ ...newVisits, ...visits ] }; diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 9eb3f5eb..626d1ee1 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -1,12 +1,14 @@ import Bottle from 'bottlejs'; -import ShortUrlVisits from '../ShortUrlVisits'; -import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import MapModal from '../helpers/MapModal'; import { createNewVisits } from '../reducers/visitCreation'; +import ShortUrlVisits from '../ShortUrlVisits'; import TagVisits from '../TagVisits'; -import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { OrphanVisits } from '../OrphanVisits'; +import { NonOrphanVisits } from '../NonOrphanVisits'; +import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; +import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; +import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; import { loadVisitsOverview } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; @@ -34,6 +36,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], )); + bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'VisitsExporter'); + bottle.decorator('NonOrphanVisits', connect( + [ 'nonOrphanVisits', 'mercureInfo', 'settings', 'selectedServer' ], + [ 'getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], + )); + // Services bottle.serviceFactory('VisitsParser', () => visitsParser); bottle.service('VisitsExporter', VisitsExporter, 'window', 'csvjson'); @@ -48,6 +56,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); + bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('cancelGetNonOrphanVisits', () => cancelGetNonOrphanVisits); + bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); }; From 9bc5a050eb876e7eb8f20cff573486e1fce803ec Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 13:53:54 +0100 Subject: [PATCH 6/8] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3e9a2b..9eff9ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ 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. ### Changed * [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs. From 5edb62e76b5eb35764508d5f6be9567c39612206 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 16:37:01 +0100 Subject: [PATCH 7/8] Created tests for non-orphan visits components --- test/visits/NonOrphanVisits.test.tsx | 47 ++++++++++++++++++++++ test/visits/NonOrphanVisitsHeader.test.tsx | 21 ++++++++++ test/visits/OrphanVisits.test.tsx | 3 +- test/visits/ShortUrlVisits.test.tsx | 1 + test/visits/TagVisits.test.tsx | 1 + 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 test/visits/NonOrphanVisits.test.tsx create mode 100644 test/visits/NonOrphanVisitsHeader.test.tsx diff --git a/test/visits/NonOrphanVisits.test.tsx b/test/visits/NonOrphanVisits.test.tsx new file mode 100644 index 00000000..813d4e89 --- /dev/null +++ b/test/visits/NonOrphanVisits.test.tsx @@ -0,0 +1,47 @@ +import { shallow } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { History, Location } from 'history'; +import { match } from 'react-router'; +import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOrphanVisits'; +import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; +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 { SelectedServer } from '../../src/servers/data'; + +describe('', () => { + it('wraps visits stats and header', () => { + const goBack = jest.fn(); + const getNonOrphanVisits = jest.fn(); + const cancelGetNonOrphanVisits = jest.fn(); + const nonOrphanVisits = Mock.all(); + const NonOrphanVisits = createNonOrphanVisits(Mock.all()); + + const wrapper = shallow( + ({ mercureInfo: {} })} + getNonOrphanVisits={getNonOrphanVisits} + nonOrphanVisits={nonOrphanVisits} + cancelGetNonOrphanVisits={cancelGetNonOrphanVisits} + history={Mock.of({ goBack })} + location={Mock.all()} + match={Mock.of({ url: 'the_base_url' })} + settings={Mock.all()} + selectedServer={Mock.all()} + />, + ).dive(); + const stats = wrapper.find(VisitsStats); + const header = wrapper.find(NonOrphanVisitsHeader); + + expect(stats).toHaveLength(1); + expect(header).toHaveLength(1); + expect(stats.prop('cancelGetVisits')).toEqual(cancelGetNonOrphanVisits); + expect(stats.prop('visitsInfo')).toEqual(nonOrphanVisits); + expect(stats.prop('baseUrl')).toEqual('the_base_url'); + expect(stats.prop('isOrphanVisits')).not.toBeDefined(); + expect(header.prop('nonOrphanVisits')).toEqual(nonOrphanVisits); + expect(header.prop('goBack')).toEqual(goBack); + }); +}); diff --git a/test/visits/NonOrphanVisitsHeader.test.tsx b/test/visits/NonOrphanVisitsHeader.test.tsx new file mode 100644 index 00000000..ea0f1fdc --- /dev/null +++ b/test/visits/NonOrphanVisitsHeader.test.tsx @@ -0,0 +1,21 @@ +import { shallow } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { NonOrphanVisitsHeader } from '../../src/visits/NonOrphanVisitsHeader'; +import VisitsHeader from '../../src/visits/VisitsHeader'; +import { Visit, VisitsInfo } from '../../src/visits/types'; + +describe('', () => { + it('wraps a VisitsHeader with provided data', () => { + const visits: Visit[] = []; + const orphanVisits = Mock.of({ visits }); + const goBack = jest.fn(); + + const wrapper = shallow(); + const visitsHeader = wrapper.find(VisitsHeader); + + expect(visitsHeader).toHaveLength(1); + expect(visitsHeader.prop('visits')).toEqual(visits); + expect(visitsHeader.prop('goBack')).toEqual(goBack); + expect(visitsHeader.prop('title')).toEqual('Non-orphan visits'); + }); +}); diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx index 99fef93e..e1f6dc78 100644 --- a/test/visits/OrphanVisits.test.tsx +++ b/test/visits/OrphanVisits.test.tsx @@ -1,7 +1,7 @@ import { shallow } from 'enzyme'; import { Mock } from 'ts-mockery'; import { History, Location } from 'history'; -import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars +import { match } from 'react-router'; import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisits'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { VisitsInfo } from '../../src/visits/types'; @@ -40,6 +40,7 @@ describe('', () => { expect(stats.prop('cancelGetVisits')).toEqual(cancelGetOrphanVisits); expect(stats.prop('visitsInfo')).toEqual(orphanVisits); expect(stats.prop('baseUrl')).toEqual('the_base_url'); + expect(stats.prop('isOrphanVisits')).toEqual(true); expect(header.prop('orphanVisits')).toEqual(orphanVisits); expect(header.prop('goBack')).toEqual(goBack); }); diff --git a/test/visits/ShortUrlVisits.test.tsx b/test/visits/ShortUrlVisits.test.tsx index a3d2db5b..6a85a838 100644 --- a/test/visits/ShortUrlVisits.test.tsx +++ b/test/visits/ShortUrlVisits.test.tsx @@ -48,6 +48,7 @@ describe('', () => { const visitHeader = wrapper.find(ShortUrlVisitsHeader); expect(visitStats).toHaveLength(1); + expect(visitStats.prop('isOrphanVisits')).not.toBeDefined(); expect(visitHeader).toHaveLength(1); }); }); diff --git a/test/visits/TagVisits.test.tsx b/test/visits/TagVisits.test.tsx index ee1f501a..ca00f9e4 100644 --- a/test/visits/TagVisits.test.tsx +++ b/test/visits/TagVisits.test.tsx @@ -44,6 +44,7 @@ describe('', () => { const visitHeader = wrapper.find(TagVisitsHeader); expect(visitStats).toHaveLength(1); + expect(visitStats.prop('isOrphanVisits')).not.toBeDefined(); expect(visitHeader).toHaveLength(1); }); }); From 0eec9b185f2ed2b0666cda28b4ba7fbaacb423cb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Feb 2022 16:40:48 +0100 Subject: [PATCH 8/8] Added test for non-orphan visits reducer --- test/visits/reducers/nonOrphanVisits.test.ts | 213 +++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 test/visits/reducers/nonOrphanVisits.test.ts diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts new file mode 100644 index 00000000..53134156 --- /dev/null +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -0,0 +1,213 @@ +import { Mock } from 'ts-mockery'; +import { addDays, formatISO, subDays } from 'date-fns'; +import reducer, { + getNonOrphanVisits, + cancelGetNonOrphanVisits, + GET_NON_ORPHAN_VISITS_START, + GET_NON_ORPHAN_VISITS_ERROR, + GET_NON_ORPHAN_VISITS, + GET_NON_ORPHAN_VISITS_LARGE, + GET_NON_ORPHAN_VISITS_CANCEL, + GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, + GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, +} from '../../../src/visits/reducers/nonOrphanVisits'; +import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; +import { rangeOf } from '../../../src/utils/utils'; +import { Visit, VisitsInfo } from '../../../src/visits/types'; +import { ShlinkVisits } from '../../../src/api/types'; +import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; +import { ShlinkState } from '../../../src/container/types'; +import { formatIsoDate } from '../../../src/utils/helpers/date'; +import { DateInterval } from '../../../src/utils/dates/types'; + +describe('nonOrphanVisitsReducer', () => { + const now = new Date(); + const visitsMocks = rangeOf(2, () => Mock.all()); + + describe('reducer', () => { + const buildState = (data: Partial) => Mock.of(data); + + it('returns loading on GET_NON_ORPHAN_VISITS_START', () => { + const state = reducer(buildState({ loading: false }), { type: GET_NON_ORPHAN_VISITS_START } as any); + const { loading } = state; + + expect(loading).toEqual(true); + }); + + it('returns loadingLarge on GET_NON_ORPHAN_VISITS_LARGE', () => { + const state = reducer(buildState({ loadingLarge: false }), { type: GET_NON_ORPHAN_VISITS_LARGE } as any); + const { loadingLarge } = state; + + expect(loadingLarge).toEqual(true); + }); + + it('returns cancelLoad on GET_NON_ORPHAN_VISITS_CANCEL', () => { + const state = reducer(buildState({ cancelLoad: false }), { type: GET_NON_ORPHAN_VISITS_CANCEL } as any); + const { cancelLoad } = state; + + expect(cancelLoad).toEqual(true); + }); + + it('stops loading and returns error on GET_NON_ORPHAN_VISITS_ERROR', () => { + const state = reducer(buildState({ loading: true, error: false }), { type: GET_NON_ORPHAN_VISITS_ERROR } as any); + const { loading, error } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(true); + }); + + it('return visits on GET_NON_ORPHAN_VISITS', () => { + const actionVisits = [{}, {}]; + const state = reducer( + buildState({ loading: true, error: false }), + { type: GET_NON_ORPHAN_VISITS, visits: actionVisits } as any, + ); + const { loading, error, visits } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(false); + expect(visits).toEqual(actionVisits); + }); + + it.each([ + [{}, visitsMocks.length + 2 ], + [ + Mock.of({ + query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined }, + }), + visitsMocks.length, + ], + [ + Mock.of({ + query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined }, + }), + visitsMocks.length, + ], + [ + Mock.of({ + query: { + startDate: formatIsoDate(subDays(now, 5)) ?? undefined, + endDate: formatIsoDate(subDays(now, 2)) ?? undefined, + }, + }), + visitsMocks.length, + ], + [ + Mock.of({ + query: { + startDate: formatIsoDate(subDays(now, 5)) ?? undefined, + endDate: formatIsoDate(addDays(now, 3)) ?? undefined, + }, + }), + visitsMocks.length + 2, + ], + ])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => { + const prevState = buildState({ ...state, visits: visitsMocks }); + const visit = Mock.of({ date: formatIsoDate(now) ?? undefined }); + + const { visits } = reducer( + prevState, + { type: CREATE_VISITS, createdVisits: [{ visit }, { visit }] } as any, + ); + + expect(visits).toHaveLength(expectedVisits); + }); + + it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => { + const state = reducer(undefined, { type: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + + expect(state).toEqual(expect.objectContaining({ progress: 85 })); + }); + + it('returns fallbackInterval on GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => { + const fallbackInterval: DateInterval = 'last30Days'; + const state = reducer(undefined, { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + + expect(state).toEqual(expect.objectContaining({ fallbackInterval })); + }); + }); + + describe('getNonOrphanVisits', () => { + type GetVisitsReturn = Promise | ((query: any) => Promise); + + const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ + getNonOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), + }); + const dispatchMock = jest.fn(); + const getState = () => Mock.of({ + orphanVisits: { cancelLoad: false }, + }); + + beforeEach(jest.resetAllMocks); + + it('dispatches start and error when promise is rejected', async () => { + const ShlinkApiClient = buildApiClientMock(Promise.reject({})); + + await getNonOrphanVisits(() => ShlinkApiClient)()(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_NON_ORPHAN_VISITS_ERROR }); + expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ undefined ], + [{}], + ])('dispatches start and success when promise is resolved', async (query) => { + const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' })); + const ShlinkApiClient = buildApiClientMock(Promise.resolve({ + data: visits, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + })); + + await getNonOrphanVisits(() => ShlinkApiClient)(query)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_NON_ORPHAN_VISITS, visits, query: query ?? {} }); + expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 5)) }) ], + { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + ], + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 200)) }) ], + { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' }, + ], + [[], expect.objectContaining({ type: GET_NON_ORPHAN_VISITS }) ], + ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ + data, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + }); + const getShlinkOrphanVisits = jest.fn() + .mockResolvedValueOnce(buildVisitsResult()) + .mockResolvedValueOnce(buildVisitsResult(lastVisits)); + const ShlinkApiClient = Mock.of({ getNonOrphanVisits: getShlinkOrphanVisits }); + + await getNonOrphanVisits(() => ShlinkApiClient)({}, true)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); + expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2); + }); + }); + + describe('cancelGetNonOrphanVisits', () => { + it('just returns the action with proper type', () => + expect(cancelGetNonOrphanVisits()).toEqual({ type: GET_NON_ORPHAN_VISITS_CANCEL })); + }); +});