mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-11 02:37:22 +03:00
Merge pull request #242 from acelaya-forks/feature/visits-table
Feature/visits table
This commit is contained in:
commit
05deb1aff0
32 changed files with 833 additions and 251 deletions
|
@ -29,6 +29,7 @@
|
|||
"no-magic-numbers": "off",
|
||||
"no-undefined": "off",
|
||||
"no-inline-comments": "off",
|
||||
"lines-around-comment": "off",
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 = {}) => (
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => (
|
|||
);
|
||||
|
||||
const UseExistingIfFoundInfoIcon = () => {
|
||||
const [ isModalOpen, toggleModal ] = useToggle(false);
|
||||
const [ isModalOpen, toggleModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
>
|
||||
×
|
||||
</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)}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SearchField.propTypes = propTypes;
|
||||
|
||||
export default SearchField;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ $mainColor: #4696e5;
|
|||
$lightHoverColor: #eee;
|
||||
$lightGrey: #ddd;
|
||||
$dangerColor: #dc3545;
|
||||
$mediumGrey: #dee2e6;
|
||||
|
||||
// Misc
|
||||
$headerHeight: 57px;
|
||||
|
|
|
@ -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) ];
|
||||
};
|
||||
|
|
8
src/utils/helpers/numbers.js
Normal file
8
src/utils/helpers/numbers.js
Normal 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;
|
59
src/utils/helpers/visits.js
Normal file
59
src/utils/helpers/visits.js
Normal 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];
|
||||
};
|
37
src/utils/mixins/sticky-cell.scss
Normal file
37
src/utils/mixins/sticky-cell.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
210
src/visits/VisitsTable.js
Normal 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;
|
35
src/visits/VisitsTable.scss
Normal file
35
src/visits/VisitsTable.scss
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
20
test/utils/helpers/numbers.test.js
Normal file
20
test/utils/helpers/numbers.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
128
test/visits/VisitsTable.test.js
Normal file
128
test/visits/VisitsTable.test.js
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue