diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index e4f8a5c2..2d5f9461 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; import qs from 'qs'; import { RouteComponentProps } from 'react-router'; import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers'; @@ -6,16 +6,17 @@ import { ShlinkVisitsParams } from '../utils/services/types'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import { ShortUrlDetail } from './reducers/shortUrlDetail'; +import VisitsStats from './VisitsStats'; export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: string }>, MercureBoundProps { getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: Function; shortUrlDetail: ShortUrlDetail; - cancelGetShortUrlVisits: Function; + cancelGetShortUrlVisits: () => void; } -const ShortUrlVisits = (VisitsStats: FC) => ({ // TODO Use VisitsStatsProps once available +const ShortUrlVisits = ({ history: { goBack }, match, location: { search }, @@ -32,7 +33,7 @@ const ShortUrlVisits = (VisitsStats: FC) => ({ // TODO Use VisitsStatsProps const { shortCode } = params; const { domain } = qs.parse(search, { ignoreQueryPrefix: true }) as { domain?: string }; - const loadVisits = (dates: Partial) => getShortUrlVisits(shortCode, { ...dates, domain }); + const loadVisits = (params: Partial) => getShortUrlVisits(shortCode, { ...params, domain }); useEffect(() => { getShortUrlDetail(shortCode, domain); diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index 2efd5c98..b14c3085 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -1,17 +1,19 @@ -import React, { FC } from 'react'; +import React from 'react'; import { RouteComponentProps } from 'react-router'; import { MercureBoundProps, useMercureTopicBinding } from '../mercure/helpers'; import ColorGenerator from '../utils/services/ColorGenerator'; +import { ShlinkVisitsParams } from '../utils/services/types'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import TagVisitsHeader from './TagVisitsHeader'; +import VisitsStats from './VisitsStats'; export interface TagVisitsProps extends RouteComponentProps<{ tag: string }>, MercureBoundProps { getTagVisits: (tag: string, query: any) => void; tagVisits: TagVisitsState; - cancelGetTagVisits: Function; + cancelGetTagVisits: () => void; } -const TagVisits = (VisitsStats: FC, colorGenerator: ColorGenerator) => ({ // TODO Use VisitsStatsProps once available +const TagVisits = (colorGenerator: ColorGenerator) => ({ history: { goBack }, match, getTagVisits, @@ -23,7 +25,7 @@ const TagVisits = (VisitsStats: FC, colorGenerator: ColorGenerator) => ({ / }: TagVisitsProps) => { const { params } = match; const { tag } = params; - const loadVisits = (dates: any) => getTagVisits(tag, dates); + const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); useMercureTopicBinding(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo); diff --git a/src/visits/VisitsStats.js b/src/visits/VisitsStats.js deleted file mode 100644 index 544c36dc..00000000 --- a/src/visits/VisitsStats.js +++ /dev/null @@ -1,247 +0,0 @@ -import { isEmpty, propEq, values } from 'ramda'; -import React, { useState, useEffect, useMemo } from 'react'; -import { Button, Card, Collapse, Progress } from 'reactstrap'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons'; -import DateRangeRow from '../utils/DateRangeRow'; -import Message from '../utils/Message'; -import { formatDate } from '../utils/helpers/date'; -import { useToggle } from '../utils/helpers/hooks'; -import SortableBarGraph from './helpers/SortableBarGraph'; -import GraphCard from './helpers/GraphCard'; -import LineChartCard from './helpers/LineChartCard'; -import VisitsTable from './VisitsTable'; -import { VisitsInfoType } from './types'; -import OpenMapModalBtn from './helpers/OpenMapModalBtn'; - -const propTypes = { - children: PropTypes.node, - getVisits: PropTypes.func, - visitsInfo: VisitsInfoType, - cancelGetVisits: PropTypes.func, - matchMedia: PropTypes.func, -}; - -const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => { - if (!acc[highlightedVisit[prop]]) { - acc[highlightedVisit[prop]] = 0; - } - - acc[highlightedVisit[prop]] += 1; - - return acc; -}, {}); -const format = formatDate(); -let selectedBar; - -const VisitsStats = ({ processStatsFromVisits, normalizeVisits }) => { - const VisitsStatsComp = ({ children, visitsInfo, getVisits, cancelGetVisits, matchMedia = window.matchMedia }) => { - const [ startDate, setStartDate ] = useState(undefined); - const [ endDate, setEndDate ] = useState(undefined); - const [ showTable, toggleTable ] = useToggle(); - const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); - const [ highlightedVisits, setHighlightedVisits ] = useState([]); - const [ highlightedLabel, setHighlightedLabel ] = useState(); - const [ isMobileDevice, setIsMobileDevice ] = useState(false); - const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); - const setSelectedVisits = (selectedVisits) => { - selectedBar = undefined; - setHighlightedVisits(selectedVisits); - }; - const highlightVisitsForProp = (prop) => (value) => { - const newSelectedBar = `${prop}_${value}`; - - if (selectedBar === newSelectedBar) { - setHighlightedVisits([]); - setHighlightedLabel(undefined); - selectedBar = undefined; - } else { - setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); - setHighlightedLabel(value); - selectedBar = newSelectedBar; - } - }; - - const { visits, loading, loadingLarge, error, progress } = visitsInfo; - const showTableControls = !loading && visits.length > 0; - const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); - const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( - () => processStatsFromVisits(normalizedVisits), - [ normalizedVisits ], - ); - const mapLocations = values(citiesForMap); - - useEffect(() => { - determineIsMobileDevice(); - window.addEventListener('resize', determineIsMobileDevice); - - return () => { - cancelGetVisits(); - window.removeEventListener('resize', determineIsMobileDevice); - }; - }, []); - useEffect(() => { - getVisits({ startDate: format(startDate), endDate: format(endDate) }); - }, [ startDate, endDate ]); - - const renderVisitsContent = () => { - if (loadingLarge) { - return ( - - This is going to take a while... :S - - - ); - } - - if (loading) { - return ; - } - - if (error) { - return ( - - An error occurred while loading visits :( - - ); - } - - if (isEmpty(visits)) { - return There are no visits matching current filter :(; - } - - return ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- - mapLocations.length > 0 && - - } - sortingItems={{ - name: 'City name', - amount: 'Visits amount', - }} - onClick={highlightVisitsForProp('city')} - /> -
-
- ); - }; - - return ( - - {children} - -
-
-
- -
-
- {showTableControls && ( - - - - - - - - - )} -
-
-
- - {showTableControls && ( - - - - )} - -
- {renderVisitsContent()} -
-
- ); - }; - - VisitsStatsComp.propTypes = propTypes; - - return VisitsStatsComp; -}; - -export default VisitsStats; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx new file mode 100644 index 00000000..67f5b0ee --- /dev/null +++ b/src/visits/VisitsStats.tsx @@ -0,0 +1,250 @@ +import { isEmpty, propEq, values } from 'ramda'; +import React, { useState, useEffect, useMemo, FC } from 'react'; +import { Button, Card, Collapse, Progress } from 'reactstrap'; +import classNames from 'classnames'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons'; +import moment from 'moment'; +import DateRangeRow from '../utils/DateRangeRow'; +import Message from '../utils/Message'; +import { formatDate } from '../utils/helpers/date'; +import { useToggle } from '../utils/helpers/hooks'; +import { ShlinkVisitsParams } from '../utils/services/types'; +import SortableBarGraph from './helpers/SortableBarGraph'; +import GraphCard from './helpers/GraphCard'; +import LineChartCard from './helpers/LineChartCard'; +import VisitsTable from './VisitsTable'; +import { NormalizedVisit, Stats, VisitsInfo } from './types'; +import OpenMapModalBtn from './helpers/OpenMapModalBtn'; +import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; + +export interface VisitsStatsProps { + matchMedia?: (query: string) => MediaQueryList; + getVisits: (params: Partial) => void; + visitsInfo: VisitsInfo; + cancelGetVisits: () => void; +} + +type HighlightableProps = 'referer' | 'country' | 'city'; + +const highlightedVisitsToStats = ( + highlightedVisits: NormalizedVisit[], + prop: HighlightableProps, +): Stats => highlightedVisits.reduce((acc, highlightedVisit) => { + if (!acc[highlightedVisit[prop]]) { + acc[highlightedVisit[prop]] = 0; + } + + acc[highlightedVisit[prop]] += 1; + + return acc; +}, {}); +const format = formatDate(); +let selectedBar: string | undefined; + +const VisitsStats: FC = ( + { children, visitsInfo, getVisits, cancelGetVisits, matchMedia = window.matchMedia }, +) => { + const [ startDate, setStartDate ] = useState(null); + const [ endDate, setEndDate ] = useState(null); + const [ showTable, toggleTable ] = useToggle(); + const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); + const [ highlightedVisits, setHighlightedVisits ] = useState([]); + const [ highlightedLabel, setHighlightedLabel ] = useState(); + const [ isMobileDevice, setIsMobileDevice ] = useState(false); + + const { visits, loading, loadingLarge, error, progress } = visitsInfo; + const showTableControls = !loading && visits.length > 0; + const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); + const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( + () => processStatsFromVisits(normalizedVisits), + [ normalizedVisits ], + ); + const mapLocations = values(citiesForMap); + + const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); + const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => { + selectedBar = undefined; + setHighlightedVisits(selectedVisits); + }; + const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => { + const newSelectedBar = `${prop}_${value}`; + + if (selectedBar === newSelectedBar) { + setHighlightedVisits([]); + setHighlightedLabel(undefined); + selectedBar = undefined; + } else { + setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); + setHighlightedLabel(value); + selectedBar = newSelectedBar; + } + }; + + useEffect(() => { + determineIsMobileDevice(); + window.addEventListener('resize', determineIsMobileDevice); + + return () => { + cancelGetVisits(); + window.removeEventListener('resize', determineIsMobileDevice); + }; + }, []); + useEffect(() => { + getVisits({ startDate: format(startDate) ?? undefined, endDate: format(endDate) ?? undefined }); + }, [ startDate, endDate ]); + + const renderVisitsContent = () => { + if (loadingLarge) { + return ( + + This is going to take a while... :S + + + ); + } + + if (loading) { + return ; + } + + if (error) { + return ( + + An error occurred while loading visits :( + + ); + } + + if (isEmpty(visits)) { + return There are no visits matching current filter :(; + } + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + mapLocations.length > 0 && + + } + sortingItems={{ + name: 'City name', + amount: 'Visits amount', + }} + onClick={highlightVisitsForProp('city')} + /> +
+
+ ); + }; + + return ( + + {children} + +
+
+
+ +
+
+ {showTableControls && ( + + + + + + + + + )} +
+
+
+ + {showTableControls && ( + + + + )} + +
+ {renderVisitsContent()} +
+
+ ); +}; + +export default VisitsStats; diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.tsx similarity index 80% rename from src/visits/VisitsTable.js rename to src/visits/VisitsTable.tsx index 0beb7635..13de3fcf 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useState } from 'react'; -import PropTypes from 'prop-types'; import Moment from 'react-moment'; import classNames from 'classnames'; import { min, splitEvery } from 'ramda'; @@ -11,35 +10,42 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import SimplePaginator from '../common/SimplePaginator'; import SearchField from '../utils/SearchField'; -import { determineOrderDir } from '../utils/utils'; +import { determineOrderDir, OrderDir } from '../utils/utils'; import { prettify } from '../utils/helpers/numbers'; +import { NormalizedVisit } from './types'; import './VisitsTable.scss'; -const NormalizedVisitType = PropTypes.shape({ +interface VisitsTableProps { + visits: NormalizedVisit[]; + selectedVisits?: NormalizedVisit[]; + setSelectedVisits: (visits: NormalizedVisit[]) => void; + isSticky?: boolean; + matchMedia?: (query: string) => MediaQueryList; +} -}); +type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer'; -const propTypes = { - visits: PropTypes.arrayOf(NormalizedVisitType).isRequired, - selectedVisits: PropTypes.arrayOf(NormalizedVisitType), - setSelectedVisits: PropTypes.func.isRequired, - isSticky: PropTypes.bool, - matchMedia: PropTypes.func, -}; +interface Order { + field?: OrderableFields; + dir?: OrderDir; +} const PAGE_SIZE = 20; -const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) => +const visitMatchesSearch = ({ browser, os, referer, country, city }: NormalizedVisit, searchTerm: string) => `${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase()); -const searchVisits = (searchTerm, visits) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); -const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => { - const greaterThan = dir === 'ASC' ? 1 : -1; - const smallerThan = dir === 'ASC' ? -1 : 1; +const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) => + visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); +const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort( + (a, b) => { + const greaterThan = dir === 'ASC' ? 1 : -1; + const smallerThan = dir === 'ASC' ? -1 : 1; - return a[field] > b[field] ? greaterThan : smallerThan; -}); -const calculateVisits = (allVisits, searchTerm, order) => { + return a[field] > b[field] ? greaterThan : smallerThan; + }, +); +const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => { const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ]; - const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits; + const sortedVisits = sortVisits(order, filteredVisits); const total = sortedVisits.length; const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits); @@ -52,23 +58,24 @@ const VisitsTable = ({ setSelectedVisits, isSticky = false, matchMedia = window.matchMedia, -}) => { +}: VisitsTableProps) => { const headerCellsClass = classNames('visits-table__header-cell', { 'visits-table__sticky': isSticky, }); const matchMobile = () => matchMedia('(max-width: 767px)').matches; const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); - const [ searchTerm, setSearchTerm ] = useState(undefined); - const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); + const [ searchTerm, setSearchTerm ] = useState(undefined); + const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const [ page, setPage ] = useState(1); const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; - const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); - const renderOrderIcon = (field) => order.dir && order.field === field && ( + const orderByColumn = (field: OrderableFields) => + () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); + const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field && ( { return matcher() ?? 'monthly'; }; -const groupVisitsByStep = (step: Step, visits: Visit[]): Stats => visits.reduce((acc, visit) => { - const key = STEP_TO_DATE_FORMAT[step](visit.date); +const groupVisitsByStep = (step: Step, visits: (Visit | NormalizedVisit)[]): Stats => visits.reduce( + (acc, visit) => { + const key = STEP_TO_DATE_FORMAT[step](visit.date); - acc[key] = acc[key] ? acc[key] + 1 : 1; + acc[key] = acc[key] ? acc[key] + 1 : 1; - return acc; -}, {}); + return acc; + }, + {}, +); const generateLabels = (step: Step, visits: Visit[]): string[] => { const unit = STEP_TO_DATE_UNIT_MAP[step]; diff --git a/src/visits/services/VisitsParser.ts b/src/visits/services/VisitsParser.ts index c905d0b1..3956788d 100644 --- a/src/visits/services/VisitsParser.ts +++ b/src/visits/services/VisitsParser.ts @@ -77,3 +77,8 @@ export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }: latitude: visitLocation?.latitude, longitude: visitLocation?.longitude, })); + +export interface VisitsParser { + processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats; + normalizeVisits: (visits: Visit[]) => NormalizedVisit[]; +} diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 5880193c..80372ebc 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -3,7 +3,6 @@ import ShortUrlVisits from '../ShortUrlVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import MapModal from '../helpers/MapModal'; -import VisitsStats from '../VisitsStats'; import { createNewVisit } from '../reducers/visitCreation'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import TagVisits from '../TagVisits'; @@ -13,13 +12,12 @@ import * as visitsParser from './VisitsParser'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('MapModal', () => MapModal); - bottle.serviceFactory('VisitsStats', VisitsStats, 'VisitsParser'); - bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsStats'); + bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ], )); - bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator'); + bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator'); bottle.decorator('TagVisits', connect( [ 'tagVisits', 'mercureInfo' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ], diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 1400ee77..b54c19f7 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,33 +1,6 @@ -import PropTypes from 'prop-types'; import { Action } from 'redux'; import { ShortUrl } from '../../short-urls/data'; -/** @deprecated Use Visit interface instead */ -export const VisitType = PropTypes.shape({ - referer: PropTypes.string, - date: PropTypes.string, - userAgent: PropTypes.string, - visitLocation: PropTypes.shape({ - countryCode: PropTypes.string, - countryName: PropTypes.string, - regionName: PropTypes.string, - cityName: PropTypes.string, - latitude: PropTypes.number, - longitude: PropTypes.number, - timezone: PropTypes.string, - isEmpty: PropTypes.bool, - }), -}); - -/** @deprecated Use VisitsInfo interface instead */ -export const VisitsInfoType = PropTypes.shape({ - visits: PropTypes.arrayOf(VisitType), - loading: PropTypes.bool, - loadingLarge: PropTypes.bool, - error: PropTypes.bool, - progress: PropTypes.number, -}); - export interface VisitsInfo { visits: Visit[]; loading: boolean; diff --git a/test/visits/ShortUrlVisits.test.tsx b/test/visits/ShortUrlVisits.test.tsx index de93ea28..f3516b4e 100644 --- a/test/visits/ShortUrlVisits.test.tsx +++ b/test/visits/ShortUrlVisits.test.tsx @@ -4,10 +4,11 @@ import { identity } from 'ramda'; import { Mock } from 'ts-mockery'; import { History, Location } from 'history'; import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars -import createShortUrlVisits, { ShortUrlVisitsProps } from '../../src/visits/ShortUrlVisits'; +import ShortUrlVisits, { ShortUrlVisitsProps } from '../../src/visits/ShortUrlVisits'; import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; import { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers/shortUrlVisits'; import { ShortUrlDetail } from '../../src/visits/reducers/shortUrlDetail'; +import VisitsStats from '../../src/visits/VisitsStats'; describe('', () => { let wrapper: ShallowWrapper; @@ -19,11 +20,8 @@ describe('', () => { const history = Mock.of({ goBack: jest.fn(), }); - const VisitsStats = jest.fn(); beforeEach(() => { - const ShortUrlVisits = createShortUrlVisits(VisitsStats); - wrapper = shallow( ()} @@ -34,7 +32,7 @@ describe('', () => { history={history} shortUrlVisits={Mock.of({ loading: true, visits: [] })} shortUrlDetail={Mock.all()} - cancelGetShortUrlVisits={identity} + cancelGetShortUrlVisits={() => {}} />, ); }); diff --git a/test/visits/ShortUrlVisitsHeader.test.js b/test/visits/ShortUrlVisitsHeader.test.tsx similarity index 70% rename from test/visits/ShortUrlVisitsHeader.test.js rename to test/visits/ShortUrlVisitsHeader.test.tsx index fef9c2ca..7eae6954 100644 --- a/test/visits/ShortUrlVisitsHeader.test.js +++ b/test/visits/ShortUrlVisitsHeader.test.tsx @@ -1,22 +1,25 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import Moment from 'react-moment'; import { ExternalLink } from 'react-external-link'; +import { Mock } from 'ts-mockery'; import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; +import { ShortUrlDetail } from '../../src/visits/reducers/shortUrlDetail'; +import { ShortUrlVisits } from '../../src/visits/reducers/shortUrlVisits'; describe('', () => { - let wrapper; - const shortUrlDetail = { + let wrapper: ShallowWrapper; + const shortUrlDetail = Mock.of({ shortUrl: { shortUrl: 'https://doma.in/abc123', longUrl: 'https://foo.bar/bar/foo', dateCreated: '2018-01-01T10:00:00+01:00', }, loading: false, - }; - const shortUrlVisits = { + }); + const shortUrlVisits = Mock.of({ visits: [{}, {}, {}], - }; + }); const goBack = jest.fn(); beforeEach(() => { @@ -29,12 +32,12 @@ describe('', () => { it('shows when the URL was created', () => { const moment = wrapper.find(Moment).first(); - expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated); + expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl?.dateCreated); }); it('shows the long URL', () => { const longUrlLink = wrapper.find(ExternalLink).last(); - expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl); + expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl?.longUrl); }); }); diff --git a/test/visits/TagVisits.test.js b/test/visits/TagVisits.test.js deleted file mode 100644 index 023b5caf..00000000 --- a/test/visits/TagVisits.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import { identity } from 'ramda'; -import createTagVisits from '../../src/visits/TagVisits'; -import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; - -describe('', () => { - let wrapper; - const getTagVisitsMock = jest.fn(); - const match = { - params: { tag: 'foo' }, - }; - const history = { - goBack: jest.fn(), - }; - const VisitsStats = jest.fn(); - - beforeEach(() => { - const TagVisits = createTagVisits(VisitsStats, {}); - - wrapper = shallow( - , - ); - }); - - afterEach(() => wrapper.unmount()); - afterEach(jest.resetAllMocks); - - it('renders visit stats and visits header', () => { - const visitStats = wrapper.find(VisitsStats); - const visitHeader = wrapper.find(TagVisitsHeader); - - expect(visitStats).toHaveLength(1); - expect(visitHeader).toHaveLength(1); - }); -}); diff --git a/test/visits/TagVisits.test.tsx b/test/visits/TagVisits.test.tsx new file mode 100644 index 00000000..6c8e8b1c --- /dev/null +++ b/test/visits/TagVisits.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { History } from 'history'; +import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars +import createTagVisits, { TagVisitsProps } from '../../src/visits/TagVisits'; +import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; +import ColorGenerator from '../../src/utils/services/ColorGenerator'; +import { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits'; +import VisitsStats from '../../src/visits/VisitsStats'; + +describe('', () => { + let wrapper: ShallowWrapper; + const getTagVisitsMock = jest.fn(); + const match = Mock.of>({ + params: { tag: 'foo' }, + }); + const history = Mock.of({ + goBack: jest.fn(), + }); + + beforeEach(() => { + const TagVisits = createTagVisits(Mock.of()); + + wrapper = shallow( + ()} + getTagVisits={getTagVisitsMock} + match={match} + history={history} + tagVisits={Mock.of({ loading: true, visits: [] })} + cancelGetTagVisits={() => {}} + />, + ); + }); + + afterEach(() => wrapper.unmount()); + afterEach(jest.resetAllMocks); + + it('renders visit stats and visits header', () => { + const visitStats = wrapper.find(VisitsStats); + const visitHeader = wrapper.find(TagVisitsHeader); + + expect(visitStats).toHaveLength(1); + expect(visitHeader).toHaveLength(1); + }); +}); diff --git a/test/visits/TagVisitsHeader.test.js b/test/visits/TagVisitsHeader.test.tsx similarity index 68% rename from test/visits/TagVisitsHeader.test.js rename to test/visits/TagVisitsHeader.test.tsx index 15a8defc..ce5918c9 100644 --- a/test/visits/TagVisitsHeader.test.js +++ b/test/visits/TagVisitsHeader.test.tsx @@ -1,19 +1,22 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; import Tag from '../../src/tags/helpers/Tag'; import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; +import { TagVisits } from '../../src/visits/reducers/tagVisits'; +import ColorGenerator from '../../src/utils/services/ColorGenerator'; describe('', () => { - let wrapper; - const tagVisits = { + let wrapper: ShallowWrapper; + const tagVisits = Mock.of({ tag: 'foo', visits: [{}, {}, {}], - }; + }); const goBack = jest.fn(); beforeEach(() => { wrapper = shallow( - , + ()} />, ); }); afterEach(() => wrapper.unmount()); diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.tsx similarity index 78% rename from test/visits/VisitsHeader.test.js rename to test/visits/VisitsHeader.test.tsx index a23cf5ee..c76274f9 100644 --- a/test/visits/VisitsHeader.test.js +++ b/test/visits/VisitsHeader.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; import VisitsHeader from '../../src/visits/VisitsHeader'; +import { Visit } from '../../src/visits/types'; describe('', () => { - let wrapper; - const visits = [{}, {}, {}]; + let wrapper: ShallowWrapper; + const visits = [ Mock.all(), Mock.all(), Mock.all() ]; const title = 'My header title'; const goBack = jest.fn(); diff --git a/test/visits/VisitsStats.test.js b/test/visits/VisitsStats.test.tsx similarity index 83% rename from test/visits/VisitsStats.test.js rename to test/visits/VisitsStats.test.tsx index b4670d22..addb780d 100644 --- a/test/visits/VisitsStats.test.js +++ b/test/visits/VisitsStats.test.tsx @@ -1,36 +1,34 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import { identity } from 'ramda'; +import { shallow, ShallowWrapper } from 'enzyme'; import { Card, Progress } from 'reactstrap'; -import createVisitStats from '../../src/visits/VisitsStats'; +import { Mock } from 'ts-mockery'; +import VisitStats from '../../src/visits/VisitsStats'; import Message from '../../src/utils/Message'; import GraphCard from '../../src/visits/helpers/GraphCard'; import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph'; import DateRangeRow from '../../src/utils/DateRangeRow'; +import { Visit, VisitsInfo } from '../../src/visits/types'; describe('', () => { - let wrapper; - const processStatsFromVisits = () => ( - { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } - ); + const visits = [ Mock.all(), Mock.all(), Mock.all() ]; + + let wrapper: ShallowWrapper; const getVisitsMock = jest.fn(); - const createComponent = (visitsInfo) => { - const VisitStats = createVisitStats({ processStatsFromVisits, normalizeVisits: identity }, () => ''); - + const createComponent = (visitsInfo: Partial) => { wrapper = shallow( ({ matches: false })} + visitsInfo={Mock.of(visitsInfo)} + cancelGetVisits={() => {}} + matchMedia={() => Mock.of({ matches: false })} />, ); return wrapper; }; - afterEach(() => wrapper && wrapper.unmount()); + afterEach(() => wrapper?.unmount()); it('renders a preloader when visits are loading', () => { const wrapper = createComponent({ loading: true, visits: [] }); @@ -70,7 +68,7 @@ describe('', () => { }); it('renders all graphics when visits are properly loaded', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const wrapper = createComponent({ loading: false, error: false, visits }); const graphs = wrapper.find(GraphCard); const sortableBarGraphs = wrapper.find(SortableBarGraph); @@ -78,7 +76,7 @@ describe('', () => { }); it('reloads visits when selected dates change', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const wrapper = createComponent({ loading: false, error: false, visits }); const dateRange = wrapper.find(DateRangeRow); dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); @@ -90,7 +88,7 @@ describe('', () => { }); it('holds the map button content generator on cities graph extraHeaderContent', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const wrapper = createComponent({ loading: false, error: false, visits }); const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]'); const extraHeaderContent = citiesGraph.prop('extraHeaderContent'); diff --git a/test/visits/VisitsTable.test.js b/test/visits/VisitsTable.test.tsx similarity index 79% rename from test/visits/VisitsTable.test.js rename to test/visits/VisitsTable.test.tsx index fdee519d..d117f53f 100644 --- a/test/visits/VisitsTable.test.js +++ b/test/visits/VisitsTable.test.tsx @@ -1,15 +1,17 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; import VisitsTable from '../../src/visits/VisitsTable'; import { rangeOf } from '../../src/utils/utils'; import SimplePaginator from '../../src/common/SimplePaginator'; import SearchField from '../../src/utils/SearchField'; +import { NormalizedVisit } from '../../src/visits/types'; describe('', () => { - const matchMedia = () => ({ matches: false }); + const matchMedia = () => Mock.of({ matches: false }); const setSelectedVisits = jest.fn(); - let wrapper; - const createWrapper = (visits, selectedVisits = []) => { + let wrapper: ShallowWrapper; + const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => { wrapper = shallow( ', () => { return wrapper; }; - afterEach(() => { - jest.resetAllMocks(); - wrapper && wrapper.unmount(); - }); + afterEach(jest.resetAllMocks); + afterEach(() => wrapper?.unmount()); it('renders columns as expected', () => { const wrapper = createWrapper([]); @@ -55,7 +55,9 @@ describe('', () => { [ 60, 3 ], [ 115, 6 ], ])('renders the expected amount of pages', (visitsCount, expectedAmountOfPages) => { - const wrapper = createWrapper(rangeOf(visitsCount, () => ({ browser: '', date: '', referer: '' }))); + const wrapper = createWrapper( + rangeOf(visitsCount, () => Mock.of({ browser: '', date: '', referer: '' })), + ); const tr = wrapper.find('tbody').find('tr'); const paginator = wrapper.find(SimplePaginator); @@ -66,7 +68,9 @@ describe('', () => { it.each( rangeOf(20, (value) => [ value ]), )('does not render footer when there is only one page to render', (visitsCount) => { - const wrapper = createWrapper(rangeOf(visitsCount, () => ({ browser: '', date: '', referer: '' }))); + const wrapper = createWrapper( + rangeOf(visitsCount, () => Mock.of({ browser: '', date: '', referer: '' })), + ); const tr = wrapper.find('tbody').find('tr'); const paginator = wrapper.find(SimplePaginator); @@ -75,7 +79,7 @@ describe('', () => { }); it('selected rows are highlighted', () => { - const visits = rangeOf(10, () => ({ browser: '', date: '', referer: '' })); + const visits = rangeOf(10, () => Mock.of({ browser: '', date: '', referer: '' })); const wrapper = createWrapper( visits, [ visits[1], visits[2] ], @@ -98,7 +102,7 @@ describe('', () => { }); it('orders visits when column is clicked', () => { - const wrapper = createWrapper(rangeOf(9, (index) => ({ + const wrapper = createWrapper(rangeOf(9, (index) => Mock.of({ browser: '', date: `${9 - index}`, referer: `${index}`, @@ -118,8 +122,8 @@ describe('', () => { it('filters list when writing in search box', () => { const wrapper = createWrapper([ - ...rangeOf(7, () => ({ browser: 'aaa', date: 'aaa', referer: 'aaa' })), - ...rangeOf(2, () => ({ browser: 'bbb', date: 'bbb', referer: 'bbb' })), + ...rangeOf(7, () => Mock.of({ browser: 'aaa', date: 'aaa', referer: 'aaa' })), + ...rangeOf(2, () => Mock.of({ browser: 'bbb', date: 'bbb', referer: 'bbb' })), ]); const searchField = wrapper.find(SearchField);