Merge pull request #242 from acelaya-forks/feature/visits-table

Feature/visits table
This commit is contained in:
Alejandro Celaya 2020-04-09 11:23:51 +02:00 committed by GitHub
commit 05deb1aff0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 833 additions and 251 deletions

View file

@ -29,6 +29,7 @@
"no-magic-numbers": "off",
"no-undefined": "off",
"no-inline-comments": "off",
"lines-around-comment": "off",
"indent": ["error", 2, {
"SwitchCase": 1
}

View file

@ -8,10 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
#### Added
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a pagintaed, sortable and filterable list.
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when suing Shlink 2.1 or higher.
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
#### Changed

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
@ -7,6 +7,7 @@ import classNames from 'classnames';
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 NotFound from './NotFound';
import './MenuLayout.scss';
@ -18,43 +19,39 @@ const propTypes = {
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
const MenuLayoutComp = ({ match, location, selectedServer }) => {
const [ showSideBar, setShowSidebar ] = useState(false);
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const { params: { serverId } } = match;
useEffect(() => setShowSidebar(false), [ location ]);
useEffect(() => hideSidebar(), [ location ]);
if (selectedServer.serverNotReachable) {
return <ServerError type="not-reachable" />;
}
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': showSideBar,
'menu-layout__burger-icon--active': sidebarVisible,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
const swipeMenuIfNoModalExists = (callback) => () => {
if (document.querySelector('.modal')) {
return;
}
setShowSidebar(showSideBar);
callback();
};
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => setShowSidebar(!showSideBar)}
/>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={showSideBar} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => setShowSidebar(false)}>
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />

View file

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
import './SimplePaginator.scss';
@ -8,9 +9,10 @@ const propTypes = {
pagesCount: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
setCurrentPage: PropTypes.func.isRequired,
centered: PropTypes.bool,
};
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
if (pagesCount < 2) {
return null;
}
@ -18,7 +20,7 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
const onClick = (page) => () => setCurrentPage(page);
return (
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
<PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem>

View file

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React from 'react';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import { useToggle } from '../utils/helpers/hooks';
import { serverType } from './prop-types';
const propTypes = {
@ -13,16 +14,16 @@ const propTypes = {
const DeleteServerButton = (DeleteServerModal) => {
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
const [ isModalOpen, setModalOpen ] = useState(false);
const [ isModalOpen, , showModal, hideModal ] = useToggle();
return (
<React.Fragment>
<span className={className} onClick={() => setModalOpen(true)}>
<span className={className} onClick={showModal}>
{!children && <FontAwesomeIcon icon={deleteIcon} />}
<span className={textClassName}>{children || 'Remove this server'}</span>
</span>
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={() => setModalOpen(!isModalOpen)} />
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
</React.Fragment>
);
};

View file

@ -36,7 +36,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) =>
maxVisits: undefined,
findIfExists: false,
});
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle(false);
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (

View file

@ -36,12 +36,16 @@ const SearchBar = (colorGenerator, ForServerVersion) => {
<ForServerVersion minVersion="1.21.0">
<div className="mt-3">
<DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
<div className="row">
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
<DateRangeRow
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
</div>
</div>
</ForServerVersion>

View file

@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => (
);
const UseExistingIfFoundInfoIcon = () => {
const [ isModalOpen, toggleModal ] = useToggle(false);
const [ isModalOpen, toggleModal ] = useToggle();
return (
<React.Fragment>

View file

@ -26,13 +26,13 @@ const propTypes = {
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal, EditMetaModal, EditShortUrlModal, ForServerVersion) => {
const ShortUrlsRowMenuComp = ({ shortUrl, selectedServer }) => {
const [ isOpen, toggle ] = useToggle(false);
const [ isQrModalOpen, toggleQrCode ] = useToggle(false);
const [ isPreviewModalOpen, togglePreview ] = useToggle(false);
const [ isTagsModalOpen, toggleTags ] = useToggle(false);
const [ isMetaModalOpen, toggleMeta ] = useToggle(false);
const [ isDeleteModalOpen, toggleDelete ] = useToggle(false);
const [ isEditModalOpen, toggleEdit ] = useToggle(false);
const [ isOpen, toggle ] = useToggle();
const [ isQrModalOpen, toggleQrCode ] = useToggle();
const [ isPreviewModalOpen, togglePreview ] = useToggle();
const [ isTagsModalOpen, toggleTags ] = useToggle();
const [ isMetaModalOpen, toggleMeta ] = useToggle();
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
return (

View file

@ -13,7 +13,7 @@ const propTypes = {
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
<div className="row">
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
<div className="col-md-6">
<DateInput
selected={startDate}
placeholderText="Since"
@ -22,7 +22,7 @@ const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }
onChange={onStartDateChange}
/>
</div>
<div className="col-xl-3 col-lg-4 col-md-6">
<div className="col-md-6">
<DateInput
className="date-range-row__date-input"
selected={endDate}

View file

@ -35,8 +35,8 @@ const Message = ({ children, loading = false, noMargin = false, type = 'default'
<Card className={cardClasses} body>
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
{loading && <FontAwesomeIcon icon={preloader} spin />}
{loading && !children && <span className="ml-2">Loading...</span>}
{children}
{loading && <span className="ml-2">{children || 'Loading...'}</span>}
{!loading && children}
</h3>
</Card>
</div>

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
@ -6,62 +6,59 @@ import classNames from 'classnames';
import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500;
let timer;
export default class SearchField extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
placeholder: PropTypes.string,
const propTypes = {
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
placeholder: PropTypes.string,
large: PropTypes.bool,
noBorder: PropTypes.bool,
};
const SearchField = ({ onChange, className, placeholder = 'Search...', large = true, noBorder = false }) => {
const [ searchTerm, setSearchTerm ] = useState('');
const resetTimer = () => {
clearTimeout(timer);
timer = null;
};
static defaultProps = {
className: '',
placeholder: 'Search...',
};
state = { showClearBtn: false, searchTerm: '' };
timer = null;
searchTermChanged(searchTerm, timeout = DEFAULT_SEARCH_INTERVAL) {
this.setState({
showClearBtn: searchTerm !== '',
searchTerm,
});
const resetTimer = () => {
clearTimeout(this.timer);
this.timer = null;
};
const searchTermChanged = (newSearchTerm, timeout = DEFAULT_SEARCH_INTERVAL) => {
setSearchTerm(newSearchTerm);
resetTimer();
this.timer = setTimeout(() => {
this.props.onChange(searchTerm);
timer = setTimeout(() => {
onChange(newSearchTerm);
resetTimer();
}, timeout);
}
};
render() {
const { className, placeholder } = this.props;
return (
<div className={classNames('search-field', className)}>
<input
type="text"
className="form-control form-control-lg search-field__input"
placeholder={placeholder}
value={this.state.searchTerm}
onChange={(e) => this.searchTermChanged(e.target.value)}
/>
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
<div
className="close search-field__close"
hidden={!this.state.showClearBtn}
id="search-field__close"
onClick={() => this.searchTermChanged('', 0)}
>
&times;
</div>
return (
<div className={classNames('search-field', className)}>
<input
type="text"
className={classNames('form-control search-field__input', {
'form-control-lg': large,
'search-field__input--no-border': noBorder,
})}
placeholder={placeholder}
value={searchTerm}
onChange={(e) => searchTermChanged(e.target.value)}
/>
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
<div
className="close search-field__close"
hidden={searchTerm === ''}
id="search-field__close"
onClick={() => searchTermChanged('', 0)}
>
&times;
</div>
);
}
}
</div>
);
};
SearchField.propTypes = propTypes;
export default SearchField;

View file

@ -2,6 +2,10 @@
.search-field {
position: relative;
&:focus-within {
z-index: 1;
}
}
.search-field__input.search-field__input {
@ -9,6 +13,11 @@
padding-right: 40px;
}
.search-field__input--no-border.search-field__input--no-border {
border: none;
border-radius: 0;
}
.search-field__icon {
@include vertical-align();

View file

@ -13,6 +13,7 @@ $mainColor: #4696e5;
$lightHoverColor: #eee;
$lightGrey: #ddd;
$dangerColor: #dc3545;
$mediumGrey: #dee2e6;
// Misc
$headerHeight: 57px;

View file

@ -12,8 +12,9 @@ export const useStateFlagTimeout = (setTimeout) => (initialValue = true, delay =
return [ flag, callback ];
};
// Return [ flag, toggle, enable, disable ]
export const useToggle = (initialValue = false) => {
const [ flag, setFlag ] = useState(initialValue);
return [ flag, () => setFlag(!flag) ];
return [ flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false) ];
};

View file

@ -0,0 +1,8 @@
const TEN_ROUNDING_NUMBER = 10;
const { ceil } = Math;
const formatter = new Intl.NumberFormat('en-US');
export const prettify = (number) => formatter.format(number);
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;

View file

@ -0,0 +1,59 @@
import { hasValue } from '../utils';
const DEFAULT = 'Others';
export const osFromUserAgent = (userAgent) => {
if (!hasValue(userAgent)) {
return DEFAULT;
}
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case lowerUserAgent.includes('linux'):
return 'Linux';
case lowerUserAgent.includes('windows'):
return 'Windows';
case lowerUserAgent.includes('mac'):
return 'MacOS';
case lowerUserAgent.includes('mobi'):
return 'Mobile';
default:
return DEFAULT;
}
};
export const browserFromUserAgent = (userAgent) => {
if (!hasValue(userAgent)) {
return DEFAULT;
}
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case lowerUserAgent.includes('opera') || lowerUserAgent.includes('opr'):
return 'Opera';
case lowerUserAgent.includes('firefox'):
return 'Firefox';
case lowerUserAgent.includes('chrome'):
return 'Chrome';
case lowerUserAgent.includes('safari'):
return 'Safari';
case lowerUserAgent.includes('edg'):
return 'Microsoft Edge';
case lowerUserAgent.includes('msie'):
return 'Internet Explorer';
default:
return DEFAULT;
}
};
export const extractDomain = (url) => {
if (!hasValue(url)) {
return 'Direct';
}
const domain = url.includes('://') ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0];
};

View file

@ -0,0 +1,37 @@
@import "../base";
@mixin sticky-cell() {
z-index: 1;
border: none !important;
position: relative;
&:before {
content: '';
position: absolute;
top: -1px;
left: 0;
bottom: -1px;
right: -1px;
background: $mediumGrey;
z-index: -2;
}
&:first-child:before {
left: -1px;
}
&:after {
content: '';
position: absolute;
top: 0;
left: 1px;
bottom: 0;
right: 0;
background: white;
z-index: -1;
}
&:first-child:after {
left: 0;
}
}

View file

@ -4,9 +4,7 @@ import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { isEmpty, isNil, range } from 'ramda';
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
const { ceil } = Math;
export const stateFlagTimeout = (setTimeout) => (
setState,
@ -43,6 +41,4 @@ export const fixLeafletIcons = () => {
export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn);
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
export const hasValue = (value) => !isNil(value) && !isEmpty(value);

View file

@ -2,7 +2,7 @@ import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types';
import React from 'react';
import { keys, values } from 'ramda';
import { keys, values, zipObj } from 'ramda';
import './GraphCard.scss';
const propTypes = {
@ -11,9 +11,10 @@ const propTypes = {
isBarChart: PropTypes.bool,
stats: PropTypes.object,
max: PropTypes.number,
highlightedStats: PropTypes.object,
};
const generateGraphData = (title, isBarChart, labels, data) => ({
const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({
labels,
datasets: [
{
@ -31,23 +32,41 @@ const generateGraphData = (title, isBarChart, labels, data) => ({
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2,
},
],
highlightedData && {
title,
label: 'Selected',
data: highlightedData,
backgroundColor: 'rgba(247, 127, 40, 0.4)',
borderColor: '#F77F28',
borderWidth: 2,
},
].filter(Boolean),
});
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const renderGraph = (title, isBarChart, stats, max) => {
const renderGraph = (title, isBarChart, stats, max, highlightedStats) => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(stats);
const data = values(!highlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
if (acc[highlightedKey]) {
acc[highlightedKey] -= highlightedStats[highlightedKey];
}
return acc;
}, { ...stats }));
const highlightedData = highlightedStats && values({ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats });
const options = {
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart && {
xAxes: [
{
ticks: { beginAtZero: true, max },
stacked: true,
},
],
yAxes: [{ stacked: true }],
},
tooltips: {
intersect: !isBarChart,
@ -56,17 +75,17 @@ const renderGraph = (title, isBarChart, stats, max) => {
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
},
};
const graphData = generateGraphData(title, isBarChart, labels, data);
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData);
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
return <Component key={height} data={graphData} options={options} height={height} />;
};
const GraphCard = ({ title, footer, isBarChart, stats, max }) => (
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats }) => (
<Card className="mt-4">
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody>
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card>
);

View file

@ -1,66 +1,101 @@
import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react';
import { Card } from 'reactstrap';
import { isEmpty, values } from 'ramda';
import React, { useState, useEffect } from 'react';
import { Button, Card, Collapse } from 'reactstrap';
import PropTypes from 'prop-types';
import qs from 'qs';
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 { shortUrlVisitsType } from './reducers/shortUrlVisits';
import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import VisitsTable from './VisitsTable';
const ShortUrlVisits = (
{ processStatsFromVisits },
OpenMapModalBtn
) => class ShortUrlVisits extends React.PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
};
const propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
getShortUrlVisits: PropTypes.func,
shortUrlVisits: shortUrlVisitsType,
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func,
matchMedia: PropTypes.func,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = (loadDetail = false) => {
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
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 memoizationId;
let timeWhenMounted;
const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
const ShortUrlVisitsComp = ({
match,
location,
shortUrlVisits,
shortUrlDetail,
getShortUrlVisits,
getShortUrlDetail,
cancelGetShortUrlVisits,
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 { params } = match;
const { shortCode } = params;
const { startDate, endDate } = mapObjIndexed(formatDate(), this.state);
const { search } = location;
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations
this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`;
getShortUrlVisits(shortCode, { startDate, endDate, domain });
const loadVisits = () => {
const start = format(startDate);
const end = format(endDate);
if (loadDetail) {
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations
memoizationId = `${timeWhenMounted}_${shortCode}_${start}_${end}`;
getShortUrlVisits(shortCode, { startDate: start, endDate: end, domain });
};
useEffect(() => {
timeWhenMounted = new Date().getTime();
getShortUrlDetail(shortCode, domain);
}
};
determineIsMobileDevice();
window.addEventListener('resize', determineIsMobileDevice);
componentDidMount() {
this.timeWhenMounted = new Date().getTime();
this.loadVisits(true);
}
return () => {
cancelGetShortUrlVisits();
window.removeEventListener('resize', determineIsMobileDevice);
};
}, []);
useEffect(() => {
loadVisits();
}, [ startDate, endDate ]);
componentWillUnmount() {
this.props.cancelGetShortUrlVisits();
}
render() {
const { shortUrlVisits, shortUrlDetail } = this.props;
const { visits, loading, loadingLarge, error } = shortUrlVisits;
const showTableControls = !loading && visits.length > 0;
const renderVisitsContent = () => {
const { visits, loading, loadingLarge, error } = shortUrlVisits;
if (loading) {
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
@ -80,7 +115,7 @@ const ShortUrlVisits = (
}
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
{ id: this.memoizationId, visits }
{ id: memoizationId, visits }
);
const mapLocations = values(citiesForMap);
@ -94,9 +129,10 @@ const ShortUrlVisits = (
</div>
<div className="col-xl-4">
<SortableBarGraph
title="Referrers"
stats={referrers}
withPagination={false}
title="Referrers"
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
@ -105,8 +141,9 @@ const ShortUrlVisits = (
</div>
<div className="col-lg-6">
<SortableBarGraph
stats={countries}
title="Countries"
stats={countries}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
sortingItems={{
name: 'Country name',
amount: 'Visits amount',
@ -115,11 +152,12 @@ const ShortUrlVisits = (
</div>
<div className="col-lg-6">
<SortableBarGraph
stats={cities}
title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
extraHeaderContent={(activeCities) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'City name',
@ -130,27 +168,58 @@ const ShortUrlVisits = (
</div>
);
};
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
return (
<React.Fragment>
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<section className="mt-4">
<DateRangeRow
startDate={this.state.startDate}
endDate={this.state.endDate}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
<div className="row flex-md-row-reverse">
<div className="col-lg-8 col-xl-6">
<DateRangeRow
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
<div className="col-lg-4 col-xl-6 mt-4 mt-lg-0">
{showTableControls && (
<Button
outline
block={isMobileDevice}
onClick={toggleTable}
>
{showTable ? 'Hide' : 'Show'} table{' '}
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} />
</Button>
)}
</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={visits} isSticky={tableIsSticky} onVisitsSelected={setHighlightedVisits} />
</Collapse>
)}
<section>
{renderVisitsContent()}
</section>
</React.Fragment>
);
}
};
ShortUrlVisitsComp.propTypes = propTypes;
return ShortUrlVisitsComp;
};
export default ShortUrlVisits;

View file

@ -3,7 +3,8 @@ import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda';
import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf, roundTen } from '../utils/utils';
import { rangeOf } from '../utils/utils';
import { roundTen } from '../utils/helpers/numbers';
import SimplePaginator from '../common/SimplePaginator';
import GraphCard from './GraphCard';
@ -14,6 +15,7 @@ const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component {
static propTypes = {
stats: PropTypes.object.isRequired,
highlightedStats: PropTypes.object,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func,
@ -72,7 +74,7 @@ export default class SortableBarGraph extends React.Component {
}
render() {
const { stats, sortingItems, title, extraHeaderContent, withPagination = true } = this.props;
const { stats, sortingItems, title, extraHeaderContent, highlightedStats, withPagination = true } = this.props;
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
@ -106,6 +108,15 @@ export default class SortableBarGraph extends React.Component {
</React.Fragment>
);
return <GraphCard isBarChart title={computeTitle} stats={currentPageStats} footer={pagination} max={max} />;
return (
<GraphCard
isBarChart
title={computeTitle}
stats={currentPageStats}
footer={pagination}
max={max}
highlightedStats={highlightedStats}
/>
);
}
}

210
src/visits/VisitsTable.js Normal file
View file

@ -0,0 +1,210 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import Moment from 'react-moment';
import classNames from 'classnames';
import { map, min, splitEvery } from 'ramda';
import {
faCaretDown as caretDownIcon,
faCaretUp as caretUpIcon,
faCheck as checkIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SimplePaginator from '../common/SimplePaginator';
import SearchField from '../utils/SearchField';
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../utils/helpers/visits';
import { determineOrderDir } from '../utils/utils';
import { prettify } from '../utils/helpers/numbers';
import { visitType } from './reducers/shortUrlVisits';
import './VisitsTable.scss';
const propTypes = {
visits: PropTypes.arrayOf(visitType).isRequired,
onVisitsSelected: PropTypes.func,
isSticky: PropTypes.bool,
matchMedia: PropTypes.func,
};
const PAGE_SIZE = 20;
const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) =>
`${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase());
const searchVisits = (searchTerm, visits) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => {
const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1;
return a[field] > b[field] ? greaterThan : smallerThan;
});
const calculateVisits = (allVisits, searchTerm, order) => {
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : allVisits;
const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits;
const total = sortedVisits.length;
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
return { visitsGroups, total };
};
const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({
date,
browser: browserFromUserAgent(userAgent),
os: osFromUserAgent(userAgent),
referer: extractDomain(referer),
country: (visitLocation && visitLocation.countryName) || 'Unknown',
city: (visitLocation && visitLocation.cityName) || 'Unknown',
}));
const VisitsTable = ({ visits, onVisitsSelected, isSticky = false, matchMedia = window.matchMedia }) => {
const allVisits = normalizeVisits(visits);
const headerCellsClass = classNames('visits-table__header-cell', {
'visits-table__sticky': isSticky,
});
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
const [ selectedVisits, setSelectedVisits ] = useState([]);
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
const [ searchTerm, setSearchTerm ] = useState(undefined);
const [ order, setOrder ] = useState({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(allVisits, searchTerm, order), [ searchTerm, order ]);
const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE;
const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
const renderOrderIcon = (field) => order.dir && order.field === field && (
<FontAwesomeIcon
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
className="visits-table__header-icon"
/>
);
useEffect(() => {
onVisitsSelected && onVisitsSelected(selectedVisits);
}, [ selectedVisits ]);
useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
useEffect(() => {
setPage(1);
setSelectedVisits([]);
}, [ searchTerm ]);
return (
<table className="table table-striped table-bordered table-hover table-sm visits-table">
<thead className="visits-table__header">
<tr>
<th
className={classNames('visits-table__header-cell text-center', {
'visits-table__sticky': isSticky,
})}
onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : []
)}
>
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th>
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('country')}>
Country
{renderOrderIcon('country')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('city')}>
City
{renderOrderIcon('city')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
Browser
{renderOrderIcon('browser')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('os')}>
OS
{renderOrderIcon('os')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
Referrer
{renderOrderIcon('referer')}
</th>
</tr>
<tr>
<td colSpan={7} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
</thead>
<tbody>
{(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && (
<tr>
<td colSpan={7} className="text-center">
No visits found with current filtering
</td>
</tr>
)}
{resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => {
const isSelected = selectedVisits.includes(visit);
return (
<tr
key={index}
style={{ cursor: 'pointer' }}
className={classNames({ 'table-primary': isSelected })}
onClick={() => setSelectedVisits(
isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ]
)}
>
<td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td>
<td>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td>
<td>{visit.country}</td>
<td>{visit.city}</td>
<td>{visit.browser}</td>
<td>{visit.os}</td>
<td>{visit.referer}</td>
</tr>
);
})}
</tbody>
{resultSet.total > PAGE_SIZE && (
<tfoot>
<tr>
<td colSpan={7} className={classNames('visits-table__footer-cell', { 'visits-table__sticky': isSticky })}>
<div className="row">
<div className="col-md-6">
<SimplePaginator
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
currentPage={page}
setCurrentPage={setPage}
centered={isMobileDevice}
/>
</div>
<div
className={classNames('col-md-6', {
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
'text-center mt-3': isMobileDevice,
})}
>
<div>
Visits <b>{prettify(start + 1)}</b> to{' '}
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
<b>{prettify(resultSet.total)}</b>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
)}
</table>
);
};
VisitsTable.propTypes = propTypes;
export default VisitsTable;

View file

@ -0,0 +1,35 @@
@import '../utils/base';
@import '../utils/mixins/sticky-cell';
.visits-table {
margin: 1.5rem 0 0;
position: relative;
}
.visits-table__header-cell {
cursor: pointer;
margin-bottom: 55px;
@include sticky-cell();
&.visits-table__sticky {
top: $headerHeight - 2px;
}
}
.visits-table__header-icon {
float: right;
margin-top: 3px;
}
.visits-table__footer-cell.visits-table__footer-cell {
bottom: 0;
margin-top: 34px;
padding: .5rem;
@include sticky-cell();
}
.visits-table__sticky.visits-table__sticky {
position: sticky;
}

View file

@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import * as PropTypes from 'prop-types';
import { useToggle } from '../../utils/helpers/hooks';
import './OpenMapModalBtn.scss';
const propTypes = {
@ -13,26 +14,25 @@ const propTypes = {
const OpenMapModalBtn = (MapModal) => {
const OpenMapModalBtn = ({ modalTitle, locations = [], activeCities }) => {
const [ mapIsOpened, setMapIsOpened ] = useState(false);
const [ dropdownIsOpened, setDropdownIsOpened ] = useState(false);
const [ mapIsOpened, , openMap, closeMap ] = useToggle();
const [ dropdownIsOpened, toggleDropdown, openDropdown ] = useToggle();
const [ locationsToShow, setLocationsToShow ] = useState([]);
const buttonRef = React.createRef();
const filterLocations = (locations) => locations.filter(({ cityName }) => activeCities.includes(cityName));
const toggleMap = () => setMapIsOpened(!mapIsOpened);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
setMapIsOpened(true);
openMap();
return;
}
setDropdownIsOpened(true);
openDropdown();
};
const openMapWithLocations = (filtered) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations);
setMapIsOpened(true);
openMap();
};
return (
@ -41,13 +41,13 @@ const OpenMapModalBtn = (MapModal) => {
<FontAwesomeIcon icon={mapIcon} />
</button>
<UncontrolledTooltip placement="left" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={() => setDropdownIsOpened(!dropdownIsOpened)} inNavbar>
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
<DropdownMenu right>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu>
</Dropdown>
<MapModal toggle={toggleMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</React.Fragment>
);
};

View file

@ -10,8 +10,24 @@ export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_V
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
/* 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.array,
visits: PropTypes.arrayOf(visitType),
loading: PropTypes.bool,
error: PropTypes.bool,
});

View file

@ -1,46 +1,5 @@
import { isNil, isEmpty, memoizeWith, prop } from 'ramda';
const osFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case lowerUserAgent.indexOf('linux') >= 0:
return 'Linux';
case lowerUserAgent.indexOf('windows') >= 0:
return 'Windows';
case lowerUserAgent.indexOf('mac') >= 0:
return 'MacOS';
case lowerUserAgent.indexOf('mobi') >= 0:
return 'Mobile';
default:
return 'Others';
}
};
const browserFromUserAgent = (userAgent) => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case lowerUserAgent.indexOf('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0:
return 'Opera';
case lowerUserAgent.indexOf('firefox') >= 0:
return 'Firefox';
case lowerUserAgent.indexOf('chrome') >= 0:
return 'Chrome';
case lowerUserAgent.indexOf('safari') >= 0:
return 'Safari';
case lowerUserAgent.indexOf('msie') >= 0:
return 'Internet Explorer';
default:
return 'Others';
}
};
const extractDomain = (url) => {
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0];
};
import { isEmpty, isNil, memoizeWith, prop } from 'ramda';
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../../utils/helpers/visits';
const visitLocationHasProperty = (visitLocation, propertyName) =>
!isNil(visitLocation)
@ -48,20 +7,19 @@ const visitLocationHasProperty = (visitLocation, propertyName) =>
&& !isEmpty(visitLocation[propertyName]);
const updateOsStatsForVisit = (osStats, { userAgent }) => {
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
const os = osFromUserAgent(userAgent);
osStats[os] = (osStats[os] || 0) + 1;
};
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
const browser = browserFromUserAgent(userAgent);
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
};
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
const notHasDomain = isNil(referer) || isEmpty(referer);
const domain = notHasDomain ? 'Direct' : extractDomain(referer);
const domain = extractDomain(referer);
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
};

View file

@ -0,0 +1,20 @@
import { roundTen } from '../../../src/utils/helpers/numbers';
describe('numbers', () => {
describe('roundTen', () => {
it('rounds provided number to the next multiple of ten', () => {
const expectationsPairs = [
[ 10, 10 ],
[ 12, 20 ],
[ 158, 160 ],
[ 5, 10 ],
[ -42, -40 ],
];
expect.assertions(expectationsPairs.length);
expectationsPairs.forEach(([ number, expected ]) => {
expect(roundTen(number)).toEqual(expected);
});
});
});
});

View file

@ -7,7 +7,6 @@ import {
determineOrderDir,
fixLeafletIcons,
rangeOf,
roundTen,
} from '../../src/utils/utils';
describe('utils', () => {
@ -86,21 +85,4 @@ describe('utils', () => {
]);
});
});
describe('roundTen', () => {
it('rounds provided number to the next multiple of ten', () => {
const expectationsPairs = [
[ 10, 10 ],
[ 12, 20 ],
[ 158, 160 ],
[ 5, 10 ],
[ -42, -40 ],
];
expect.assertions(expectationsPairs.length);
expectationsPairs.forEach(([ number, expected ]) => {
expect(roundTen(number)).toEqual(expected);
});
});
});
});

View file

@ -10,24 +10,25 @@ describe('<GraphCard />', () => {
foo: 123,
bar: 456,
};
const matchMedia = () => ({ matches: false });
afterEach(() => wrapper && wrapper.unmount());
it('renders Doughnut when is not a bar chart', () => {
wrapper = shallow(<GraphCard matchMedia={matchMedia} title="The chart" stats={stats} />);
wrapper = shallow(<GraphCard title="The chart" stats={stats} />);
const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar);
expect(doughnut).toHaveLength(1);
expect(horizontal).toHaveLength(0);
const { labels, datasets: [{ title, data, backgroundColor, borderColor }] } = doughnut.prop('data');
const { labels, datasets } = doughnut.prop('data');
const [{ title, data, backgroundColor, borderColor }] = datasets;
const { legend, scales } = doughnut.prop('options');
expect(title).toEqual('The chart');
expect(labels).toEqual(keys(stats));
expect(data).toEqual(values(stats));
expect(datasets).toHaveLength(1);
expect(backgroundColor).toEqual([
'#97BBCD',
'#DCDCDC',
@ -43,7 +44,7 @@ describe('<GraphCard />', () => {
});
it('renders HorizontalBar when is not a bar chart', () => {
wrapper = shallow(<GraphCard matchMedia={matchMedia} isBarChart title="The chart" stats={stats} />);
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} />);
const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar);
@ -60,8 +61,27 @@ describe('<GraphCard />', () => {
xAxes: [
{
ticks: { beginAtZero: true },
stacked: true,
},
],
yAxes: [{ stacked: true }],
});
});
it.each([
[{ foo: 23 }, [ 100, 456 ], [ 23, 0 ]],
[{ foo: 50 }, [ 73, 456 ], [ 50, 0 ]],
[{ bar: 45 }, [ 123, 411 ], [ 0, 45 ]],
[{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]],
[ undefined, [ 123, 456 ], undefined ],
])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => {
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} highlightedStats={highlightedStats} />);
const horizontal = wrapper.find(HorizontalBar);
const { datasets: [{ data }, highlightedData ] } = horizontal.prop('data');
expect(data).toEqual(expectedData);
expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData);
!expectedHighlightedData && expect(highlightedData).toBeUndefined();
});
});

View file

@ -31,19 +31,17 @@ describe('<ShortUrlVisits />', () => {
shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}}
cancelGetShortUrlVisits={identity}
matchMedia={() => ({ matches: false })}
/>
);
return wrapper;
};
afterEach(() => {
getShortUrlVisitsMock.mockReset();
wrapper && wrapper.unmount();
});
afterEach(() => wrapper && wrapper.unmount());
it('renders a preloader when visits are loading', () => {
const wrapper = createComponent({ loading: true });
const wrapper = createComponent({ loading: true, visits: [] });
const loadingMessage = wrapper.find(Message);
expect(loadingMessage).toHaveLength(1);
@ -51,7 +49,7 @@ describe('<ShortUrlVisits />', () => {
});
it('renders a warning when loading large amounts of visits', () => {
const wrapper = createComponent({ loading: true, loadingLarge: true });
const wrapper = createComponent({ loading: true, loadingLarge: true, visits: [] });
const loadingMessage = wrapper.find(Message);
expect(loadingMessage).toHaveLength(1);
@ -59,7 +57,7 @@ describe('<ShortUrlVisits />', () => {
});
it('renders an error message when visits could not be loaded', () => {
const wrapper = createComponent({ loading: false, error: true });
const wrapper = createComponent({ loading: false, error: true, visits: [] });
const errorMessage = wrapper.find(Card);
expect(errorMessage).toHaveLength(1);
@ -90,9 +88,8 @@ describe('<ShortUrlVisits />', () => {
dateRange.simulate('endDateChange', '2016-01-02T00:00:00+01:00');
dateRange.simulate('endDateChange', '2016-01-03T00:00:00+01:00');
expect(getShortUrlVisitsMock).toHaveBeenCalledTimes(4);
expect(wrapper.state('startDate')).toEqual('2016-01-01T00:00:00+01:00');
expect(wrapper.state('endDate')).toEqual('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', () => {

View file

@ -0,0 +1,128 @@
import React from 'react';
import { shallow } from 'enzyme';
import VisitsTable from '../../src/visits/VisitsTable';
import { rangeOf } from '../../src/utils/utils';
import SimplePaginator from '../../src/common/SimplePaginator';
import SearchField from '../../src/utils/SearchField';
describe('<VisitsTable />', () => {
const matchMedia = () => ({ matches: false });
let wrapper;
const createWrapper = (visits) => {
wrapper = shallow(<VisitsTable visits={visits} matchMedia={matchMedia} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it('renders columns as expected', () => {
const wrapper = createWrapper([]);
const th = wrapper.find('thead').find('th');
expect(th).toHaveLength(7);
expect(th.at(1).text()).toContain('Date');
expect(th.at(2).text()).toContain('Country');
expect(th.at(3).text()).toContain('City');
expect(th.at(4).text()).toContain('Browser');
expect(th.at(5).text()).toContain('OS');
expect(th.at(6).text()).toContain('Referrer');
});
it('shows warning when no visits are found', () => {
const wrapper = createWrapper([]);
const td = wrapper.find('tbody').find('td');
expect(td).toHaveLength(1);
expect(td.text()).toContain('No visits found with current filtering');
});
it.each([
[ 50, 3 ],
[ 21, 2 ],
[ 30, 2 ],
[ 60, 3 ],
[ 115, 6 ],
])('renders the expected amount of pages', (visitsCount, expectedAmountOfPages) => {
const wrapper = createWrapper(rangeOf(visitsCount, () => ({ userAgent: '', date: '', referer: '' })));
const tr = wrapper.find('tbody').find('tr');
const paginator = wrapper.find(SimplePaginator);
expect(tr).toHaveLength(20);
expect(paginator.prop('pagesCount')).toEqual(expectedAmountOfPages);
});
it.each(
rangeOf(20, (value) => [ value ])
)('does not render footer when there is only one page to render', (visitsCount) => {
const wrapper = createWrapper(rangeOf(visitsCount, () => ({ userAgent: '', date: '', referer: '' })));
const tr = wrapper.find('tbody').find('tr');
const paginator = wrapper.find(SimplePaginator);
expect(tr).toHaveLength(visitsCount);
expect(paginator).toHaveLength(0);
});
it('selected rows are highlighted', () => {
const wrapper = createWrapper(rangeOf(10, () => ({ userAgent: '', date: '', referer: '' })));
expect(wrapper.find('.text-primary')).toHaveLength(0);
expect(wrapper.find('.table-primary')).toHaveLength(0);
wrapper.find('tr').at(5).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(2);
expect(wrapper.find('.table-primary')).toHaveLength(1);
wrapper.find('tr').at(3).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(3);
expect(wrapper.find('.table-primary')).toHaveLength(2);
wrapper.find('tr').at(3).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(2);
expect(wrapper.find('.table-primary')).toHaveLength(1);
// Select all
wrapper.find('thead').find('th').at(0).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(11);
expect(wrapper.find('.table-primary')).toHaveLength(10);
// Select none
wrapper.find('thead').find('th').at(0).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(0);
expect(wrapper.find('.table-primary')).toHaveLength(0);
});
it('orders visits when column is clicked', () => {
const wrapper = createWrapper(rangeOf(9, (index) => ({
userAgent: '',
date: `${9 - index}`,
referer: `${index}`,
visitLocation: {
countryName: `Country_${index}`,
},
})));
expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_1');
wrapper.find('thead').find('th').at(1).simulate('click'); // Date column ASC
expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_9');
wrapper.find('thead').find('th').at(6).simulate('click'); // Referer column - ASC
expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_1');
wrapper.find('thead').find('th').at(6).simulate('click'); // Referer column - DESC
expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_9');
wrapper.find('thead').find('th').at(6).simulate('click'); // Referer column - reset
expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_1');
});
it('filters list when writing in search box', () => {
const wrapper = createWrapper([
...rangeOf(7, () => ({ userAgent: 'aaa', date: 'aaa', referer: 'aaa' })),
...rangeOf(2, () => ({ userAgent: 'bbb', date: 'bbb', referer: 'bbb' })),
]);
const searchField = wrapper.find(SearchField);
expect(wrapper.find('tbody').find('tr')).toHaveLength(7 + 2);
searchField.simulate('change', 'aa');
expect(wrapper.find('tbody').find('tr')).toHaveLength(7);
searchField.simulate('change', 'bb');
expect(wrapper.find('tbody').find('tr')).toHaveLength(2);
searchField.simulate('change', '');
expect(wrapper.find('tbody').find('tr')).toHaveLength(7 + 2);
});
});