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. 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 b8205b2f..2c1c146e 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'; @@ -19,6 +19,7 @@ const MenuLayout = ( ShortUrlVisits: FC, TagVisits: FC, OrphanVisits: FC, + NonOrphanVisits: FC, ServerError: FC, Overview: FC, EditShortUrl: FC, @@ -33,6 +34,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 +57,7 @@ const MenuLayout = ( {addOrphanVisitsRoute && } + {addNonOrphanVisitsRoute && } {addManageDomainsRoute && } ({ shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, orphanVisits: orphanVisitsReducer, + nonOrphanVisits: nonOrphanVisitsReducer, shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, diff --git a/src/servers/Overview.scss b/src/servers/Overview.scss deleted file mode 100644 index 9f939a2f..00000000 --- a/src/servers/Overview.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import '../utils/base'; - -.overview__card.overview__card { - text-align: center; - border-top: 3px solid var(--brand-color); - color: inherit; - text-decoration: none; -} - -.overview__card-title { - text-transform: uppercase; - color: $textPlaceholder; -} diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 64b9061d..0605fc40 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -1,5 +1,5 @@ import { FC, useEffect } from 'react'; -import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap'; +import { Card, CardBody, CardHeader, Row } from 'reactstrap'; import { Link, useHistory } from 'react-router-dom'; import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import { prettify } from '../utils/helpers/numbers'; @@ -11,8 +11,9 @@ import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { Versions } from '../utils/helpers/version'; import { Topics } from '../mercure/helpers/Topics'; import { ShlinkShortUrlsListParams } from '../api/types'; +import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features'; import { getServerId, SelectedServer } from './data'; -import './Overview.scss'; +import { HighlightCard } from './helpers/HighlightCard'; interface OverviewConnectProps { shortUrlsList: ShortUrlsListState; @@ -41,6 +42,8 @@ export const Overview = ( const { loading: loadingTags } = tagsList; const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview; const serverId = getServerId(selectedServer); + const linkToOrphanVisits = supportsOrphanVisits(selectedServer); + const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer); const history = useHistory(); useEffect(() => { @@ -52,40 +55,33 @@ 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/helpers/HighlightCard.scss b/src/servers/helpers/HighlightCard.scss new file mode 100644 index 00000000..ecfda6ed --- /dev/null +++ b/src/servers/helpers/HighlightCard.scss @@ -0,0 +1,21 @@ +@import '../../utils/base'; + +.highlight-card.highlight-card { + text-align: center; + border-top: 3px solid var(--brand-color); + color: inherit; + text-decoration: none; +} + +.highlight-card__link-icon { + position: absolute; + right: 5px; + bottom: 5px; + opacity: 0.1; + transform: rotate(-45deg); +} + +.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..d274f4c5 --- /dev/null +++ b/src/servers/helpers/HighlightCard.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { Card, CardText, CardTitle } from 'reactstrap'; +import { Link } from 'react-router-dom'; +import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import './HighlightCard.scss'; + +export interface HighlightCardProps { + title: string; + link?: string | false; +} + +const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link }; + +export const HighlightCard: FC = ({ children, title, link }) => ( + + {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/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'); }; 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 new file mode 100644 index 00000000..e365f31e --- /dev/null +++ b/test/servers/helpers/HighlightCard.test.tsx @@ -0,0 +1,65 @@ +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('', () => { + let wrapper: ShallowWrapper; + const createWrapper = (props: HighlightCardProps & { children?: ReactNode }) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + 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); + 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(); + }); + + 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.find(FontAwesomeIcon)).toHaveLength(1); + expect(wrapper.prop('tag')).toEqual(Link); + expect(wrapper.prop('to')).toEqual(link); + }); +}); 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); }); }); 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 })); + }); +});