Extracted visits charts elements into reusable component

This commit is contained in:
Alejandro Celaya 2020-05-10 17:49:55 +02:00
parent 6eead70511
commit 18e18f533b
10 changed files with 368 additions and 295 deletions

View file

@ -61,6 +61,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} />
{/* <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>}

View file

@ -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>

View file

@ -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 VisitsHeader from './VisitsHeader';
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 ( return (
<Card className="mt-4" body inverse color="danger"> <VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetShortUrlVisits} visitsInfo={shortUrlVisits}>
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} /> <VisitsHeader 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>
); );
}; };

224
src/visits/VisitsStats.js Normal file
View file

@ -0,0 +1,224 @@
import { isEmpty, propEq, values } from 'ramda';
import React, { useState, useEffect, useMemo } from 'react';
import { Button, Card, Collapse } 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, // TODO VisitsInfo type
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 } = 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 (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>
{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

@ -2,6 +2,7 @@ import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { flatten, prop, range, splitEvery } from 'ramda'; import { flatten, prop, range, splitEvery } from 'ramda';
import { shortUrlMatches } from '../../short-urls/helpers'; import { shortUrlMatches } from '../../short-urls/helpers';
import { VisitType } from '../types';
/* 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';
@ -12,24 +13,8 @@ export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_
export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT'; export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT';
/* 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,

View file

@ -3,13 +3,15 @@ import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../r
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 * 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' ]

23
src/visits/types/index.js Normal file
View file

@ -0,0 +1,23 @@
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,
error: PropTypes.bool,
});

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

View file

@ -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 VisitsHeader from '../../src/visits/VisitsHeader';
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(VisitsHeader);
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');
}); });
}); });

View file

@ -0,0 +1,95 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { Card } 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);
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);
expect(loadingMessage).toHaveLength(1);
expect(loadingMessage.html()).toContain('This is going to take a while... :S');
});
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');
});
});