diff --git a/CHANGELOG.md b/CHANGELOG.md index df595121..40ae208b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI. * If it fails, it will assume it is either not configured or not supported by the Shlink version. -* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app. - * [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag. This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same. +* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag. + + This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag. + +* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app. + #### Changed * [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu. diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index 15682458..428eeb1a 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -8,6 +8,7 @@ import * as PropTypes from 'prop-types'; import { serverType } from '../servers/prop-types'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { useToggle } from '../utils/helpers/hooks'; +import { versionMatch } from '../utils/helpers/version'; import NotFound from './NotFound'; import './MenuLayout.scss'; @@ -17,7 +18,16 @@ const propTypes = { selectedServer: serverType, }; -const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => { +const MenuLayout = ( + TagsList, + ShortUrls, + AsideMenu, + CreateShortUrl, + ShortUrlVisits, + TagVisits, + ShlinkVersions, + ServerError +) => { const MenuLayoutComp = ({ match, location, selectedServer }) => { const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); const { params: { serverId } } = match; @@ -28,6 +38,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi return ; } + const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' }); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible, }); @@ -61,6 +72,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi + {addTagsVisitsRoute && } List short URLs} diff --git a/src/common/services/provideServices.js b/src/common/services/provideServices.js index a8c842b6..1d4b288b 100644 --- a/src/common/services/provideServices.js +++ b/src/common/services/provideServices.js @@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => { 'AsideMenu', 'CreateShortUrl', 'ShortUrlVisits', + 'TagVisits', 'ShlinkVersions', 'ServerError' ); diff --git a/src/index.scss b/src/index.scss index 77df9f09..be46288b 100644 --- a/src/index.scss +++ b/src/index.scss @@ -61,3 +61,7 @@ body, background-color: darken($mainColor, 12%); } } + +.progress-bar { + background-color: $mainColor; +} diff --git a/src/reducers/index.js b/src/reducers/index.js index 51b95d8c..1cdf34fd 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,6 +9,7 @@ import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; +import tagVisitsReducer from '../visits/reducers/tagVisits'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; @@ -27,6 +28,7 @@ export default combineReducers({ shortUrlMeta: shortUrlMetaReducer, shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, + tagVisits: tagVisitsReducer, shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index b7d346cc..9953e4e2 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -1,8 +1,8 @@ import { handleActions } from 'redux-actions'; import { assoc, assocPath, reject } from 'ramda'; import PropTypes from 'prop-types'; -import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits'; import { shortUrlMatches } from '../helpers'; +import { CREATE_VISIT } from '../../visits/reducers/visitCreation'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; @@ -50,7 +50,7 @@ export default handleActions({ [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), - [CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( + [CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath( [ 'shortUrls', 'data' ], state.shortUrls && state.shortUrls.data && state.shortUrls.data.map( (shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) diff --git a/src/tags/TagCard.js b/src/tags/TagCard.js index 3f66bf5b..9ec68982 100644 --- a/src/tags/TagCard.js +++ b/src/tags/TagCard.js @@ -60,7 +60,7 @@ const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGen {prettify(tagStats.shortUrlsCount)} Visits diff --git a/src/tags/TagsList.js b/src/tags/TagsList.js index 54b18c97..f0e70874 100644 --- a/src/tags/TagsList.js +++ b/src/tags/TagsList.js @@ -4,6 +4,9 @@ import PropTypes from 'prop-types'; import Message from '../utils/Message'; import SearchField from '../utils/SearchField'; import { serverType } from '../servers/prop-types'; +import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { SettingsType } from '../settings/reducers/settings'; +import { bindToMercureTopic } from '../mercure/helpers'; import { TagsListType } from './reducers/tagsList'; const { ceil } = Math; @@ -14,15 +17,26 @@ const propTypes = { forceListTags: PropTypes.func, tagsList: TagsListType, selectedServer: serverType, + createNewVisit: PropTypes.func, + loadMercureInfo: PropTypes.func, + mercureInfo: MercureInfoType, + settings: SettingsType, }; const TagsList = (TagCard) => { - const TagListComp = ({ filterTags, forceListTags, tagsList, selectedServer }) => { + const TagListComp = ( + { filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo, settings } + ) => { + const { realTimeUpdates } = settings; const [ displayedTag, setDisplayedTag ] = useState(); useEffect(() => { forceListTags(); }, []); + useEffect( + bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo), + [ mercureInfo ] + ); const renderContent = () => { if (tagsList.loading) { diff --git a/src/tags/helpers/Tag.js b/src/tags/helpers/Tag.js index 29515af5..e2a29e9a 100644 --- a/src/tags/helpers/Tag.js +++ b/src/tags/helpers/Tag.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import './Tag.scss'; import { colorGeneratorType } from '../../utils/services/ColorGenerator'; +import './Tag.scss'; const propTypes = { text: PropTypes.string, @@ -17,12 +17,12 @@ const Tag = ({ children, clearable, colorGenerator, - onClick = () => {}, - onClose = () => {}, + onClick, + onClose, }) => ( {children || text} diff --git a/src/tags/helpers/Tag.scss b/src/tags/helpers/Tag.scss index 757abd36..a3a5ecec 100644 --- a/src/tags/helpers/Tag.scss +++ b/src/tags/helpers/Tag.scss @@ -1,6 +1,5 @@ .tag { color: #fff; - cursor: pointer; } .tag:not(:last-child) { diff --git a/src/tags/reducers/tagsList.js b/src/tags/reducers/tagsList.js index e810f2c7..643b3a94 100644 --- a/src/tags/reducers/tagsList.js +++ b/src/tags/reducers/tagsList.js @@ -1,6 +1,7 @@ import { handleActions } from 'redux-actions'; import { isEmpty, reject } from 'ramda'; import PropTypes from 'prop-types'; +import { CREATE_VISIT } from '../../visits/reducers/visitCreation'; import { TAG_DELETED } from './tagDelete'; import { TAG_EDITED } from './tagEdit'; @@ -11,10 +12,15 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; /* eslint-enable padding-line-between-statements */ +const TagStatsType = PropTypes.shape({ + shortUrlsCount: PropTypes.number, + visitsCount: PropTypes.number, +}); + export const TagsListType = PropTypes.shape({ tags: PropTypes.arrayOf(PropTypes.string), filteredTags: PropTypes.arrayOf(PropTypes.string), - stats: PropTypes.object, // Record + stats: PropTypes.objectOf(TagStatsType), // Record loading: PropTypes.bool, error: PropTypes.bool, }); @@ -29,11 +35,23 @@ const initialState = { const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag; const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags); +const increaseVisitsForTags = (tags, stats) => tags.reduce((stats, tag) => { + if (!stats[tag]) { + return stats; + } + + const tagStats = stats[tag]; + + tagStats.visitsCount = tagStats.visitsCount + 1; + stats[tag] = tagStats; + + return stats; +}, { ...stats }); export default handleActions({ - [LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }), - [LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }), - [LIST_TAGS]: (state, { tags, stats }) => ({ stats, tags, filteredTags: tags, loading: false, error: false }), + [LIST_TAGS_START]: () => ({ ...initialState, loading: true }), + [LIST_TAGS_ERROR]: () => ({ ...initialState, error: true }), + [LIST_TAGS]: (state, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }), [TAG_DELETED]: (state, { tag }) => ({ ...state, tags: rejectTag(state.tags, tag), @@ -48,6 +66,10 @@ export default handleActions({ ...state, filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)), }), + [CREATE_VISIT]: (state, { shortUrl }) => ({ + ...state, + stats: increaseVisitsForTags(shortUrl.tags, state.stats), + }), }, initialState); export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => { @@ -74,7 +96,4 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis } }; -export const filterTags = (searchTerm) => ({ - type: FILTER_TAGS, - searchTerm, -}); +export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm }); diff --git a/src/tags/services/provideServices.js b/src/tags/services/provideServices.js index 566791ea..7917d068 100644 --- a/src/tags/services/provideServices.js +++ b/src/tags/services/provideServices.js @@ -28,7 +28,10 @@ const provideServices = (bottle, connect) => { bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ])); bottle.serviceFactory('TagsList', TagsList, 'TagCard'); - bottle.decorator('TagsList', connect([ 'tagsList', 'selectedServer' ], [ 'forceListTags', 'filterTags' ])); + bottle.decorator('TagsList', connect( + [ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ], + [ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ] + )); // Actions const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force); diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js index 76d1cd56..05a9c5bf 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -36,6 +36,10 @@ export default class ShlinkApiClient { this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query) .then((resp) => resp.data.visits); + getTagVisits = (tag, query) => + this._performRequest(`/tags/${tag}/visits`, 'GET', query) + .then((resp) => resp.data.visits); + getShortUrl = (shortCode, domain) => this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain }) .then((resp) => resp.data); diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 12f5deb3..c3eac779 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -1,24 +1,12 @@ -import { isEmpty, propEq, values } from 'ramda'; -import React, { useState, useEffect, useMemo } from 'react'; -import { Button, Card, Collapse } from 'reactstrap'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import qs from 'qs'; -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 { MercureInfoType } from '../mercure/reducers/mercureInfo'; import { bindToMercureTopic } from '../mercure/helpers'; import { SettingsType } from '../settings/reducers/settings'; -import SortableBarGraph from './SortableBarGraph'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; -import VisitsHeader from './VisitsHeader'; -import GraphCard from './GraphCard'; +import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; -import VisitsTable from './VisitsTable'; const propTypes = { history: PropTypes.shape({ @@ -35,26 +23,13 @@ const propTypes = { getShortUrlDetail: PropTypes.func, shortUrlDetail: shortUrlDetailType, cancelGetShortUrlVisits: PropTypes.func, - matchMedia: PropTypes.func, createNewVisit: PropTypes.func, loadMercureInfo: PropTypes.func, mercureInfo: MercureInfoType, settings: SettingsType, }; -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 ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => { +const ShortUrlVisits = (VisitsStats) => { const ShortUrlVisitsComp = ({ history, match, @@ -64,65 +39,21 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa getShortUrlVisits, getShortUrlDetail, cancelGetShortUrlVisits, - matchMedia = window.matchMedia, createNewVisit, loadMercureInfo, mercureInfo, settings: { realTimeUpdates }, }) => { - const [ startDate, setStartDate ] = useState(undefined); - const [ endDate, setEndDate ] = useState(undefined); - const [ showTable, toggleTable ] = useToggle(); - const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); - const [ highlightedVisits, setHighlightedVisits ] = 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([]); - selectedBar = undefined; - } else { - setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); - selectedBar = newSelectedBar; - } - }; - const { params } = match; const { shortCode } = params; const { search } = location; const { domain } = qs.parse(search, { ignoreQueryPrefix: true }); - const { visits, loading, loadingLarge, error } = shortUrlVisits; - 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 loadVisits = () => - getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain }); + const loadVisits = (dates) => getShortUrlVisits(shortCode, { ...dates, domain }); useEffect(() => { getShortUrlDetail(shortCode, domain); - determineIsMobileDevice(); - window.addEventListener('resize', determineIsMobileDevice); - - return () => { - cancelGetShortUrlVisits(); - window.removeEventListener('resize', determineIsMobileDevice); - }; }, []); - useEffect(() => { - loadVisits(); - }, [ startDate, endDate ]); useEffect( bindToMercureTopic( mercureInfo, @@ -134,138 +65,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa [ mercureInfo ], ); - const renderVisitsContent = () => { - if (loading) { - const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; - - return {message}; - } - - 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 ( - - - -
-
-
- -
-
- {showTableControls && ( - - - - - - - - - )} -
-
-
- - {showTableControls && ( - - - - )} - -
- {renderVisitsContent()} -
-
+ + + ); }; diff --git a/src/visits/ShortUrlVisitsHeader.js b/src/visits/ShortUrlVisitsHeader.js new file mode 100644 index 00000000..a3b100a3 --- /dev/null +++ b/src/visits/ShortUrlVisitsHeader.js @@ -0,0 +1,54 @@ +import { UncontrolledTooltip } from 'reactstrap'; +import Moment from 'react-moment'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { ExternalLink } from 'react-external-link'; +import { shortUrlDetailType } from './reducers/shortUrlDetail'; +import { shortUrlVisitsType } from './reducers/shortUrlVisits'; +import VisitsHeader from './VisitsHeader'; +import './ShortUrlVisitsHeader.scss'; + +const propTypes = { + shortUrlDetail: shortUrlDetailType.isRequired, + shortUrlVisits: shortUrlVisitsType.isRequired, + goBack: PropTypes.func.isRequired, +}; + +const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }) => { + const { shortUrl, loading } = shortUrlDetail; + const { visits } = shortUrlVisits; + const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; + const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : ''; + + const renderDate = () => ( + + + {shortUrl.dateCreated} + + + {shortUrl.dateCreated} + + + ); + const visitsStatsTitle = ( + + Visits for + + ); + + return ( + +
+
Created: {renderDate()}
+
+ Long URL:{' '} + {loading && Loading...} + {!loading && } +
+
+ ); +}; + +ShortUrlVisitsHeader.propTypes = propTypes; + +export default ShortUrlVisitsHeader; diff --git a/src/visits/ShortUrlVisitsHeader.scss b/src/visits/ShortUrlVisitsHeader.scss new file mode 100644 index 00000000..cb223b60 --- /dev/null +++ b/src/visits/ShortUrlVisitsHeader.scss @@ -0,0 +1,3 @@ +.short-url-visits-header__created-at { + cursor: default; +} diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js index a19e9f4f..3d849fa6 100644 --- a/src/visits/SortableBarGraph.js +++ b/src/visits/SortableBarGraph.js @@ -28,7 +28,7 @@ export default class SortableBarGraph extends React.Component { orderField: undefined, orderDir: undefined, currentPage: 1, - itemsPerPage: Infinity, + itemsPerPage: 50, }; getSortedPairsForStats(stats, sortingItems) { diff --git a/src/visits/TagVisits.js b/src/visits/TagVisits.js new file mode 100644 index 00000000..ae17aacd --- /dev/null +++ b/src/visits/TagVisits.js @@ -0,0 +1,64 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { MercureInfoType } from '../mercure/reducers/mercureInfo'; +import { SettingsType } from '../settings/reducers/settings'; +import { bindToMercureTopic } from '../mercure/helpers'; +import { TagVisitsType } from './reducers/tagVisits'; +import TagVisitsHeader from './TagVisitsHeader'; + +const propTypes = { + history: PropTypes.shape({ + goBack: PropTypes.func, + }), + match: PropTypes.shape({ + params: PropTypes.object, + }), + getTagVisits: PropTypes.func, + tagVisits: TagVisitsType, + cancelGetTagVisits: PropTypes.func, + createNewVisit: PropTypes.func, + loadMercureInfo: PropTypes.func, + mercureInfo: MercureInfoType, + settings: SettingsType, +}; + +const TagVisits = (VisitsStats, colorGenerator) => { + const TagVisitsComp = ({ + history, + match, + getTagVisits, + tagVisits, + cancelGetTagVisits, + createNewVisit, + loadMercureInfo, + mercureInfo, + settings: { realTimeUpdates }, + }) => { + const { params } = match; + const { tag } = params; + const loadVisits = (dates) => getTagVisits(tag, dates); + + useEffect( + bindToMercureTopic( + mercureInfo, + realTimeUpdates, + 'https://shlink.io/new-visit', + createNewVisit, + loadMercureInfo + ), + [ mercureInfo ], + ); + + return ( + + + + ); + }; + + TagVisitsComp.propTypes = propTypes; + + return TagVisitsComp; +}; + +export default TagVisits; diff --git a/src/visits/TagVisitsHeader.js b/src/visits/TagVisitsHeader.js new file mode 100644 index 00000000..48c8bf3c --- /dev/null +++ b/src/visits/TagVisitsHeader.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Tag from '../tags/helpers/Tag'; +import { colorGeneratorType } from '../utils/services/ColorGenerator'; +import VisitsHeader from './VisitsHeader'; +import { TagVisitsType } from './reducers/tagVisits'; +import './ShortUrlVisitsHeader.scss'; + +const propTypes = { + tagVisits: TagVisitsType.isRequired, + goBack: PropTypes.func.isRequired, + colorGenerator: colorGeneratorType, +}; + +const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }) => { + const { visits, tag } = tagVisits; + + const visitsStatsTitle = ( + + Visits for + + + ); + + return ; +}; + +TagVisitsHeader.propTypes = propTypes; + +export default TagVisitsHeader; diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 84752c1c..fa59beee 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -1,67 +1,44 @@ -import { Button, Card, UncontrolledTooltip } from 'reactstrap'; -import Moment from 'react-moment'; +import { Button, Card } from 'reactstrap'; import React from 'react'; import PropTypes from 'prop-types'; -import { ExternalLink } from 'react-external-link'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount'; -import { shortUrlDetailType } from './reducers/shortUrlDetail'; -import { shortUrlVisitsType } from './reducers/shortUrlVisits'; -import './VisitsHeader.scss'; +import { shortUrlType } from '../short-urls/reducers/shortUrlsList'; +import { VisitType } from './types'; const propTypes = { - shortUrlDetail: shortUrlDetailType.isRequired, - shortUrlVisits: shortUrlVisitsType.isRequired, + visits: PropTypes.arrayOf(VisitType).isRequired, goBack: PropTypes.func.isRequired, + title: PropTypes.node.isRequired, + children: PropTypes.node, + shortUrl: shortUrlType, }; -export default function VisitsHeader({ shortUrlDetail, shortUrlVisits, goBack }) { - const { shortUrl, loading } = shortUrlDetail; - const { visits } = shortUrlVisits; - const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; - const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : ''; +const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => ( +
+ +

