diff --git a/.eslintrc b/.eslintrc index 3a282c9c..bf19fb0c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -29,6 +29,7 @@ "no-magic-numbers": "off", "no-undefined": "off", "no-inline-comments": "off", + "lines-around-comment": "off", "indent": ["error", 2, { "SwitchCase": 1 } diff --git a/CHANGELOG.md b/CHANGELOG.md index bc98c657..654cbc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/common/MenuLayout.js b/src/common/MenuLayout.js index ad63d427..8bdb577d 100644 --- a/src/common/MenuLayout.js +++ b/src/common/MenuLayout.js @@ -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 ; } 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 ( - setShowSidebar(!showSideBar)} - /> +
- -
setShowSidebar(false)}> + +
hideSidebar()}>
diff --git a/src/common/SimplePaginator.js b/src/common/SimplePaginator.js index f87b35f9..0859e4ff 100644 --- a/src/common/SimplePaginator.js +++ b/src/common/SimplePaginator.js @@ -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 ( - + diff --git a/src/servers/DeleteServerButton.js b/src/servers/DeleteServerButton.js index e50610bc..23cab6e4 100644 --- a/src/servers/DeleteServerButton.js +++ b/src/servers/DeleteServerButton.js @@ -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 ( - setModalOpen(true)}> + {!children && } {children || 'Remove this server'} - setModalOpen(!isModalOpen)} /> + ); }; diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 32544d9e..d2ee0cae 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -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 = {}) => ( diff --git a/src/short-urls/SearchBar.js b/src/short-urls/SearchBar.js index efa574c2..2bda7245 100644 --- a/src/short-urls/SearchBar.js +++ b/src/short-urls/SearchBar.js @@ -36,12 +36,16 @@ const SearchBar = (colorGenerator, ForServerVersion) => {
- +
+
+ +
+
diff --git a/src/short-urls/UseExistingIfFoundInfoIcon.js b/src/short-urls/UseExistingIfFoundInfoIcon.js index cfbd2c54..803d4b23 100644 --- a/src/short-urls/UseExistingIfFoundInfoIcon.js +++ b/src/short-urls/UseExistingIfFoundInfoIcon.js @@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => ( ); const UseExistingIfFoundInfoIcon = () => { - const [ isModalOpen, toggleModal ] = useToggle(false); + const [ isModalOpen, toggleModal ] = useToggle(); return ( diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.js b/src/short-urls/helpers/ShortUrlsRowMenu.js index fe490cc8..ae6dc1e7 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.js +++ b/src/short-urls/helpers/ShortUrlsRowMenu.js @@ -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 ( diff --git a/src/utils/DateRangeRow.js b/src/utils/DateRangeRow.js index 24f3ed29..376ee671 100644 --- a/src/utils/DateRangeRow.js +++ b/src/utils/DateRangeRow.js @@ -13,7 +13,7 @@ const propTypes = { const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
-
+
-
+

{loading && } - {loading && !children && Loading...} - {children} + {loading && {children || 'Loading...'}} + {!loading && children}

diff --git a/src/utils/SearchField.js b/src/utils/SearchField.js index a9e4a527..2b460fa8 100644 --- a/src/utils/SearchField.js +++ b/src/utils/SearchField.js @@ -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 ( -
- this.searchTermChanged(e.target.value)} - /> - - + return ( +
+ searchTermChanged(e.target.value)} + /> + + - ); - } -} +
+ ); +}; + +SearchField.propTypes = propTypes; + +export default SearchField; diff --git a/src/utils/SearchField.scss b/src/utils/SearchField.scss index b4c73165..5877b9ce 100644 --- a/src/utils/SearchField.scss +++ b/src/utils/SearchField.scss @@ -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(); diff --git a/src/utils/base.scss b/src/utils/base.scss index 5038fdfb..69fdacd1 100644 --- a/src/utils/base.scss +++ b/src/utils/base.scss @@ -13,6 +13,7 @@ $mainColor: #4696e5; $lightHoverColor: #eee; $lightGrey: #ddd; $dangerColor: #dc3545; +$mediumGrey: #dee2e6; // Misc $headerHeight: 57px; diff --git a/src/utils/helpers/hooks.js b/src/utils/helpers/hooks.js index 3eef15b5..81a60517 100644 --- a/src/utils/helpers/hooks.js +++ b/src/utils/helpers/hooks.js @@ -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) ]; }; diff --git a/src/utils/helpers/numbers.js b/src/utils/helpers/numbers.js new file mode 100644 index 00000000..b9e0757f --- /dev/null +++ b/src/utils/helpers/numbers.js @@ -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; diff --git a/src/utils/helpers/visits.js b/src/utils/helpers/visits.js new file mode 100644 index 00000000..754cbb19 --- /dev/null +++ b/src/utils/helpers/visits.js @@ -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]; +}; diff --git a/src/utils/mixins/sticky-cell.scss b/src/utils/mixins/sticky-cell.scss new file mode 100644 index 00000000..71d48ede --- /dev/null +++ b/src/utils/mixins/sticky-cell.scss @@ -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; + } +} diff --git a/src/utils/utils.js b/src/utils/utils.js index ee2ee170..db527df6 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -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); diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index 253f4011..5814739e 100644 --- a/src/visits/GraphCard.js +++ b/src/visits/GraphCard.js @@ -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 ; }; -const GraphCard = ({ title, footer, isBarChart, stats, max }) => ( +const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats }) => ( {typeof title === 'function' ? title() : title} - {renderGraph(title, isBarChart, stats, max)} + {renderGraph(title, isBarChart, stats, max, highlightedStats)} {footer && {footer}} ); diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index a38b03c8..fd6026e2 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -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 = (
mapLocations.length > 0 && - + } sortingItems={{ name: 'City name', @@ -130,27 +168,58 @@ const ShortUrlVisits = (
); }; - const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits); return (
- +
+
+ +
+
+ {showTableControls && ( + + )} +
+
+ {showTableControls && ( + + + + )} +
{renderVisitsContent()}
); - } + }; + + ShortUrlVisitsComp.propTypes = propTypes; + + return ShortUrlVisitsComp; }; export default ShortUrlVisits; diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js index 7e236e76..54a1bd9b 100644 --- a/src/visits/SortableBarGraph.js +++ b/src/visits/SortableBarGraph.js @@ -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 { ); - return ; + return ( + + ); } } diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js new file mode 100644 index 00000000..bd4dcaf1 --- /dev/null +++ b/src/visits/VisitsTable.js @@ -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 && ( + + ); + + 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 ( + + + + + + + + + + + + + + + + + {(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && ( + + + + )} + {resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => { + const isSelected = selectedVisits.includes(visit); + + return ( + setSelectedVisits( + isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ] + )} + > + + + + + + + + + ); + })} + + {resultSet.total > PAGE_SIZE && ( + + + + + + )} +
setSelectedVisits( + selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [] + )} + > + 0 })} /> + + Date + {renderOrderIcon('date')} + + Country + {renderOrderIcon('country')} + + City + {renderOrderIcon('city')} + + Browser + {renderOrderIcon('browser')} + + OS + {renderOrderIcon('os')} + + Referrer + {renderOrderIcon('referer')} +
+ +
+ No visits found with current filtering +
+ {isSelected && } + + {visit.date} + {visit.country}{visit.city}{visit.browser}{visit.os}{visit.referer}
+
+
+ +
+
+
+ Visits {prettify(start + 1)} to{' '} + {prettify(min(end, resultSet.total))} of{' '} + {prettify(resultSet.total)} +
+
+
+
+ ); +}; + +VisitsTable.propTypes = propTypes; + +export default VisitsTable; diff --git a/src/visits/VisitsTable.scss b/src/visits/VisitsTable.scss new file mode 100644 index 00000000..9249df8b --- /dev/null +++ b/src/visits/VisitsTable.scss @@ -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; +} diff --git a/src/visits/helpers/OpenMapModalBtn.js b/src/visits/helpers/OpenMapModalBtn.js index 5d3ec58b..fab798c3 100644 --- a/src/visits/helpers/OpenMapModalBtn.js +++ b/src/visits/helpers/OpenMapModalBtn.js @@ -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) => { buttonRef.current}>Show in map - setDropdownIsOpened(!dropdownIsOpened)} inNavbar> + Show all locations Show locations in current page - + ); }; diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index ac6b2dd5..2d4256d9 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -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, }); diff --git a/src/visits/services/VisitsParser.js b/src/visits/services/VisitsParser.js index 004f8e30..32cdf29d 100644 --- a/src/visits/services/VisitsParser.js +++ b/src/visits/services/VisitsParser.js @@ -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; }; diff --git a/test/utils/helpers/numbers.test.js b/test/utils/helpers/numbers.test.js new file mode 100644 index 00000000..3802f6d6 --- /dev/null +++ b/test/utils/helpers/numbers.test.js @@ -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); + }); + }); + }); +}); diff --git a/test/utils/utils.test.js b/test/utils/utils.test.js index 94ac847c..512053fe 100644 --- a/test/utils/utils.test.js +++ b/test/utils/utils.test.js @@ -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); - }); - }); - }); }); diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js index 2b7d2645..545e8c22 100644 --- a/test/visits/GraphCard.test.js +++ b/test/visits/GraphCard.test.js @@ -10,24 +10,25 @@ describe('', () => { foo: 123, bar: 456, }; - const matchMedia = () => ({ matches: false }); afterEach(() => wrapper && wrapper.unmount()); it('renders Doughnut when is not a bar chart', () => { - wrapper = shallow(); + wrapper = shallow(); 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('', () => { }); it('renders HorizontalBar when is not a bar chart', () => { - wrapper = shallow(); + wrapper = shallow(); const doughnut = wrapper.find(Doughnut); const horizontal = wrapper.find(HorizontalBar); @@ -60,8 +61,27 @@ describe('', () => { 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(); + 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(); + }); }); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 785f216a..1adae436 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -31,19 +31,17 @@ describe('', () => { 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('', () => { }); 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('', () => { }); 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('', () => { 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', () => { diff --git a/test/visits/VisitsTable.test.js b/test/visits/VisitsTable.test.js new file mode 100644 index 00000000..b9485e1c --- /dev/null +++ b/test/visits/VisitsTable.test.js @@ -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('', () => { + const matchMedia = () => ({ matches: false }); + let wrapper; + const createWrapper = (visits) => { + wrapper = shallow(); + + 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); + }); +});