Merge pull request #268 from acelaya-forks/feature/tag-visits

Feature/tag visits
This commit is contained in:
Alejandro Celaya 2020-05-13 20:41:39 +02:00 committed by GitHub
commit dcc5b9cc8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1124 additions and 499 deletions

View file

@ -15,12 +15,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag.
This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag.
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
#### Changed
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.

View file

@ -8,6 +8,7 @@ import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import { versionMatch } from '../utils/helpers/version';
import NotFound from './NotFound';
import './MenuLayout.scss';
@ -17,7 +18,16 @@ const propTypes = {
selectedServer: serverType,
};
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
const MenuLayout = (
TagsList,
ShortUrls,
AsideMenu,
CreateShortUrl,
ShortUrlVisits,
TagVisits,
ShlinkVersions,
ServerError
) => {
const MenuLayoutComp = ({ match, location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const { params: { serverId } } = match;
@ -28,6 +38,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
return <ServerError type="not-reachable" />;
}
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
@ -61,6 +72,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
<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/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
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}

View file

@ -27,6 +27,7 @@ const provideServices = (bottle, connect, withRouter) => {
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits',
'TagVisits',
'ShlinkVersions',
'ServerError'
);

View file

@ -61,3 +61,7 @@ body,
background-color: darken($mainColor, 12%);
}
}
.progress-bar {
background-color: $mainColor;
}

View file