+ + + {title} + + + Visits:{' '} + + +

+

+ {title} +

- const renderDate = () => ( - - {shortUrl.dateCreated} - - {shortUrl.dateCreated} - - - ); - const visitsStatsTitle = ( - - Visit stats for - - ); - - return ( -
- -

- - - {visitsStatsTitle} - - - Visits:{' '} - - -

-

{visitsStatsTitle}

-
-
Created: {renderDate()}
-
- Long URL:{' '} - {loading && Loading...} - {!loading && } -
-
-
- ); -} + {children &&
{children}
} +
+
+); VisitsHeader.propTypes = propTypes; + +export default VisitsHeader; diff --git a/src/visits/VisitsHeader.scss b/src/visits/VisitsHeader.scss deleted file mode 100644 index 51dcc29f..00000000 --- a/src/visits/VisitsHeader.scss +++ /dev/null @@ -1,3 +0,0 @@ -.visits-header__created-at { - cursor: default; -} diff --git a/src/visits/VisitsStats.js b/src/visits/VisitsStats.js new file mode 100644 index 00000000..6b584b75 --- /dev/null +++ b/src/visits/VisitsStats.js @@ -0,0 +1,231 @@ +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 './SortableBarGraph'; +import GraphCard from './GraphCard'; +import VisitsTable from './VisitsTable'; +import { VisitsInfoType } from './types'; + +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 }, OpenMapModalBtn) => { + 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 [ 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([]); + selectedBar = undefined; + } else { + setHighlightedVisits(normalizedVisits.filter(propEq(prop, 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/reducers/common.js b/src/visits/reducers/common.js new file mode 100644 index 00000000..3b150dcc --- /dev/null +++ b/src/visits/reducers/common.js @@ -0,0 +1,60 @@ +import { flatten, prop, range, splitEvery } from 'ramda'; + +const ITEMS_PER_PAGE = 5000; +const PARALLEL_REQUESTS_COUNT = 4; +const PARALLEL_STARTING_PAGE = 2; + +const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; +const calcProgress = (total, current) => current * 100 / total; + +export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, actionMap, dispatch, getState) => { + dispatch({ type: actionMap.start }); + + const loadVisits = async (page = 1) => { + const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); + + // If pagination was not returned, then this is an old shlink version. Just return data + if (!pagination || isLastPage(pagination)) { + return data; + } + + // If there are more pages, make requests in blocks of 4 + const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); + const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); + + if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { + dispatch({ type: actionMap.large }); + } + + return data.concat(await loadPagesBlocks(pagesBlocks)); + }; + + const loadPagesBlocks = async (pagesBlocks, index = 0) => { + const { shortUrlVisits: { cancelLoad } } = getState(); + + if (cancelLoad) { + return []; + } + + const data = await loadVisitsInParallel(pagesBlocks[index]); + + dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) }); + + if (index < pagesBlocks.length - 1) { + return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); + } + + return data; + }; + + const loadVisitsInParallel = (pages) => + Promise.all(pages.map((page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); + + try { + const visits = await loadVisits(); + + dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); + } catch (e) { + dispatch({ type: actionMap.error }); + } +}; diff --git a/src/visits/reducers/shortUrlDetail.js b/src/visits/reducers/shortUrlDetail.js index 477464f5..7612b7ab 100644 --- a/src/visits/reducers/shortUrlDetail.js +++ b/src/visits/reducers/shortUrlDetail.js @@ -21,9 +21,9 @@ const initialState = { }; export default handleActions({ - [GET_SHORT_URL_DETAIL_START]: (state) => ({ ...state, loading: true }), - [GET_SHORT_URL_DETAIL_ERROR]: (state) => ({ ...state, loading: false, error: true }), - [GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }), + [GET_SHORT_URL_DETAIL_START]: () => ({ ...initialState, loading: true }), + [GET_SHORT_URL_DETAIL_ERROR]: () => ({ ...initialState, loading: false, error: true }), + [GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ ...initialState, shortUrl }), }, initialState); export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => { diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index f3be7e33..cbbdd081 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,7 +1,9 @@ import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; -import { flatten, prop, range, splitEvery } from 'ramda'; import { shortUrlMatches } from '../../short-urls/helpers'; +import { VisitType } from '../types'; +import { getVisitsWithLoader } from './common'; +import { CREATE_VISIT } from './visitCreation'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -9,31 +11,17 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; -export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT'; +export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED'; /* eslint-enable padding-line-between-statements */ -export const visitType = PropTypes.shape({ - referer: PropTypes.string, - date: PropTypes.string, - userAgent: PropTypes.string, - visitLocations: 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, - }), -}); - -export const shortUrlVisitsType = PropTypes.shape({ - visits: PropTypes.arrayOf(visitType), +export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType + visits: PropTypes.arrayOf(VisitType), shortCode: PropTypes.string, domain: PropTypes.string, loading: PropTypes.bool, + loadingLarge: PropTypes.bool, error: PropTypes.bool, + progress: PropTypes.number, }); const initialState = { @@ -44,34 +32,22 @@ const initialState = { loadingLarge: false, error: false, cancelLoad: false, + progress: 0, }; export default handleActions({ - [GET_SHORT_URL_VISITS_START]: (state) => ({ - ...state, - loading: true, - loadingLarge: false, - cancelLoad: false, - }), - [GET_SHORT_URL_VISITS_ERROR]: (state) => ({ - ...state, - loading: false, - loadingLarge: false, - error: true, - cancelLoad: false, - }), + [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_SHORT_URL_VISITS_ERROR]: () => ({ ...initialState, error: true }), [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({ + ...initialState, visits, shortCode, domain, - loading: false, - loadingLarge: false, - error: false, - cancelLoad: false, }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), - [CREATE_SHORT_URL_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand const { shortCode, domain, visits } = state; if (!shortUrlMatches(shortUrl, shortCode, domain)) { @@ -82,65 +58,19 @@ export default handleActions({ }, }, initialState); -export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => { - dispatch({ type: GET_SHORT_URL_VISITS_START }); +export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => (dispatch, getState) => { const { getShortUrlVisits } = buildShlinkApiClient(getState); - const itemsPerPage = 5000; - const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; - - const loadVisits = async (page = 1) => { - const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }); - - // If pagination was not returned, then this is an older shlink version. Just return data - if (!pagination || isLastPage(pagination)) { - return data; - } - - // If there are more pages, make requests in blocks of 4 - const parallelRequestsCount = 4; - const parallelStartingPage = 2; - const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1); - const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange); - - if (pagination.pagesCount - 1 > parallelRequestsCount) { - dispatch({ type: GET_SHORT_URL_VISITS_LARGE }); - } - - return data.concat(await loadPagesBlocks(pagesBlocks)); + const visitsLoader = (page, itemsPerPage) => getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }); + const extraFinishActionData = { shortCode, domain: query.domain }; + const actionMap = { + start: GET_SHORT_URL_VISITS_START, + large: GET_SHORT_URL_VISITS_LARGE, + finish: GET_SHORT_URL_VISITS, + error: GET_SHORT_URL_VISITS_ERROR, + progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, }; - const loadPagesBlocks = async (pagesBlocks, index = 0) => { - const { shortUrlVisits: { cancelLoad } } = getState(); - - if (cancelLoad) { - return []; - } - - const data = await loadVisitsInParallel(pagesBlocks[index]); - - if (index < pagesBlocks.length - 1) { - return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); - } - - return data; - }; - - const loadVisitsInParallel = (pages) => - Promise.all(pages.map( - (page) => - getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }) - .then(prop('data')) - )).then(flatten); - - try { - const visits = await loadVisits(); - - dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS }); - } catch (e) { - dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); - } + return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); }; export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL); - -export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT }); diff --git a/src/visits/reducers/tagVisits.js b/src/visits/reducers/tagVisits.js new file mode 100644 index 00000000..d149322b --- /dev/null +++ b/src/visits/reducers/tagVisits.js @@ -0,0 +1,68 @@ +import { createAction, handleActions } from 'redux-actions'; +import PropTypes from 'prop-types'; +import { VisitType } from '../types'; +import { getVisitsWithLoader } from './common'; +import { CREATE_VISIT } from './visitCreation'; + +/* eslint-disable padding-line-between-statements */ +export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; +export const GET_TAG_VISITS_ERROR = 'shlink/tagVisits/GET_TAG_VISITS_ERROR'; +export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS'; +export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE'; +export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; +export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED'; +/* eslint-enable padding-line-between-statements */ + +export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType + visits: PropTypes.arrayOf(VisitType), + tag: PropTypes.string, + loading: PropTypes.bool, + loadingLarge: PropTypes.bool, + error: PropTypes.bool, + progress: PropTypes.number, +}); + +const initialState = { + visits: [], + tag: '', + loading: false, + loadingLarge: false, + error: false, + cancelLoad: false, + progress: 0, +}; + +export default handleActions({ + [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_TAG_VISITS_ERROR]: () => ({ ...initialState, error: true }), + [GET_TAG_VISITS]: (state, { visits, tag }) => ({ ...initialState, visits, tag }), + [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), + [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand + const { tag, visits } = state; + + if (!shortUrl.tags.includes(tag)) { + return state; + } + + return { ...state, visits: [ ...visits, visit ] }; + }, +}, initialState); + +export const getTagVisits = (buildShlinkApiClient) => (tag, query = {}) => (dispatch, getState) => { + const { getTagVisits } = buildShlinkApiClient(getState); + const visitsLoader = (page, itemsPerPage) => getTagVisits(tag, { ...query, page, itemsPerPage }); + const extraFinishActionData = { tag }; + const actionMap = { + start: GET_TAG_VISITS_START, + large: GET_TAG_VISITS_LARGE, + finish: GET_TAG_VISITS, + error: GET_TAG_VISITS_ERROR, + progress: GET_TAG_VISITS_PROGRESS_CHANGED, + }; + + return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); +}; + +export const cancelGetTagVisits = createAction(GET_TAG_VISITS_CANCEL); diff --git a/src/visits/reducers/visitCreation.js b/src/visits/reducers/visitCreation.js new file mode 100644 index 00000000..e37890a1 --- /dev/null +++ b/src/visits/reducers/visitCreation.js @@ -0,0 +1,3 @@ +export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT'; + +export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT }); diff --git a/src/visits/services/provideServices.js b/src/visits/services/provideServices.js index 404bc4a1..f0701a05 100644 --- a/src/visits/services/provideServices.js +++ b/src/visits/services/provideServices.js @@ -1,19 +1,29 @@ import ShortUrlVisits from '../ShortUrlVisits'; -import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits'; +import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import OpenMapModalBtn from '../helpers/OpenMapModalBtn'; import MapModal from '../helpers/MapModal'; +import VisitsStats from '../VisitsStats'; +import { createNewVisit } from '../reducers/visitCreation'; +import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; +import TagVisits from '../TagVisits'; import * as visitsParser from './VisitsParser'; const provideServices = (bottle, connect) => { // Components bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal'); bottle.serviceFactory('MapModal', () => MapModal); - bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn'); + bottle.serviceFactory('VisitsStats', VisitsStats, 'VisitsParser', 'OpenMapModalBtn'); + bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsStats'); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ] )); + bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator'); + bottle.decorator('TagVisits', connect( + [ 'tagVisits', 'mercureInfo', 'settings' ], + [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ] + )); // Services bottle.serviceFactory('VisitsParser', () => visitsParser); @@ -22,6 +32,10 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); + + bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); + bottle.serviceFactory('createNewVisit', () => createNewVisit); }; diff --git a/src/visits/types/index.js b/src/visits/types/index.js new file mode 100644 index 00000000..bd6d1383 --- /dev/null +++ b/src/visits/types/index.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; + +export const VisitType = PropTypes.shape({ + referer: PropTypes.string, + date: PropTypes.string, + userAgent: PropTypes.string, + visitLocations: 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, + }), +}); + +export const VisitsInfoType = PropTypes.shape({ + visits: PropTypes.arrayOf(VisitType), + loading: PropTypes.bool, + loadingLarge: PropTypes.bool, + error: PropTypes.bool, + progress: PropTypes.number, +}); diff --git a/test/short-urls/reducers/shortUrlsList.test.js b/test/short-urls/reducers/shortUrlsList.test.js index c8e725c2..08bb0a82 100644 --- a/test/short-urls/reducers/shortUrlsList.test.js +++ b/test/short-urls/reducers/shortUrlsList.test.js @@ -7,7 +7,7 @@ import reducer, { import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags'; import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta'; -import { CREATE_SHORT_URL_VISIT } from '../../../src/visits/reducers/shortUrlVisits'; +import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlsListReducer', () => { describe('reducer', () => { @@ -103,7 +103,7 @@ describe('shortUrlsListReducer', () => { }); }); - it('updates visits count on CREATE_SHORT_URL_VISIT', () => { + it('updates visits count on CREATE_VISIT', () => { const shortCode = 'abc123'; const shortUrl = { shortCode, @@ -119,7 +119,7 @@ describe('shortUrlsListReducer', () => { }, }; - expect(reducer(state, { type: CREATE_SHORT_URL_VISIT, shortUrl })).toEqual({ + expect(reducer(state, { type: CREATE_VISIT, shortUrl })).toEqual({ shortUrls: { data: [ { shortCode, domain: 'example.com', visitsCount: 5 }, diff --git a/test/tags/TagCard.test.js b/test/tags/TagCard.test.js index 49fee1c8..6d806752 100644 --- a/test/tags/TagCard.test.js +++ b/test/tags/TagCard.test.js @@ -51,7 +51,7 @@ describe('', () => { expect(links.at(1).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr'); expect(links.at(1).text()).toContain('48'); - expect(links.at(2).prop('to')).toEqual('/server/1/tags/ssr/visits'); + expect(links.at(2).prop('to')).toEqual('/server/1/tag/ssr/visits'); expect(links.at(2).text()).toContain('23,257'); }); }); diff --git a/test/tags/TagsList.test.js b/test/tags/TagsList.test.js index c3bd3393..b4ebcafa 100644 --- a/test/tags/TagsList.test.js +++ b/test/tags/TagsList.test.js @@ -15,7 +15,7 @@ describe('', () => { const TagsList = createTagsList(TagCard); wrapper = shallow( - + ); return wrapper; diff --git a/test/tags/reducers/tagsList.test.js b/test/tags/reducers/tagsList.test.js index c312fea8..da86b564 100644 --- a/test/tags/reducers/tagsList.test.js +++ b/test/tags/reducers/tagsList.test.js @@ -11,17 +11,17 @@ import { TAG_EDITED } from '../../../src/tags/reducers/tagEdit'; describe('tagsListReducer', () => { describe('reducer', () => { it('returns loading on LIST_TAGS_START', () => { - expect(reducer({}, { type: LIST_TAGS_START })).toEqual({ + expect(reducer({}, { type: LIST_TAGS_START })).toEqual(expect.objectContaining({ loading: true, error: false, - }); + })); }); it('returns error on LIST_TAGS_ERROR', () => { - expect(reducer({}, { type: LIST_TAGS_ERROR })).toEqual({ + expect(reducer({}, { type: LIST_TAGS_ERROR })).toEqual(expect.objectContaining({ loading: false, error: true, - }); + })); }); it('returns provided tags as filtered and regular tags on LIST_TAGS', () => { diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.js index 07f93a66..c1889f5d 100644 --- a/test/utils/services/ShlinkApiClient.test.js +++ b/test/utils/services/ShlinkApiClient.test.js @@ -71,6 +71,28 @@ describe('ShlinkApiClient', () => { }); }); + describe('getTagVisits', () => { + it('properly returns tag visits', async () => { + const expectedVisits = [ 'foo', 'bar' ]; + const axiosSpy = jest.fn(createAxiosMock({ + data: { + visits: { + data: expectedVisits, + }, + }, + })); + const { getTagVisits } = new ShlinkApiClient(axiosSpy); + + const actualVisits = await getTagVisits('foo', {}); + + expect({ data: expectedVisits }).toEqual(actualVisits); + expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: '/tags/foo/visits', + method: 'GET', + })); + }); + }); + describe('getShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => { const expectedShortUrl = { foo: 'bar' }; diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 76cf0b28..615db013 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -1,18 +1,11 @@ import React from 'react'; import { shallow } from 'enzyme'; import { identity } from 'ramda'; -import { Card } from 'reactstrap'; import createShortUrlVisits from '../../src/visits/ShortUrlVisits'; -import Message from '../../src/utils/Message'; -import GraphCard from '../../src/visits/GraphCard'; -import SortableBarGraph from '../../src/visits/SortableBarGraph'; -import DateRangeRow from '../../src/utils/DateRangeRow'; +import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; describe('', () => { let wrapper; - const processStatsFromVisits = () => ( - { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } - ); const getShortUrlVisitsMock = jest.fn(); const match = { params: { shortCode: 'abc123' }, @@ -22,9 +15,10 @@ describe('', () => { goBack: jest.fn(), }; const realTimeUpdates = { enabled: true }; + const VisitsStats = jest.fn(); - const createComponent = (shortUrlVisits) => { - const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits, normalizeVisits: identity }, () => ''); + beforeEach(() => { + const ShortUrlVisits = createShortUrlVisits(VisitsStats); wrapper = shallow( ', () => { match={match} location={location} history={history} - shortUrlVisits={shortUrlVisits} + shortUrlVisits={{ loading: true, visits: [] }} shortUrlDetail={{}} cancelGetShortUrlVisits={identity} matchMedia={() => ({ matches: false })} settings={{ realTimeUpdates }} /> ); - - return wrapper; - }; - - afterEach(() => wrapper && wrapper.unmount()); - - it('renders a preloader when visits are loading', () => { - const wrapper = createComponent({ loading: true, visits: [] }); - const loadingMessage = wrapper.find(Message); - - expect(loadingMessage).toHaveLength(1); - expect(loadingMessage.html()).toContain('Loading...'); }); - it('renders a warning when loading large amounts of visits', () => { - const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] }); - const loadingMessage = wrapper.find(Message); + afterEach(() => wrapper.unmount()); + afterEach(jest.resetAllMocks); - expect(loadingMessage).toHaveLength(1); - expect(loadingMessage.html()).toContain('This is going to take a while... :S'); - }); + it('renders visit stats and visits header', () => { + const visitStats = wrapper.find(VisitsStats); + const visitHeader = wrapper.find(ShortUrlVisitsHeader); - it('renders an error message when visits could not be loaded', () => { - const wrapper = createComponent({ loading: false, error: true, visits: [] }); - const errorMessage = wrapper.find(Card); - - expect(errorMessage).toHaveLength(1); - expect(errorMessage.html()).toContain('An error occurred while loading visits :('); - }); - - it('renders a message when visits are loaded but the list is empty', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [] }); - const message = wrapper.find(Message); - - expect(message).toHaveLength(1); - expect(message.html()).toContain('There are no visits matching current filter :('); - }); - - it('renders all graphics when visits are properly loaded', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const graphs = wrapper.find(GraphCard); - const sortableBarGraphs = wrapper.find(SortableBarGraph); - - expect(graphs.length + sortableBarGraphs.length).toEqual(5); - }); - - it('reloads visits when selected dates change', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const dateRange = wrapper.find(DateRangeRow); - - dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); - dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00'); - dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00'); - - expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00'); - expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00'); - }); - - it('holds the map button content generator on cities graph extraHeaderContent', () => { - const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); - const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]'); - const extraHeaderContent = citiesGraph.prop('extraHeaderContent'); - - expect(extraHeaderContent).toHaveLength(1); - expect(typeof extraHeaderContent).toEqual('function'); + expect(visitStats).toHaveLength(1); + expect(visitHeader).toHaveLength(1); }); }); diff --git a/test/visits/ShortUrlVisitsHeader.test.js b/test/visits/ShortUrlVisitsHeader.test.js new file mode 100644 index 00000000..763efc57 --- /dev/null +++ b/test/visits/ShortUrlVisitsHeader.test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Moment from 'react-moment'; +import { ExternalLink } from 'react-external-link'; +import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader'; + +describe('', () => { + let wrapper; + const shortUrlDetail = { + shortUrl: { + shortUrl: 'https://doma.in/abc123', + longUrl: 'https://foo.bar/bar/foo', + dateCreated: '2018-01-01T10:00:00+01:00', + }, + loading: false, + }; + const shortUrlVisits = { + visits: [{}, {}, {}], + }; + const goBack = jest.fn(); + + beforeEach(() => { + wrapper = shallow( + + ); + }); + afterEach(() => wrapper.unmount()); + + it('shows when the URL was created', () => { + const moment = wrapper.find(Moment).first(); + + 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); + }); +}); diff --git a/test/visits/TagVisits.test.js b/test/visits/TagVisits.test.js new file mode 100644 index 00000000..8a871dae --- /dev/null +++ b/test/visits/TagVisits.test.js @@ -0,0 +1,44 @@ +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 realTimeUpdates = { enabled: true }; + 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/TagVisitsHeader.test.js b/test/visits/TagVisitsHeader.test.js new file mode 100644 index 00000000..d35d3b50 --- /dev/null +++ b/test/visits/TagVisitsHeader.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Tag from '../../src/tags/helpers/Tag'; +import TagVisitsHeader from '../../src/visits/TagVisitsHeader'; + +describe('', () => { + let wrapper; + const tagVisits = { + tag: 'foo', + visits: [{}, {}, {}], + }; + const goBack = jest.fn(); + + beforeEach(() => { + wrapper = shallow( + + ); + }); + afterEach(() => wrapper.unmount()); + + it('shows expected visits', () => { + expect(wrapper.prop('visits')).toEqual(tagVisits.visits); + }); + + it('shows title for tag', () => { + const title = shallow(wrapper.prop('title')); + const tag = title.find(Tag).first(); + + expect(tag.prop('text')).toEqual(tagVisits.tag); + + title.unmount(); + }); +}); diff --git a/test/visits/VisitsHeader.test.js b/test/visits/VisitsHeader.test.js index 8c151c4b..ade980e8 100644 --- a/test/visits/VisitsHeader.test.js +++ b/test/visits/VisitsHeader.test.js @@ -1,46 +1,35 @@ import React from 'react'; import { shallow } from 'enzyme'; -import Moment from 'react-moment'; -import { ExternalLink } from 'react-external-link'; import VisitsHeader from '../../src/visits/VisitsHeader'; describe('', () => { let wrapper; - const shortUrlDetail = { - shortUrl: { - shortUrl: 'https://doma.in/abc123', - longUrl: 'https://foo.bar/bar/foo', - dateCreated: '2018-01-01T10:00:00+01:00', - }, - loading: false, - }; - const shortUrlVisits = { - visits: [{}, {}, {}], - }; + const visits = [{}, {}, {}]; + const title = 'My header title'; const goBack = jest.fn(); beforeEach(() => { - wrapper = shallow(); + wrapper = shallow( + + ); }); + afterEach(() => wrapper.unmount()); + afterEach(jest.resetAllMocks); it('shows the amount of visits', () => { const visitsBadge = wrapper.find('.badge'); expect(visitsBadge.html()).toContain( - `Visits: ${shortUrlVisits.visits.length}` + `Visits: ${visits.length}` ); }); - it('shows when the URL was created', () => { - const moment = wrapper.find(Moment).first(); + it('shows the title in two places', () => { + const titles = wrapper.find('.text-center'); - 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(titles).toHaveLength(2); + expect(titles.at(0).html()).toContain(title); + expect(titles.at(1).html()).toContain(title); }); }); diff --git a/test/visits/VisitsStats.test.js b/test/visits/VisitsStats.test.js new file mode 100644 index 00000000..212127ad --- /dev/null +++ b/test/visits/VisitsStats.test.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { identity } from 'ramda'; +import { Card, Progress } from 'reactstrap'; +import createVisitStats from '../../src/visits/VisitsStats'; +import Message from '../../src/utils/Message'; +import GraphCard from '../../src/visits/GraphCard'; +import SortableBarGraph from '../../src/visits/SortableBarGraph'; +import DateRangeRow from '../../src/utils/DateRangeRow'; + +describe('', () => { + let wrapper; + const processStatsFromVisits = () => ( + { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } + ); + const getVisitsMock = jest.fn(); + + const createComponent = (visitsInfo) => { + const VisitStats = createVisitStats({ processStatsFromVisits, normalizeVisits: identity }, () => ''); + + wrapper = shallow( + ({ matches: false })} + /> + ); + + return wrapper; + }; + + afterEach(() => wrapper && wrapper.unmount()); + + it('renders a preloader when visits are loading', () => { + const wrapper = createComponent({ loading: true, visits: [] }); + const loadingMessage = wrapper.find(Message); + const progress = wrapper.find(Progress); + + expect(loadingMessage).toHaveLength(1); + expect(loadingMessage.html()).toContain('Loading...'); + expect(progress).toHaveLength(0); + }); + + it('renders a warning and progress bar when loading large amounts of visits', () => { + const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [], progress: 25 }); + const loadingMessage = wrapper.find(Message); + const progress = wrapper.find(Progress); + + expect(loadingMessage).toHaveLength(1); + expect(loadingMessage.html()).toContain('This is going to take a while... :S'); + expect(progress).toHaveLength(1); + expect(progress.prop('value')).toEqual(25); + }); + + it('renders an error message when visits could not be loaded', () => { + const wrapper = createComponent({ loading: false, error: true, visits: [] }); + const errorMessage = wrapper.find(Card); + + expect(errorMessage).toHaveLength(1); + expect(errorMessage.html()).toContain('An error occurred while loading visits :('); + }); + + it('renders a message when visits are loaded but the list is empty', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [] }); + const message = wrapper.find(Message); + + expect(message).toHaveLength(1); + expect(message.html()).toContain('There are no visits matching current filter :('); + }); + + it('renders all graphics when visits are properly loaded', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const graphs = wrapper.find(GraphCard); + const sortableBarGraphs = wrapper.find(SortableBarGraph); + + expect(graphs.length + sortableBarGraphs.length).toEqual(5); + }); + + it('reloads visits when selected dates change', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const dateRange = wrapper.find(DateRangeRow); + + dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00'); + dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00'); + dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00'); + + expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00'); + expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00'); + }); + + it('holds the map button content generator on cities graph extraHeaderContent', () => { + const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] }); + const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]'); + const extraHeaderContent = citiesGraph.prop('extraHeaderContent'); + + expect(extraHeaderContent).toHaveLength(1); + expect(typeof extraHeaderContent).toEqual('function'); + }); +}); diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 5e2544f9..0c295a12 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -1,14 +1,14 @@ import reducer, { getShortUrlVisits, cancelGetShortUrlVisits, - createNewVisit, GET_SHORT_URL_VISITS_START, GET_SHORT_URL_VISITS_ERROR, GET_SHORT_URL_VISITS, GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_CANCEL, - CREATE_SHORT_URL_VISIT, + GET_SHORT_URL_VISITS_PROGRESS_CHANGED, } from '../../../src/visits/reducers/shortUrlVisits'; +import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlVisitsReducer', () => { describe('reducer', () => { @@ -54,7 +54,7 @@ describe('shortUrlVisitsReducer', () => { it.each([ [{ shortCode: 'abc123' }, [{}, {}, {}]], [{ shortCode: 'def456' }, [{}, {}]], - ])('appends a new visit on CREATE_SHORT_URL_VISIT', (state, expectedVisits) => { + ])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => { const shortUrl = { shortCode: 'abc123', }; @@ -63,10 +63,16 @@ describe('shortUrlVisitsReducer', () => { visits: [{}, {}], }; - const { visits } = reducer(prevState, { type: CREATE_SHORT_URL_VISIT, shortUrl, visit: {} }); + const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} }); expect(visits).toEqual(expectedVisits); }); + + it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { + const state = reducer({}, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: 85 }); + + expect(state).toEqual({ progress: 85 }); + }); }); describe('getShortUrlVisits', () => { @@ -128,7 +134,7 @@ describe('shortUrlVisitsReducer', () => { await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); - expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ visits: [{}, {}, {}, {}, {}, {}], })); }); @@ -138,11 +144,4 @@ describe('shortUrlVisitsReducer', () => { it('just returns the action with proper type', () => expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL })); }); - - describe('createNewVisit', () => { - it('just returns the action with proper type', () => - expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual( - { type: CREATE_SHORT_URL_VISIT, shortUrl: {}, visit: {} } - )); - }); }); diff --git a/test/visits/reducers/tagVisits.test.js b/test/visits/reducers/tagVisits.test.js new file mode 100644 index 00000000..b39444eb --- /dev/null +++ b/test/visits/reducers/tagVisits.test.js @@ -0,0 +1,127 @@ +import reducer, { + getTagVisits, + cancelGetTagVisits, + GET_TAG_VISITS_START, + GET_TAG_VISITS_ERROR, + GET_TAG_VISITS, + GET_TAG_VISITS_LARGE, + GET_TAG_VISITS_CANCEL, + GET_TAG_VISITS_PROGRESS_CHANGED, +} from '../../../src/visits/reducers/tagVisits'; +import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; + +describe('tagVisitsReducer', () => { + describe('reducer', () => { + it('returns loading on GET_TAG_VISITS_START', () => { + const state = reducer({ loading: false }, { type: GET_TAG_VISITS_START }); + const { loading } = state; + + expect(loading).toEqual(true); + }); + + it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => { + const state = reducer({ loadingLarge: false }, { type: GET_TAG_VISITS_LARGE }); + const { loadingLarge } = state; + + expect(loadingLarge).toEqual(true); + }); + + it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => { + const state = reducer({ cancelLoad: false }, { type: GET_TAG_VISITS_CANCEL }); + const { cancelLoad } = state; + + expect(cancelLoad).toEqual(true); + }); + + it('stops loading and returns error on GET_TAG_VISITS_ERROR', () => { + const state = reducer({ loading: true, error: false }, { type: GET_TAG_VISITS_ERROR }); + const { loading, error } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(true); + }); + + it('return visits on GET_TAG_VISITS', () => { + const actionVisits = [{}, {}]; + const state = reducer({ loading: true, error: false }, { type: GET_TAG_VISITS, visits: actionVisits }); + const { loading, error, visits } = state; + + expect(loading).toEqual(false); + expect(error).toEqual(false); + expect(visits).toEqual(actionVisits); + }); + + it.each([ + [{ tag: 'foo' }, [{}, {}, {}]], + [{ tag: 'bar' }, [{}, {}]], + ])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => { + const shortUrl = { + tags: [ 'foo', 'baz' ], + }; + const prevState = { + ...state, + visits: [{}, {}], + }; + + const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} }); + + expect(visits).toEqual(expectedVisits); + }); + + it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { + const state = reducer({}, { type: GET_TAG_VISITS_PROGRESS_CHANGED, progress: 85 }); + + expect(state).toEqual({ progress: 85 }); + }); + }); + + describe('getTagVisits', () => { + const buildApiClientMock = (returned) => ({ + getTagVisits: jest.fn(typeof returned === 'function' ? returned : () => returned), + }); + const dispatchMock = jest.fn(); + const getState = () => ({ + tagVisits: { cancelVisits: false }, + }); + + beforeEach(jest.resetAllMocks); + + it('dispatches start and error when promise is rejected', async () => { + const ShlinkApiClient = buildApiClientMock(Promise.reject()); + + await getTagVisits(() => ShlinkApiClient)('foo')(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS_ERROR }); + expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ undefined ], + [{}], + ])('dispatches start and success when promise is resolved', async (query) => { + const visits = [{}, {}]; + const tag = 'foo'; + const ShlinkApiClient = buildApiClientMock(Promise.resolve({ + data: visits, + pagination: { + currentPage: 1, + pagesCount: 1, + }, + })); + + await getTagVisits(() => ShlinkApiClient)(tag, query)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag }); + expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + }); + }); + + describe('cancelGetTagVisits', () => { + it('just returns the action with proper type', () => + expect(cancelGetTagVisits()).toEqual({ type: GET_TAG_VISITS_CANCEL })); + }); +}); diff --git a/test/visits/reducers/visitCreation.test.js b/test/visits/reducers/visitCreation.test.js new file mode 100644 index 00000000..e010255e --- /dev/null +++ b/test/visits/reducers/visitCreation.test.js @@ -0,0 +1,10 @@ +import { CREATE_VISIT, createNewVisit } from '../../../src/visits/reducers/visitCreation'; + +describe('visitCreationReducer', () => { + describe('createNewVisit', () => { + it('just returns the action with proper type', () => + expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual( + { type: CREATE_VISIT, shortUrl: {}, visit: {} } + )); + }); +});