mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-10 17:43:51 +00:00
Merge pull request #268 from acelaya-forks/feature/tag-visits
Feature/tag visits
This commit is contained in:
@@ -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 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.
|
* 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.
|
* [#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.
|
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
|
#### Changed
|
||||||
|
|
||||||
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as PropTypes from 'prop-types';
|
|||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { versionMatch } from '../utils/helpers/version';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import './MenuLayout.scss';
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
@@ -17,7 +18,16 @@ const propTypes = {
|
|||||||
selectedServer: serverType,
|
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 MenuLayoutComp = ({ match, location, selectedServer }) => {
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
const { params: { serverId } } = match;
|
const { params: { serverId } } = match;
|
||||||
@@ -28,6 +38,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
|
|||||||
return <ServerError type="not-reachable" />;
|
return <ServerError type="not-reachable" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||||
'menu-layout__burger-icon--active': sidebarVisible,
|
'menu-layout__burger-icon--active': sidebarVisible,
|
||||||
});
|
});
|
||||||
@@ -61,6 +72,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
|
|||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
|
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => {
|
|||||||
'AsideMenu',
|
'AsideMenu',
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
|
'TagVisits',
|
||||||
'ShlinkVersions',
|
'ShlinkVersions',
|
||||||
'ServerError'
|
'ServerError'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,3 +61,7 @@ body,
|
|||||||
background-color: darken($mainColor, 12%);
|
background-color: darken($mainColor, 12%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background-color: $mainColor;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
|||||||
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
|
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
@@ -27,6 +28,7 @@ export default combineReducers({
|
|||||||
shortUrlMeta: shortUrlMetaReducer,
|
shortUrlMeta: shortUrlMetaReducer,
|
||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
|
tagVisits: tagVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { handleActions } from 'redux-actions';
|
import { handleActions } from 'redux-actions';
|
||||||
import { assoc, assocPath, reject } from 'ramda';
|
import { assoc, assocPath, reject } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits';
|
|
||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
|
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
|
||||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
||||||
@@ -50,7 +50,7 @@ export default handleActions({
|
|||||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
||||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
||||||
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
|
[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' ],
|
[ 'shortUrls', 'data' ],
|
||||||
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
|
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
|
||||||
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
|
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGen
|
|||||||
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/server/${id}/tags/${tag}/visits`}
|
to={`/server/${id}/tag/${tag}/visits`}
|
||||||
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
|
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
|
||||||
>
|
>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
|
|||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { serverType } from '../servers/prop-types';
|
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';
|
import { TagsListType } from './reducers/tagsList';
|
||||||
|
|
||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
@@ -14,15 +17,26 @@ const propTypes = {
|
|||||||
forceListTags: PropTypes.func,
|
forceListTags: PropTypes.func,
|
||||||
tagsList: TagsListType,
|
tagsList: TagsListType,
|
||||||
selectedServer: serverType,
|
selectedServer: serverType,
|
||||||
|
createNewVisit: PropTypes.func,
|
||||||
|
loadMercureInfo: PropTypes.func,
|
||||||
|
mercureInfo: MercureInfoType,
|
||||||
|
settings: SettingsType,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TagsList = (TagCard) => {
|
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();
|
const [ displayedTag, setDisplayedTag ] = useState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
forceListTags();
|
forceListTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(
|
||||||
|
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||||
|
[ mercureInfo ]
|
||||||
|
);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (tagsList.loading) {
|
if (tagsList.loading) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './Tag.scss';
|
|
||||||
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||||
|
import './Tag.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
text: PropTypes.string,
|
text: PropTypes.string,
|
||||||
@@ -17,12 +17,12 @@ const Tag = ({
|
|||||||
children,
|
children,
|
||||||
clearable,
|
clearable,
|
||||||
colorGenerator,
|
colorGenerator,
|
||||||
onClick = () => {},
|
onClick,
|
||||||
onClose = () => {},
|
onClose,
|
||||||
}) => (
|
}) => (
|
||||||
<span
|
<span
|
||||||
className="badge tag"
|
className="badge tag"
|
||||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
|
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.tag {
|
.tag {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag:not(:last-child) {
|
.tag:not(:last-child) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { handleActions } from 'redux-actions';
|
import { handleActions } from 'redux-actions';
|
||||||
import { isEmpty, reject } from 'ramda';
|
import { isEmpty, reject } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
|
||||||
import { TAG_DELETED } from './tagDelete';
|
import { TAG_DELETED } from './tagDelete';
|
||||||
import { TAG_EDITED } from './tagEdit';
|
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';
|
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
const TagStatsType = PropTypes.shape({
|
||||||
|
shortUrlsCount: PropTypes.number,
|
||||||
|
visitsCount: PropTypes.number,
|
||||||
|
});
|
||||||
|
|
||||||
export const TagsListType = PropTypes.shape({
|
export const TagsListType = PropTypes.shape({
|
||||||
tags: PropTypes.arrayOf(PropTypes.string),
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
||||||
stats: PropTypes.object, // Record
|
stats: PropTypes.objectOf(TagStatsType), // Record
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
error: PropTypes.bool,
|
error: PropTypes.bool,
|
||||||
});
|
});
|
||||||
@@ -29,11 +35,23 @@ const initialState = {
|
|||||||
|
|
||||||
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
|
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
|
||||||
const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags);
|
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({
|
export default handleActions({
|
||||||
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
|
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
[LIST_TAGS_ERROR]: () => ({ ...initialState, error: true }),
|
||||||
[LIST_TAGS]: (state, { tags, stats }) => ({ stats, tags, filteredTags: tags, loading: false, error: false }),
|
[LIST_TAGS]: (state, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||||
[TAG_DELETED]: (state, { tag }) => ({
|
[TAG_DELETED]: (state, { tag }) => ({
|
||||||
...state,
|
...state,
|
||||||
tags: rejectTag(state.tags, tag),
|
tags: rejectTag(state.tags, tag),
|
||||||
@@ -48,6 +66,10 @@ export default handleActions({
|
|||||||
...state,
|
...state,
|
||||||
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
|
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
|
||||||
}),
|
}),
|
||||||
|
[CREATE_VISIT]: (state, { shortUrl }) => ({
|
||||||
|
...state,
|
||||||
|
stats: increaseVisitsForTags(shortUrl.tags, state.stats),
|
||||||
|
}),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => {
|
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) => ({
|
export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm });
|
||||||
type: FILTER_TAGS,
|
|
||||||
searchTerm,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ const provideServices = (bottle, connect) => {
|
|||||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
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
|
// Actions
|
||||||
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);
|
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export default class ShlinkApiClient {
|
|||||||
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
|
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
|
||||||
.then((resp) => resp.data.visits);
|
.then((resp) => resp.data.visits);
|
||||||
|
|
||||||
|
getTagVisits = (tag, query) =>
|
||||||
|
this._performRequest(`/tags/${tag}/visits`, 'GET', query)
|
||||||
|
.then((resp) => resp.data.visits);
|
||||||
|
|
||||||
getShortUrl = (shortCode, domain) =>
|
getShortUrl = (shortCode, domain) =>
|
||||||
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
|
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||||
.then((resp) => resp.data);
|
.then((resp) => resp.data);
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import { isEmpty, propEq, values } from 'ramda';
|
import React, { useEffect } from 'react';
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { Button, Card, Collapse } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import qs from 'qs';
|
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 { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||||
import { bindToMercureTopic } from '../mercure/helpers';
|
import { bindToMercureTopic } from '../mercure/helpers';
|
||||||
import { SettingsType } from '../settings/reducers/settings';
|
import { SettingsType } from '../settings/reducers/settings';
|
||||||
import SortableBarGraph from './SortableBarGraph';
|
|
||||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||||
import VisitsHeader from './VisitsHeader';
|
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
|
||||||
import GraphCard from './GraphCard';
|
|
||||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||||
import VisitsTable from './VisitsTable';
|
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
history: PropTypes.shape({
|
history: PropTypes.shape({
|
||||||
@@ -35,26 +23,13 @@ const propTypes = {
|
|||||||
getShortUrlDetail: PropTypes.func,
|
getShortUrlDetail: PropTypes.func,
|
||||||
shortUrlDetail: shortUrlDetailType,
|
shortUrlDetail: shortUrlDetailType,
|
||||||
cancelGetShortUrlVisits: PropTypes.func,
|
cancelGetShortUrlVisits: PropTypes.func,
|
||||||
matchMedia: PropTypes.func,
|
|
||||||
createNewVisit: PropTypes.func,
|
createNewVisit: PropTypes.func,
|
||||||
loadMercureInfo: PropTypes.func,
|
loadMercureInfo: PropTypes.func,
|
||||||
mercureInfo: MercureInfoType,
|
mercureInfo: MercureInfoType,
|
||||||
settings: SettingsType,
|
settings: SettingsType,
|
||||||
};
|
};
|
||||||
|
|
||||||
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
|
const ShortUrlVisits = (VisitsStats) => {
|
||||||
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 ShortUrlVisitsComp = ({
|
const ShortUrlVisitsComp = ({
|
||||||
history,
|
history,
|
||||||
match,
|
match,
|
||||||
@@ -64,65 +39,21 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
|
|||||||
getShortUrlVisits,
|
getShortUrlVisits,
|
||||||
getShortUrlDetail,
|
getShortUrlDetail,
|
||||||
cancelGetShortUrlVisits,
|
cancelGetShortUrlVisits,
|
||||||
matchMedia = window.matchMedia,
|
|
||||||
createNewVisit,
|
createNewVisit,
|
||||||
loadMercureInfo,
|
loadMercureInfo,
|
||||||
mercureInfo,
|
mercureInfo,
|
||||||
settings: { realTimeUpdates },
|
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 { params } = match;
|
||||||
const { shortCode } = params;
|
const { shortCode } = params;
|
||||||
const { search } = location;
|
const { search } = location;
|
||||||
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
|
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
|
||||||
|
|
||||||
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
const loadVisits = (dates) => getShortUrlVisits(shortCode, { ...dates, domain });
|
||||||
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 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getShortUrlDetail(shortCode, domain);
|
getShortUrlDetail(shortCode, domain);
|
||||||
determineIsMobileDevice();
|
|
||||||
window.addEventListener('resize', determineIsMobileDevice);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelGetShortUrlVisits();
|
|
||||||
window.removeEventListener('resize', determineIsMobileDevice);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
|
||||||
loadVisits();
|
|
||||||
}, [ startDate, endDate ]);
|
|
||||||
useEffect(
|
useEffect(
|
||||||
bindToMercureTopic(
|
bindToMercureTopic(
|
||||||
mercureInfo,
|
mercureInfo,
|
||||||
@@ -134,138 +65,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
|
|||||||
[ mercureInfo ],
|
[ mercureInfo ],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderVisitsContent = () => {
|
|
||||||
if (loading) {
|
|
||||||
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
|
|
||||||
|
|
||||||
return <Message loading>{message}</Message>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="mt-4" body inverse color="danger">
|
|
||||||
An error occurred while loading visits :(
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmpty(visits)) {
|
|
||||||
return <Message>There are no visits matching current filter :(</Message>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-xl-4 col-lg-6">
|
|
||||||
<GraphCard title="Operating systems" stats={os} />
|
|
||||||
</div>
|
|
||||||
<div className="col-xl-4 col-lg-6">
|
|
||||||
<GraphCard title="Browsers" stats={browsers} />
|
|
||||||
</div>
|
|
||||||
<div className="col-xl-4">
|
|
||||||
<SortableBarGraph
|
|
||||||
title="Referrers"
|
|
||||||
stats={referrers}
|
|
||||||
withPagination={false}
|
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
|
|
||||||
sortingItems={{
|
|
||||||
name: 'Referrer name',
|
|
||||||
amount: 'Visits amount',
|
|
||||||
}}
|
|
||||||
onClick={highlightVisitsForProp('referer')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-6">
|
|
||||||
<SortableBarGraph
|
|
||||||
title="Countries"
|
|
||||||
stats={countries}
|
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
|
||||||
sortingItems={{
|
|
||||||
name: 'Country name',
|
|
||||||
amount: 'Visits amount',
|
|
||||||
}}
|
|
||||||
onClick={highlightVisitsForProp('country')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-6">
|
|
||||||
<SortableBarGraph
|
|
||||||
title="Cities"
|
|
||||||
stats={cities}
|
|
||||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
|
||||||
extraHeaderContent={(activeCities) =>
|
|
||||||
mapLocations.length > 0 &&
|
|
||||||
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
|
||||||
}
|
|
||||||
sortingItems={{
|
|
||||||
name: 'City name',
|
|
||||||
amount: 'Visits amount',
|
|
||||||
}}
|
|
||||||
onClick={highlightVisitsForProp('city')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
|
||||||
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={history.goBack} />
|
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={history.goBack} />
|
||||||
|
</VisitsStats>
|
||||||
<section className="mt-4">
|
|
||||||
<div className="row flex-md-row-reverse">
|
|
||||||
<div className="col-lg-7 col-xl-6">
|
|
||||||
<DateRangeRow
|
|
||||||
disabled={loading}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
onStartDateChange={setStartDate}
|
|
||||||
onEndDateChange={setEndDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
|
||||||
{showTableControls && (
|
|
||||||
<span className={classNames({ row: isMobileDevice })}>
|
|
||||||
<span className={classNames({ 'col-6': isMobileDevice })}>
|
|
||||||
<Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
|
|
||||||
{showTable ? 'Hide' : 'Show'} table
|
|
||||||
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
<span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
|
|
||||||
<Button
|
|
||||||
outline
|
|
||||||
disabled={highlightedVisits.length === 0}
|
|
||||||
block={isMobileDevice}
|
|
||||||
onClick={() => setSelectedVisits([])}
|
|
||||||
>
|
|
||||||
Reset selection
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{showTableControls && (
|
|
||||||
<Collapse
|
|
||||||
isOpen={showTable}
|
|
||||||
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
|
|
||||||
onEntered={setSticky}
|
|
||||||
onExiting={unsetSticky}
|
|
||||||
>
|
|
||||||
<VisitsTable
|
|
||||||
visits={normalizedVisits}
|
|
||||||
selectedVisits={highlightedVisits}
|
|
||||||
setSelectedVisits={setSelectedVisits}
|
|
||||||
isSticky={tableIsSticky}
|
|
||||||
/>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
{renderVisitsContent()}
|
|
||||||
</section>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
54
src/visits/ShortUrlVisitsHeader.js
Normal file
54
src/visits/ShortUrlVisitsHeader.js
Normal file
@@ -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 = () => (
|
||||||
|
<span>
|
||||||
|
<b id="created" className="short-url-visits-header__created-at">
|
||||||
|
<Moment fromNow>{shortUrl.dateCreated}</Moment>
|
||||||
|
</b>
|
||||||
|
<UncontrolledTooltip placement="bottom" target="created">
|
||||||
|
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const visitsStatsTitle = (
|
||||||
|
<React.Fragment>
|
||||||
|
Visits for <ExternalLink href={shortLink} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>
|
||||||
|
<hr />
|
||||||
|
<div>Created: {renderDate()}</div>
|
||||||
|
<div>
|
||||||
|
Long URL:{' '}
|
||||||
|
{loading && <small>Loading...</small>}
|
||||||
|
{!loading && <ExternalLink href={longLink} />}
|
||||||
|
</div>
|
||||||
|
</VisitsHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ShortUrlVisitsHeader.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ShortUrlVisitsHeader;
|
||||||
3
src/visits/ShortUrlVisitsHeader.scss
Normal file
3
src/visits/ShortUrlVisitsHeader.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.short-url-visits-header__created-at {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default class SortableBarGraph extends React.Component {
|
|||||||
orderField: undefined,
|
orderField: undefined,
|
||||||
orderDir: undefined,
|
orderDir: undefined,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
itemsPerPage: Infinity,
|
itemsPerPage: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
getSortedPairsForStats(stats, sortingItems) {
|
getSortedPairsForStats(stats, sortingItems) {
|
||||||
|
|||||||
64
src/visits/TagVisits.js
Normal file
64
src/visits/TagVisits.js
Normal file
@@ -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 (
|
||||||
|
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits}>
|
||||||
|
<TagVisitsHeader tagVisits={tagVisits} goBack={history.goBack} colorGenerator={colorGenerator} />
|
||||||
|
</VisitsStats>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TagVisitsComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return TagVisitsComp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagVisits;
|
||||||
30
src/visits/TagVisitsHeader.js
Normal file
30
src/visits/TagVisitsHeader.js
Normal file
@@ -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 = (
|
||||||
|
<span className="d-flex align-items-center justify-content-center">
|
||||||
|
<span className="mr-2">Visits for</span>
|
||||||
|
<Tag text={tag} colorGenerator={colorGenerator} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
TagVisitsHeader.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default TagVisitsHeader;
|
||||||
@@ -1,67 +1,44 @@
|
|||||||
import { Button, Card, UncontrolledTooltip } from 'reactstrap';
|
import { Button, Card } from 'reactstrap';
|
||||||
import Moment from 'react-moment';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { ExternalLink } from 'react-external-link';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
|
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
|
||||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
import { shortUrlType } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
import { VisitType } from './types';
|
||||||
import './VisitsHeader.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
shortUrlDetail: shortUrlDetailType.isRequired,
|
visits: PropTypes.arrayOf(VisitType).isRequired,
|
||||||
shortUrlVisits: shortUrlVisitsType.isRequired,
|
|
||||||
goBack: PropTypes.func.isRequired,
|
goBack: PropTypes.func.isRequired,
|
||||||
|
title: PropTypes.node.isRequired,
|
||||||
|
children: PropTypes.node,
|
||||||
|
shortUrl: shortUrlType,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VisitsHeader({ shortUrlDetail, shortUrlVisits, goBack }) {
|
const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => (
|
||||||
const { shortUrl, loading } = shortUrlDetail;
|
<header>
|
||||||
const { visits } = shortUrlVisits;
|
<Card className="bg-light" body>
|
||||||
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
<h2 className="d-flex justify-content-between align-items-center mb-0">
|
||||||
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
|
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} />
|
||||||
|
</Button>
|
||||||
|
<span className="text-center d-none d-sm-block">
|
||||||
|
<small>{title}</small>
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-main ml-3">
|
||||||
|
Visits:{' '}
|
||||||
|
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-center d-block d-sm-none mb-0 mt-3">
|
||||||
|
<small>{title}</small>
|
||||||
|
</h3>
|
||||||
|
|
||||||
const renderDate = () => (
|
{children && <div className="mt-md-2">{children}</div>}
|
||||||
<span>
|
</Card>
|
||||||
<b id="created" className="visits-header__created-at"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
|
</header>
|
||||||
<UncontrolledTooltip placement="bottom" target="created">
|
);
|
||||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
|
||||||
</UncontrolledTooltip>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
const visitsStatsTitle = (
|
|
||||||
<React.Fragment>
|
|
||||||
Visit stats for <ExternalLink href={shortLink} />
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header>
|
|
||||||
<Card className="bg-light" body>
|
|
||||||
<h2 className="d-flex justify-content-between align-items-center">
|
|
||||||
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
|
|
||||||
<FontAwesomeIcon icon={faArrowLeft} />
|
|
||||||
</Button>
|
|
||||||
<span className="text-center d-none d-sm-block">
|
|
||||||
{visitsStatsTitle}
|
|
||||||
</span>
|
|
||||||
<span className="badge badge-main ml-3">
|
|
||||||
Visits:{' '}
|
|
||||||
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<h3 className="text-center d-block d-sm-none mb-0">{visitsStatsTitle}</h3>
|
|
||||||
<hr />
|
|
||||||
<div>Created: {renderDate()}</div>
|
|
||||||
<div>
|
|
||||||
Long URL:{' '}
|
|
||||||
{loading && <small>Loading...</small>}
|
|
||||||
{!loading && <ExternalLink href={longLink} />}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
VisitsHeader.propTypes = propTypes;
|
VisitsHeader.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default VisitsHeader;
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
.visits-header__created-at {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
231
src/visits/VisitsStats.js
Normal file
231
src/visits/VisitsStats.js
Normal file
@@ -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 (
|
||||||
|
<Message loading>
|
||||||
|
This is going to take a while... :S
|
||||||
|
<Progress value={progress} striped={progress === 100} className="mt-3" />
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Message loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="mt-4" body inverse color="danger">
|
||||||
|
An error occurred while loading visits :(
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(visits)) {
|
||||||
|
return <Message>There are no visits matching current filter :(</Message>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-xl-4 col-lg-6">
|
||||||
|
<GraphCard title="Operating systems" stats={os} />
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-4 col-lg-6">
|
||||||
|
<GraphCard title="Browsers" stats={browsers} />
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-4">
|
||||||
|
<SortableBarGraph
|
||||||
|
title="Referrers"
|
||||||
|
stats={referrers}
|
||||||
|
withPagination={false}
|
||||||
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
|
||||||
|
sortingItems={{
|
||||||
|
name: 'Referrer name',
|
||||||
|
amount: 'Visits amount',
|
||||||
|
}}
|
||||||
|
onClick={highlightVisitsForProp('referer')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<SortableBarGraph
|
||||||
|
title="Countries"
|
||||||
|
stats={countries}
|
||||||
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
||||||
|
sortingItems={{
|
||||||
|
name: 'Country name',
|
||||||
|
amount: 'Visits amount',
|
||||||
|
}}
|
||||||
|
onClick={highlightVisitsForProp('country')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<SortableBarGraph
|
||||||
|
title="Cities"
|
||||||
|
stats={cities}
|
||||||
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||||
|
extraHeaderContent={(activeCities) =>
|
||||||
|
mapLocations.length > 0 &&
|
||||||
|
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
|
||||||
|
}
|
||||||
|
sortingItems={{
|
||||||
|
name: 'City name',
|
||||||
|
amount: 'Visits amount',
|
||||||
|
}}
|
||||||
|
onClick={highlightVisitsForProp('city')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<section className="mt-4">
|
||||||
|
<div className="row flex-md-row-reverse">
|
||||||
|
<div className="col-lg-7 col-xl-6">
|
||||||
|
<DateRangeRow
|
||||||
|
disabled={loading}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
||||||
|
{showTableControls && (
|
||||||
|
<span className={classNames({ row: isMobileDevice })}>
|
||||||
|
<span className={classNames({ 'col-6': isMobileDevice })}>
|
||||||
|
<Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
|
||||||
|
{showTable ? 'Hide' : 'Show'} table
|
||||||
|
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
<span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
|
||||||
|
<Button
|
||||||
|
outline
|
||||||
|
disabled={highlightedVisits.length === 0}
|
||||||
|
block={isMobileDevice}
|
||||||
|
onClick={() => setSelectedVisits([])}
|
||||||
|
>
|
||||||
|
Reset selection
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{showTableControls && (
|
||||||
|
<Collapse
|
||||||
|
isOpen={showTable}
|
||||||
|
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
|
||||||
|
onEntered={setSticky}
|
||||||
|
onExiting={unsetSticky}
|
||||||
|
>
|
||||||
|
<VisitsTable
|
||||||
|
visits={normalizedVisits}
|
||||||
|
selectedVisits={highlightedVisits}
|
||||||
|
setSelectedVisits={setSelectedVisits}
|
||||||
|
isSticky={tableIsSticky}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{renderVisitsContent()}
|
||||||
|
</section>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
VisitsStatsComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return VisitsStatsComp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VisitsStats;
|
||||||
60
src/visits/reducers/common.js
Normal file
60
src/visits/reducers/common.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -21,9 +21,9 @@ const initialState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default handleActions({
|
export default handleActions({
|
||||||
[GET_SHORT_URL_DETAIL_START]: (state) => ({ ...state, loading: true }),
|
[GET_SHORT_URL_DETAIL_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_SHORT_URL_DETAIL_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
[GET_SHORT_URL_DETAIL_ERROR]: () => ({ ...initialState, loading: false, error: true }),
|
||||||
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
|
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ ...initialState, shortUrl }),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createAction, handleActions } from 'redux-actions';
|
import { createAction, handleActions } from 'redux-actions';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
|
||||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
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 */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
|
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 = '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_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
|
||||||
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
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 */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export const visitType = PropTypes.shape({
|
export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType
|
||||||
referer: PropTypes.string,
|
visits: PropTypes.arrayOf(VisitType),
|
||||||
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),
|
|
||||||
shortCode: PropTypes.string,
|
shortCode: PropTypes.string,
|
||||||
domain: PropTypes.string,
|
domain: PropTypes.string,
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
|
loadingLarge: PropTypes.bool,
|
||||||
error: PropTypes.bool,
|
error: PropTypes.bool,
|
||||||
|
progress: PropTypes.number,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -44,34 +32,22 @@ const initialState = {
|
|||||||
loadingLarge: false,
|
loadingLarge: false,
|
||||||
error: false,
|
error: false,
|
||||||
cancelLoad: false,
|
cancelLoad: false,
|
||||||
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default handleActions({
|
export default handleActions({
|
||||||
[GET_SHORT_URL_VISITS_START]: (state) => ({
|
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
...state,
|
[GET_SHORT_URL_VISITS_ERROR]: () => ({ ...initialState, error: true }),
|
||||||
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]: (state, { visits, shortCode, domain }) => ({
|
[GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({
|
||||||
|
...initialState,
|
||||||
visits,
|
visits,
|
||||||
shortCode,
|
shortCode,
|
||||||
domain,
|
domain,
|
||||||
loading: false,
|
|
||||||
loadingLarge: false,
|
|
||||||
error: false,
|
|
||||||
cancelLoad: false,
|
|
||||||
}),
|
}),
|
||||||
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: 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;
|
const { shortCode, domain, visits } = state;
|
||||||
|
|
||||||
if (!shortUrlMatches(shortUrl, shortCode, domain)) {
|
if (!shortUrlMatches(shortUrl, shortCode, domain)) {
|
||||||
@@ -82,65 +58,19 @@ export default handleActions({
|
|||||||
},
|
},
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => {
|
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => (dispatch, getState) => {
|
||||||
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
|
||||||
const { getShortUrlVisits } = buildShlinkApiClient(getState);
|
const { getShortUrlVisits } = buildShlinkApiClient(getState);
|
||||||
const itemsPerPage = 5000;
|
const visitsLoader = (page, itemsPerPage) => getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
|
||||||
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
|
const extraFinishActionData = { shortCode, domain: query.domain };
|
||||||
|
const actionMap = {
|
||||||
const loadVisits = async (page = 1) => {
|
start: GET_SHORT_URL_VISITS_START,
|
||||||
const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
|
large: GET_SHORT_URL_VISITS_LARGE,
|
||||||
|
finish: GET_SHORT_URL_VISITS,
|
||||||
// If pagination was not returned, then this is an older shlink version. Just return data
|
error: GET_SHORT_URL_VISITS_ERROR,
|
||||||
if (!pagination || isLastPage(pagination)) {
|
progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
|
||||||
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 loadPagesBlocks = async (pagesBlocks, index = 0) => {
|
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
|
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
|
||||||
|
|
||||||
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT });
|
|
||||||
|
|||||||
68
src/visits/reducers/tagVisits.js
Normal file
68
src/visits/reducers/tagVisits.js
Normal file
@@ -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);
|
||||||
3
src/visits/reducers/visitCreation.js
Normal file
3
src/visits/reducers/visitCreation.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT';
|
||||||
|
|
||||||
|
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT });
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import ShortUrlVisits from '../ShortUrlVisits';
|
import ShortUrlVisits from '../ShortUrlVisits';
|
||||||
import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits';
|
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
|
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
|
||||||
import MapModal from '../helpers/MapModal';
|
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';
|
import * as visitsParser from './VisitsParser';
|
||||||
|
|
||||||
const provideServices = (bottle, connect) => {
|
const provideServices = (bottle, connect) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal');
|
bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal');
|
||||||
bottle.serviceFactory('MapModal', () => 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(
|
bottle.decorator('ShortUrlVisits', connect(
|
||||||
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
|
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
|
||||||
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
|
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||||
));
|
));
|
||||||
|
bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator');
|
||||||
|
bottle.decorator('TagVisits', connect(
|
||||||
|
[ 'tagVisits', 'mercureInfo', 'settings' ],
|
||||||
|
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||||
|
));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('VisitsParser', () => visitsParser);
|
bottle.serviceFactory('VisitsParser', () => visitsParser);
|
||||||
@@ -22,6 +32,10 @@ const provideServices = (bottle, connect) => {
|
|||||||
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
|
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
|
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
|
||||||
|
|
||||||
|
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
|
||||||
|
|
||||||
bottle.serviceFactory('createNewVisit', () => createNewVisit);
|
bottle.serviceFactory('createNewVisit', () => createNewVisit);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
25
src/visits/types/index.js
Normal file
25
src/visits/types/index.js
Normal file
@@ -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,
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import reducer, {
|
|||||||
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
|
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
|
||||||
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
|
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
|
||||||
import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
|
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('shortUrlsListReducer', () => {
|
||||||
describe('reducer', () => {
|
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 shortCode = 'abc123';
|
||||||
const shortUrl = {
|
const shortUrl = {
|
||||||
shortCode,
|
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: {
|
shortUrls: {
|
||||||
data: [
|
data: [
|
||||||
{ shortCode, domain: 'example.com', visitsCount: 5 },
|
{ shortCode, domain: 'example.com', visitsCount: 5 },
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('<TagCard />', () => {
|
|||||||
|
|
||||||
expect(links.at(1).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
|
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(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');
|
expect(links.at(2).text()).toContain('23,257');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('<TagsList />', () => {
|
|||||||
const TagsList = createTagsList(TagCard);
|
const TagsList = createTagsList(TagCard);
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />
|
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} settings={{}} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ import { TAG_EDITED } from '../../../src/tags/reducers/tagEdit';
|
|||||||
describe('tagsListReducer', () => {
|
describe('tagsListReducer', () => {
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
it('returns loading on LIST_TAGS_START', () => {
|
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,
|
loading: true,
|
||||||
error: false,
|
error: false,
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error on LIST_TAGS_ERROR', () => {
|
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,
|
loading: false,
|
||||||
error: true,
|
error: true,
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns provided tags as filtered and regular tags on LIST_TAGS', () => {
|
it('returns provided tags as filtered and regular tags on LIST_TAGS', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
describe('getShortUrl', () => {
|
||||||
it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => {
|
it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => {
|
||||||
const expectedShortUrl = { foo: 'bar' };
|
const expectedShortUrl = { foo: 'bar' };
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { identity } from 'ramda';
|
import { identity } from 'ramda';
|
||||||
import { Card } from 'reactstrap';
|
|
||||||
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
|
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
|
||||||
import Message from '../../src/utils/Message';
|
import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader';
|
||||||
import GraphCard from '../../src/visits/GraphCard';
|
|
||||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
|
||||||
import DateRangeRow from '../../src/utils/DateRangeRow';
|
|
||||||
|
|
||||||
describe('<ShortUrlVisits />', () => {
|
describe('<ShortUrlVisits />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const processStatsFromVisits = () => (
|
|
||||||
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
|
|
||||||
);
|
|
||||||
const getShortUrlVisitsMock = jest.fn();
|
const getShortUrlVisitsMock = jest.fn();
|
||||||
const match = {
|
const match = {
|
||||||
params: { shortCode: 'abc123' },
|
params: { shortCode: 'abc123' },
|
||||||
@@ -22,9 +15,10 @@ describe('<ShortUrlVisits />', () => {
|
|||||||
goBack: jest.fn(),
|
goBack: jest.fn(),
|
||||||
};
|
};
|
||||||
const realTimeUpdates = { enabled: true };
|
const realTimeUpdates = { enabled: true };
|
||||||
|
const VisitsStats = jest.fn();
|
||||||
|
|
||||||
const createComponent = (shortUrlVisits) => {
|
beforeEach(() => {
|
||||||
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits, normalizeVisits: identity }, () => '');
|
const ShortUrlVisits = createShortUrlVisits(VisitsStats);
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<ShortUrlVisits
|
<ShortUrlVisits
|
||||||
@@ -33,77 +27,23 @@ describe('<ShortUrlVisits />', () => {
|
|||||||
match={match}
|
match={match}
|
||||||
location={location}
|
location={location}
|
||||||
history={history}
|
history={history}
|
||||||
shortUrlVisits={shortUrlVisits}
|
shortUrlVisits={{ loading: true, visits: [] }}
|
||||||
shortUrlDetail={{}}
|
shortUrlDetail={{}}
|
||||||
cancelGetShortUrlVisits={identity}
|
cancelGetShortUrlVisits={identity}
|
||||||
matchMedia={() => ({ matches: false })}
|
matchMedia={() => ({ matches: false })}
|
||||||
settings={{ realTimeUpdates }}
|
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', () => {
|
afterEach(() => wrapper.unmount());
|
||||||
const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] });
|
afterEach(jest.resetAllMocks);
|
||||||
const loadingMessage = wrapper.find(Message);
|
|
||||||
|
|
||||||
expect(loadingMessage).toHaveLength(1);
|
it('renders visit stats and visits header', () => {
|
||||||
expect(loadingMessage.html()).toContain('This is going to take a while... :S');
|
const visitStats = wrapper.find(VisitsStats);
|
||||||
});
|
const visitHeader = wrapper.find(ShortUrlVisitsHeader);
|
||||||
|
|
||||||
it('renders an error message when visits could not be loaded', () => {
|
expect(visitStats).toHaveLength(1);
|
||||||
const wrapper = createComponent({ loading: false, error: true, visits: [] });
|
expect(visitHeader).toHaveLength(1);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
40
test/visits/ShortUrlVisitsHeader.test.js
Normal file
40
test/visits/ShortUrlVisitsHeader.test.js
Normal file
@@ -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('<ShortUrlVisitsHeader />', () => {
|
||||||
|
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(
|
||||||
|
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
test/visits/TagVisits.test.js
Normal file
44
test/visits/TagVisits.test.js
Normal file
@@ -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('<TagVisits />', () => {
|
||||||
|
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(
|
||||||
|
<TagVisits
|
||||||
|
getTagVisits={getTagVisitsMock}
|
||||||
|
match={match}
|
||||||
|
history={history}
|
||||||
|
tagVisits={{ loading: true, visits: [] }}
|
||||||
|
cancelGetTagVisits={identity}
|
||||||
|
settings={{ realTimeUpdates }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
test/visits/TagVisitsHeader.test.js
Normal file
33
test/visits/TagVisitsHeader.test.js
Normal file
@@ -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('<TagVisitsHeader />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const tagVisits = {
|
||||||
|
tag: 'foo',
|
||||||
|
visits: [{}, {}, {}],
|
||||||
|
};
|
||||||
|
const goBack = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={{}} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,46 +1,35 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import Moment from 'react-moment';
|
|
||||||
import { ExternalLink } from 'react-external-link';
|
|
||||||
import VisitsHeader from '../../src/visits/VisitsHeader';
|
import VisitsHeader from '../../src/visits/VisitsHeader';
|
||||||
|
|
||||||
describe('<VisitsHeader />', () => {
|
describe('<VisitsHeader />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const shortUrlDetail = {
|
const visits = [{}, {}, {}];
|
||||||
shortUrl: {
|
const title = 'My header title';
|
||||||
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();
|
const goBack = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = shallow(<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />);
|
wrapper = shallow(
|
||||||
|
<VisitsHeader visits={visits} goBack={goBack} title={title} />
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
afterEach(jest.resetAllMocks);
|
||||||
|
|
||||||
it('shows the amount of visits', () => {
|
it('shows the amount of visits', () => {
|
||||||
const visitsBadge = wrapper.find('.badge');
|
const visitsBadge = wrapper.find('.badge');
|
||||||
|
|
||||||
expect(visitsBadge.html()).toContain(
|
expect(visitsBadge.html()).toContain(
|
||||||
`Visits: <span><strong class="short-url-visits-count__amount">${shortUrlVisits.visits.length}</strong></span>`
|
`Visits: <span><strong class="short-url-visits-count__amount">${visits.length}</strong></span>`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows when the URL was created', () => {
|
it('shows the title in two places', () => {
|
||||||
const moment = wrapper.find(Moment).first();
|
const titles = wrapper.find('.text-center');
|
||||||
|
|
||||||
expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated);
|
expect(titles).toHaveLength(2);
|
||||||
});
|
expect(titles.at(0).html()).toContain(title);
|
||||||
|
expect(titles.at(1).html()).toContain(title);
|
||||||
it('shows the long URL', () => {
|
|
||||||
const longUrlLink = wrapper.find(ExternalLink).last();
|
|
||||||
|
|
||||||
expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
100
test/visits/VisitsStats.test.js
Normal file
100
test/visits/VisitsStats.test.js
Normal file
@@ -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('<VisitStats />', () => {
|
||||||
|
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(
|
||||||
|
<VisitStats
|
||||||
|
getVisits={getVisitsMock}
|
||||||
|
visitsInfo={visitsInfo}
|
||||||
|
cancelGetVisits={identity}
|
||||||
|
matchMedia={() => ({ 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import reducer, {
|
import reducer, {
|
||||||
getShortUrlVisits,
|
getShortUrlVisits,
|
||||||
cancelGetShortUrlVisits,
|
cancelGetShortUrlVisits,
|
||||||
createNewVisit,
|
|
||||||
GET_SHORT_URL_VISITS_START,
|
GET_SHORT_URL_VISITS_START,
|
||||||
GET_SHORT_URL_VISITS_ERROR,
|
GET_SHORT_URL_VISITS_ERROR,
|
||||||
GET_SHORT_URL_VISITS,
|
GET_SHORT_URL_VISITS,
|
||||||
GET_SHORT_URL_VISITS_LARGE,
|
GET_SHORT_URL_VISITS_LARGE,
|
||||||
GET_SHORT_URL_VISITS_CANCEL,
|
GET_SHORT_URL_VISITS_CANCEL,
|
||||||
CREATE_SHORT_URL_VISIT,
|
GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
|
||||||
} from '../../../src/visits/reducers/shortUrlVisits';
|
} from '../../../src/visits/reducers/shortUrlVisits';
|
||||||
|
import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation';
|
||||||
|
|
||||||
describe('shortUrlVisitsReducer', () => {
|
describe('shortUrlVisitsReducer', () => {
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
@@ -54,7 +54,7 @@ describe('shortUrlVisitsReducer', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
[{ shortCode: 'abc123' }, [{}, {}, {}]],
|
[{ shortCode: 'abc123' }, [{}, {}, {}]],
|
||||||
[{ shortCode: 'def456' }, [{}, {}]],
|
[{ shortCode: 'def456' }, [{}, {}]],
|
||||||
])('appends a new visit on CREATE_SHORT_URL_VISIT', (state, expectedVisits) => {
|
])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => {
|
||||||
const shortUrl = {
|
const shortUrl = {
|
||||||
shortCode: 'abc123',
|
shortCode: 'abc123',
|
||||||
};
|
};
|
||||||
@@ -63,10 +63,16 @@ describe('shortUrlVisitsReducer', () => {
|
|||||||
visits: [{}, {}],
|
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);
|
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', () => {
|
describe('getShortUrlVisits', () => {
|
||||||
@@ -128,7 +134,7 @@ describe('shortUrlVisitsReducer', () => {
|
|||||||
await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
|
await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
|
||||||
|
|
||||||
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests);
|
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests);
|
||||||
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||||
visits: [{}, {}, {}, {}, {}, {}],
|
visits: [{}, {}, {}, {}, {}, {}],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -138,11 +144,4 @@ describe('shortUrlVisitsReducer', () => {
|
|||||||
it('just returns the action with proper type', () =>
|
it('just returns the action with proper type', () =>
|
||||||
expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL }));
|
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: {} }
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
127
test/visits/reducers/tagVisits.test.js
Normal file
127
test/visits/reducers/tagVisits.test.js
Normal file
@@ -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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
10
test/visits/reducers/visitCreation.test.js
Normal file
10
test/visits/reducers/visitCreation.test.js
Normal file
@@ -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: {} }
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user