@ -9,6 +9,7 @@ import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
@ -27,6 +28,7 @@ export default combineReducers({
shortUrlMeta: shortUrlMetaReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,

View file

@ -1,8 +1,8 @@
import { handleActions } from 'redux-actions';
import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types';
import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits';
import { shortUrlMatches } from '../helpers';
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
@ -50,7 +50,7 @@ export default handleActions({
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
[CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)

View file

@ -60,7 +60,7 @@ const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGen
<b>{prettify(tagStats.shortUrlsCount)}</b>
</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"
>
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>

View file

@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
import Message from '../utils/Message';
import SearchField from '../utils/SearchField';
import { serverType } from '../servers/prop-types';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { SettingsType } from '../settings/reducers/settings';
import { bindToMercureTopic } from '../mercure/helpers';
import { TagsListType } from './reducers/tagsList';
const { ceil } = Math;
@ -14,15 +17,26 @@ const propTypes = {
forceListTags: PropTypes.func,
tagsList: TagsListType,
selectedServer: serverType,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
settings: SettingsType,
};
const TagsList = (TagCard) => {
const TagListComp = ({ filterTags, forceListTags, tagsList, selectedServer }) => {
const TagListComp = (
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo, settings }
) => {
const { realTimeUpdates } = settings;
const [ displayedTag, setDisplayedTag ] = useState();
useEffect(() => {
forceListTags();
}, []);
useEffect(
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
[ mercureInfo ]
);
const renderContent = () => {
if (tagsList.loading) {

View file

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Tag.scss';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
import './Tag.scss';
const propTypes = {
text: PropTypes.string,
@ -17,12 +17,12 @@ const Tag = ({
children,
clearable,
colorGenerator,
onClick = () => {},
onClose = () => {},
onClick,
onClose,
}) => (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children || text}

View file

@ -1,6 +1,5 @@
.tag {
color: #fff;
cursor: pointer;
}
.tag:not(:last-child) {

View file

@ -1,6 +1,7 @@
import { handleActions } from 'redux-actions';
import { isEmpty, reject } from 'ramda';
import PropTypes from 'prop-types';
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit';
@ -11,10 +12,15 @@ export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
/* eslint-enable padding-line-between-statements */
const TagStatsType = PropTypes.shape({
shortUrlsCount: PropTypes.number,
visitsCount: PropTypes.number,
});
export const TagsListType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
filteredTags: PropTypes.arrayOf(PropTypes.string),
stats: PropTypes.object, // Record
stats: PropTypes.objectOf(TagStatsType), // Record
loading: PropTypes.bool,
error: PropTypes.bool,
});
@ -29,11 +35,23 @@ const initialState = {
const renameTag = (oldName, newName) => (tag) => tag === oldName ? newName : tag;
const rejectTag = (tags, tagToReject) => reject((tag) => tag === tagToReject, tags);
const increaseVisitsForTags = (tags, stats) => tags.reduce((stats, tag) => {
if (!stats[tag]) {
return stats;
}
const tagStats = stats[tag];
tagStats.visitsCount = tagStats.visitsCount + 1;
stats[tag] = tagStats;
return stats;
}, { ...stats });
export default handleActions({
[LIST_TAGS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_TAGS_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[LIST_TAGS]: (state, { tags, stats }) => ({ stats, tags, filteredTags: tags, loading: false, error: false }),
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
[LIST_TAGS_ERROR]: () => ({ ...initialState, error: true }),
[LIST_TAGS]: (state, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
[TAG_DELETED]: (state, { tag }) => ({
...state,
tags: rejectTag(state.tags, tag),
@ -48,6 +66,10 @@ export default handleActions({
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm)),
}),
[CREATE_VISIT]: (state, { shortUrl }) => ({
...state,
stats: increaseVisitsForTags(shortUrl.tags, state.stats),
}),
}, initialState);
export const listTags = (buildShlinkApiClient, force = true) => () => async (dispatch, getState) => {
@ -74,7 +96,4 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
}
};
export const filterTags = (searchTerm) => ({
type: FILTER_TAGS,
searchTerm,
});
export const filterTags = (searchTerm) => ({ type: FILTER_TAGS, searchTerm });

View file

@ -28,7 +28,10 @@ const provideServices = (bottle, connect) => {
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
bottle.decorator('TagsList', connect([ 'tagsList', 'selectedServer' ], [ 'forceListTags', 'filterTags' ]));
bottle.decorator('TagsList', connect(
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
[ 'forceListTags', 'filterTags', 'createNewVisit', 'loadMercureInfo' ]
));
// Actions
const listTagsActionFactory = (force) => ({ buildShlinkApiClient }) => listTags(buildShlinkApiClient, force);

View file

@ -36,6 +36,10 @@ export default class ShlinkApiClient {
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getTagVisits = (tag, query) =>
this._performRequest(`/tags/${tag}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
.then((resp) => resp.data);

View file

@ -1,24 +1,12 @@
import { isEmpty, propEq, values } from 'ramda';
import React, { useState, useEffect, useMemo } from 'react';
import { Button, Card, Collapse } from 'reactstrap';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import qs from 'qs';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
import DateRangeRow from '../utils/DateRangeRow';
import Message from '../utils/Message';
import { formatDate } from '../utils/helpers/date';
import { useToggle } from '../utils/helpers/hooks';
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
import { bindToMercureTopic } from '../mercure/helpers';
import { SettingsType } from '../settings/reducers/settings';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import VisitsTable from './VisitsTable';
const propTypes = {
history: PropTypes.shape({
@ -35,26 +23,13 @@ const propTypes = {
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
matchMedia: PropTypes.func,
createNewVisit: PropTypes.func,
loadMercureInfo: PropTypes.func,
mercureInfo: MercureInfoType,
settings: SettingsType,
};
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
if (!acc[highlightedVisit[prop]]) {
acc[highlightedVisit[prop]] = 0;
}
acc[highlightedVisit[prop]] += 1;
return acc;
}, {});
const format = formatDate();
let selectedBar;
const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
const ShortUrlVisits = (VisitsStats) => {
const ShortUrlVisitsComp = ({
history,
match,
@ -64,65 +39,21 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
getShortUrlVisits,
getShortUrlDetail,
cancelGetShortUrlVisits,
matchMedia = window.matchMedia,
createNewVisit,
loadMercureInfo,
mercureInfo,
settings: { realTimeUpdates },
}) => {
const [ startDate, setStartDate ] = useState(undefined);
const [ endDate, setEndDate ] = useState(undefined);
const [ showTable, toggleTable ] = useToggle();
const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle();
const [ highlightedVisits, setHighlightedVisits ] = useState([]);
const [ isMobileDevice, setIsMobileDevice ] = useState(false);
const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
const setSelectedVisits = (selectedVisits) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
};
const highlightVisitsForProp = (prop) => (value) => {
const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) {
setHighlightedVisits([]);
selectedBar = undefined;
} else {
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
selectedBar = newSelectedBar;
}
};
const { params } = match;
const { shortCode } = params;
const { search } = location;
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
const { visits, loading, loadingLarge, error } = shortUrlVisits;
const showTableControls = !loading && visits.length > 0;
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
() => processStatsFromVisits(normalizedVisits),
[ normalizedVisits ]
);
const mapLocations = values(citiesForMap);
const loadVisits = () =>
getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
const loadVisits = (dates) => getShortUrlVisits(shortCode, { ...dates, domain });
useEffect(() => {
getShortUrlDetail(shortCode, domain);
determineIsMobileDevice();
window.addEventListener('resize', determineIsMobileDevice);
return () => {
cancelGetShortUrlVisits();
window.removeEventListener('resize', determineIsMobileDevice);
};
}, []);
useEffect(() => {
loadVisits();
}, [ startDate, endDate ]);
useEffect(
bindToMercureTopic(
mercureInfo,
@ -134,138 +65,10 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
[ mercureInfo ],
);
const renderVisitsContent = () => {
if (loading) {
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
return <Message 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 (
<React.Fragment>
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={history.goBack} />
<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>
<VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={history.goBack} />
</VisitsStats>
);
};

View 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;

View file

@ -0,0 +1,3 @@
.short-url-visits-header__created-at {
cursor: default;
}

View file

@ -28,7 +28,7 @@ export default class SortableBarGraph extends React.Component {
orderField: undefined,
orderDir: undefined,
currentPage: 1,
itemsPerPage: Infinity,
itemsPerPage: 50,
};
getSortedPairsForStats(stats, sortingItems) {

64
src/visits/TagVisits.js Normal file
View 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;

View 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;

View file

@ -1,67 +1,44 @@
import { Button, Card, UncontrolledTooltip } from 'reactstrap';
import Moment from 'react-moment';
import { Button, Card } from 'reactstrap';
import React from 'react';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import ShortUrlVisitsCount from '../short-urls/helpers/ShortUrlVisitsCount';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import './VisitsHeader.scss';
import { shortUrlType } from '../short-urls/reducers/shortUrlsList';
import { VisitType } from './types';
const propTypes = {
shortUrlDetail: shortUrlDetailType.isRequired,
shortUrlVisits: shortUrlVisitsType.isRequired,
visits: PropTypes.arrayOf(VisitType).isRequired,
goBack: PropTypes.func.isRequired,
title: PropTypes.node.isRequired,
children: PropTypes.node,
shortUrl: shortUrlType,
};
export default function VisitsHeader({ shortUrlDetail, shortUrlVisits, goBack }) {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
const VisitsHeader = ({ visits, goBack, shortUrl, children, title }) => (
<header>
<Card className="bg-light" body>
<h2 className="d-flex justify-content-between align-items-center mb-0">
<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 = () => (
<span>
<b id="created" className="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>
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>
);
}
{children && <div className="mt-md-2">{children}</div>}
</Card>
</header>
);
VisitsHeader.propTypes = propTypes;
export default VisitsHeader;

View file

@ -1,3 +0,0 @@
.visits-header__created-at {
cursor: default;
}

231
src/visits/VisitsStats.js Normal file
View 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;

View 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 });
}
};

View file

@ -21,9 +21,9 @@ const initialState = {
};
export default handleActions({
[GET_SHORT_URL_DETAIL_START]: (state) => ({ ...state, loading: true }),
[GET_SHORT_URL_DETAIL_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ shortUrl, loading: false, error: false }),
[GET_SHORT_URL_DETAIL_START]: () => ({ ...initialState, loading: true }),
[GET_SHORT_URL_DETAIL_ERROR]: () => ({ ...initialState, loading: false, error: true }),
[GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ ...initialState, shortUrl }),
}, initialState);
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {

View file

@ -1,7 +1,9 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda';
import { shortUrlMatches } from '../../short-urls/helpers';
import { VisitType } from '../types';
import { getVisitsWithLoader } from './common';
import { CREATE_VISIT } from './visitCreation';
/* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
@ -9,31 +11,17 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT';
export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED';
/* eslint-enable padding-line-between-statements */
export const visitType = PropTypes.shape({
referer: PropTypes.string,
date: PropTypes.string,
userAgent: PropTypes.string,
visitLocations: PropTypes.shape({
countryCode: PropTypes.string,
countryName: PropTypes.string,
regionName: PropTypes.string,
cityName: PropTypes.string,
latitude: PropTypes.number,
longitude: PropTypes.number,
timezone: PropTypes.string,
isEmpty: PropTypes.bool,
}),
});
export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.arrayOf(visitType),
export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType
visits: PropTypes.arrayOf(VisitType),
shortCode: PropTypes.string,
domain: PropTypes.string,
loading: PropTypes.bool,
loadingLarge: PropTypes.bool,
error: PropTypes.bool,
progress: PropTypes.number,
});
const initialState = {
@ -44,34 +32,22 @@ const initialState = {
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export default handleActions({
[GET_SHORT_URL_VISITS_START]: (state) => ({
...state,
loading: true,
loadingLarge: false,
cancelLoad: false,
}),
[GET_SHORT_URL_VISITS_ERROR]: (state) => ({
...state,
loading: false,
loadingLarge: false,
error: true,
cancelLoad: false,
}),
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_SHORT_URL_VISITS_ERROR]: () => ({ ...initialState, error: true }),
[GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({
...initialState,
visits,
shortCode,
domain,
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
}),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISIT]: (state, { shortUrl, visit }) => { // eslint-disable-line object-shorthand
const { shortCode, domain, visits } = state;
if (!shortUrlMatches(shortUrl, shortCode, domain)) {
@ -82,65 +58,19 @@ export default handleActions({
},
}, initialState);
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => (dispatch, getState) => {
const { getShortUrlVisits } = buildShlinkApiClient(getState);
const itemsPerPage = 5000;
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;
const loadVisits = async (page = 1) => {
const { pagination, data } = await getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
// If pagination was not returned, then this is an older shlink version. Just return data
if (!pagination || isLastPage(pagination)) {
return data;
}
// If there are more pages, make requests in blocks of 4
const parallelRequestsCount = 4;
const parallelStartingPage = 2;
const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1);
const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange);
if (pagination.pagesCount - 1 > parallelRequestsCount) {
dispatch({ type: GET_SHORT_URL_VISITS_LARGE });
}
return data.concat(await loadPagesBlocks(pagesBlocks));
const visitsLoader = (page, itemsPerPage) => getShortUrlVisits(shortCode, { ...query, page, itemsPerPage });
const extraFinishActionData = { shortCode, domain: query.domain };
const actionMap = {
start: GET_SHORT_URL_VISITS_START,
large: GET_SHORT_URL_VISITS_LARGE,
finish: GET_SHORT_URL_VISITS,
error: GET_SHORT_URL_VISITS_ERROR,
progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
};
const loadPagesBlocks = async (pagesBlocks, index = 0) => {
const { shortUrlVisits: { cancelLoad } } = getState();
if (cancelLoad) {
return [];
}
const data = await loadVisitsInParallel(pagesBlocks[index]);
if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
}
return data;
};
const loadVisitsInParallel = (pages) =>
Promise.all(pages.map(
(page) =>
getShortUrlVisits(shortCode, { ...query, page, itemsPerPage })
.then(prop('data'))
)).then(flatten);
try {
const visits = await loadVisits();
dispatch({ visits, shortCode, domain: query.domain, type: GET_SHORT_URL_VISITS });
} catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
}
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState);
};
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT });

View 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);

View file

@ -0,0 +1,3 @@
export const CREATE_VISIT = 'shlink/visitCreation/CREATE_VISIT';
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_VISIT });

View file

@ -1,19 +1,29 @@
import ShortUrlVisits from '../ShortUrlVisits';
import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
import MapModal from '../helpers/MapModal';
import VisitsStats from '../VisitsStats';
import { createNewVisit } from '../reducers/visitCreation';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import TagVisits from '../TagVisits';
import * as visitsParser from './VisitsParser';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('OpenMapModalBtn', OpenMapModalBtn, 'MapModal');
bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
bottle.serviceFactory('VisitsStats', VisitsStats, 'VisitsParser', 'OpenMapModalBtn');
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsStats');
bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
));
bottle.serviceFactory('TagVisits', TagVisits, 'VisitsStats', 'ColorGenerator');
bottle.decorator('TagVisits', connect(
[ 'tagVisits', 'mercureInfo', 'settings' ],
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisit', 'loadMercureInfo' ]
));
// Services
bottle.serviceFactory('VisitsParser', () => visitsParser);
@ -22,6 +32,10 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
bottle.serviceFactory('createNewVisit', () => createNewVisit);
};

25
src/visits/types/index.js Normal file
View 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,
});

View file

@ -7,7 +7,7 @@ import reducer, {
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
import { CREATE_SHORT_URL_VISIT } from '../../../src/visits/reducers/shortUrlVisits';
import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation';
describe('shortUrlsListReducer', () => {
describe('reducer', () => {
@ -103,7 +103,7 @@ describe('shortUrlsListReducer', () => {
});
});
it('updates visits count on CREATE_SHORT_URL_VISIT', () => {
it('updates visits count on CREATE_VISIT', () => {
const shortCode = 'abc123';
const shortUrl = {
shortCode,
@ -119,7 +119,7 @@ describe('shortUrlsListReducer', () => {
},
};
expect(reducer(state, { type: CREATE_SHORT_URL_VISIT, shortUrl })).toEqual({
expect(reducer(state, { type: CREATE_VISIT, shortUrl })).toEqual({
shortUrls: {
data: [
{ shortCode, domain: 'example.com', visitsCount: 5 },

View file

@ -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).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');
});
});

View file

@ -15,7 +15,7 @@ describe('<TagsList />', () => {
const TagsList = createTagsList(TagCard);
wrapper = shallow(
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} settings={{}} />
);
return wrapper;

View file

@ -11,17 +11,17 @@ import { TAG_EDITED } from '../../../src/tags/reducers/tagEdit';
describe('tagsListReducer', () => {
describe('reducer', () => {
it('returns loading on LIST_TAGS_START', () => {
expect(reducer({}, { type: LIST_TAGS_START })).toEqual({
expect(reducer({}, { type: LIST_TAGS_START })).toEqual(expect.objectContaining({
loading: true,
error: false,
});
}));
});
it('returns error on LIST_TAGS_ERROR', () => {
expect(reducer({}, { type: LIST_TAGS_ERROR })).toEqual({
expect(reducer({}, { type: LIST_TAGS_ERROR })).toEqual(expect.objectContaining({
loading: false,
error: true,
});
}));
});
it('returns provided tags as filtered and regular tags on LIST_TAGS', () => {

View file

@ -71,6 +71,28 @@ describe('ShlinkApiClient', () => {
});
});
describe('getTagVisits', () => {
it('properly returns tag visits', async () => {
const expectedVisits = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({
data: {
visits: {
data: expectedVisits,
},
},
}));
const { getTagVisits } = new ShlinkApiClient(axiosSpy);
const actualVisits = await getTagVisits('foo', {});
expect({ data: expectedVisits }).toEqual(actualVisits);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
url: '/tags/foo/visits',
method: 'GET',
}));
});
});
describe('getShortUrl', () => {
it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => {
const expectedShortUrl = { foo: 'bar' };

View file

@ -1,18 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { Card } from 'reactstrap';
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
import Message from '../../src/utils/Message';
import GraphCard from '../../src/visits/GraphCard';
import SortableBarGraph from '../../src/visits/SortableBarGraph';
import DateRangeRow from '../../src/utils/DateRangeRow';
import ShortUrlVisitsHeader from '../../src/visits/ShortUrlVisitsHeader';
describe('<ShortUrlVisits />', () => {
let wrapper;
const processStatsFromVisits = () => (
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
);
const getShortUrlVisitsMock = jest.fn();
const match = {
params: { shortCode: 'abc123' },
@ -22,9 +15,10 @@ describe('<ShortUrlVisits />', () => {
goBack: jest.fn(),
};
const realTimeUpdates = { enabled: true };
const VisitsStats = jest.fn();
const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits, normalizeVisits: identity }, () => '');
beforeEach(() => {
const ShortUrlVisits = createShortUrlVisits(VisitsStats);
wrapper = shallow(
<ShortUrlVisits
@ -33,77 +27,23 @@ describe('<ShortUrlVisits />', () => {
match={match}
location={location}
history={history}
shortUrlVisits={shortUrlVisits}
shortUrlVisits={{ loading: true, visits: [] }}
shortUrlDetail={{}}
cancelGetShortUrlVisits={identity}
matchMedia={() => ({ matches: false })}
settings={{ realTimeUpdates }}
/>
);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it('renders a preloader when visits are loading', () => {
const wrapper = createComponent({ loading: true, visits: [] });
const loadingMessage = wrapper.find(Message);
expect(loadingMessage).toHaveLength(1);
expect(loadingMessage.html()).toContain('Loading...');
});
it('renders a warning when loading large amounts of visits', () => {
const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] });
const loadingMessage = wrapper.find(Message);
afterEach(() => wrapper.unmount());
afterEach(jest.resetAllMocks);
expect(loadingMessage).toHaveLength(1);
expect(loadingMessage.html()).toContain('This is going to take a while... :S');
});
it('renders visit stats and visits header', () => {
const visitStats = wrapper.find(VisitsStats);
const visitHeader = wrapper.find(ShortUrlVisitsHeader);
it('renders an error message when visits could not be loaded', () => {
const wrapper = createComponent({ loading: false, error: true, visits: [] });
const errorMessage = wrapper.find(Card);
expect(errorMessage).toHaveLength(1);
expect(errorMessage.html()).toContain('An error occurred while loading visits :(');
});
it('renders a message when visits are loaded but the list is empty', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [] });
const message = wrapper.find(Message);
expect(message).toHaveLength(1);
expect(message.html()).toContain('There are no visits matching current filter :(');
});
it('renders all graphics when visits are properly loaded', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const graphs = wrapper.find(GraphCard);
const sortableBarGraphs = wrapper.find(SortableBarGraph);
expect(graphs.length + sortableBarGraphs.length).toEqual(5);
});
it('reloads visits when selected dates change', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const dateRange = wrapper.find(DateRangeRow);
dateRange.simulate('startDateChange', '2016-01-01T00:00:00+01:00');
dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00');
dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00');
expect(wrapper.find(DateRangeRow).prop('startDate')).toEqual('2016-01-01T00:00:00+01:00');
expect(wrapper.find(DateRangeRow).prop('endDate')).toEqual('2016-01-03T00:00:00+01:00');
});
it('holds the map button content generator on cities graph extraHeaderContent', () => {
const wrapper = createComponent({ loading: false, error: false, visits: [{}, {}, {}] });
const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]');
const extraHeaderContent = citiesGraph.prop('extraHeaderContent');
expect(extraHeaderContent).toHaveLength(1);
expect(typeof extraHeaderContent).toEqual('function');
expect(visitStats).toHaveLength(1);
expect(visitHeader).toHaveLength(1);
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View file

@ -1,46 +1,35 @@
import React from 'react';
import { shallow } from 'enzyme';
import Moment from 'react-moment';
import { ExternalLink } from 'react-external-link';
import VisitsHeader from '../../src/visits/VisitsHeader';
describe('<VisitsHeader />', () => {
let wrapper;
const shortUrlDetail = {
shortUrl: {
shortUrl: 'https://doma.in/abc123',
longUrl: 'https://foo.bar/bar/foo',
dateCreated: '2018-01-01T10:00:00+01:00',
},
loading: false,
};
const shortUrlVisits = {
visits: [{}, {}, {}],
};
const visits = [{}, {}, {}];
const title = 'My header title';
const goBack = jest.fn();
beforeEach(() => {
wrapper = shallow(<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />);
wrapper = shallow(
<VisitsHeader visits={visits} goBack={goBack} title={title} />
);
});
afterEach(() => wrapper.unmount());
afterEach(jest.resetAllMocks);
it('shows the amount of visits', () => {
const visitsBadge = wrapper.find('.badge');
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', () => {
const moment = wrapper.find(Moment).first();
it('shows the title in two places', () => {
const titles = wrapper.find('.text-center');
expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl.dateCreated);
});
it('shows the long URL', () => {
const longUrlLink = wrapper.find(ExternalLink).last();
expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl.longUrl);
expect(titles).toHaveLength(2);
expect(titles.at(0).html()).toContain(title);
expect(titles.at(1).html()).toContain(title);
});
});

View 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');
});
});

View file

@ -1,14 +1,14 @@
import reducer, {
getShortUrlVisits,
cancelGetShortUrlVisits,
createNewVisit,
GET_SHORT_URL_VISITS_START,
GET_SHORT_URL_VISITS_ERROR,
GET_SHORT_URL_VISITS,
GET_SHORT_URL_VISITS_LARGE,
GET_SHORT_URL_VISITS_CANCEL,
CREATE_SHORT_URL_VISIT,
GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
} from '../../../src/visits/reducers/shortUrlVisits';
import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation';
describe('shortUrlVisitsReducer', () => {
describe('reducer', () => {
@ -54,7 +54,7 @@ describe('shortUrlVisitsReducer', () => {
it.each([
[{ shortCode: 'abc123' }, [{}, {}, {}]],
[{ shortCode: 'def456' }, [{}, {}]],
])('appends a new visit on CREATE_SHORT_URL_VISIT', (state, expectedVisits) => {
])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => {
const shortUrl = {
shortCode: 'abc123',
};
@ -63,10 +63,16 @@ describe('shortUrlVisitsReducer', () => {
visits: [{}, {}],
};
const { visits } = reducer(prevState, { type: CREATE_SHORT_URL_VISIT, shortUrl, visit: {} });
const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} });
expect(visits).toEqual(expectedVisits);
});
it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => {
const state = reducer({}, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: 85 });
expect(state).toEqual({ progress: 85 });
});
});
describe('getShortUrlVisits', () => {
@ -128,7 +134,7 @@ describe('shortUrlVisitsReducer', () => {
await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
visits: [{}, {}, {}, {}, {}, {}],
}));
});
@ -138,11 +144,4 @@ describe('shortUrlVisitsReducer', () => {
it('just returns the action with proper type', () =>
expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL }));
});
describe('createNewVisit', () => {
it('just returns the action with proper type', () =>
expect(createNewVisit({ shortUrl: {}, visit: {} })).toEqual(
{ type: CREATE_SHORT_URL_VISIT, shortUrl: {}, visit: {} }
));
});
});

View 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 }));
});
});

View 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: {} }
));
});
});