From e6034dfb1493d0e9ad6a43c5911cdfd58aa7d39d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 3 Apr 2020 23:00:57 +0200 Subject: [PATCH 01/15] Created VisitsTable --- src/common/SimplePaginator.js | 6 +- src/short-urls/SearchBar.js | 16 ++- src/utils/DateRangeRow.js | 4 +- src/utils/SearchField.js | 11 +- src/utils/SearchField.scss | 5 + src/utils/helpers/visits.js | 59 +++++++++ src/visits/ShortUrlVisits.js | 44 +++++-- src/visits/VisitsTable.js | 176 ++++++++++++++++++++++++++ src/visits/VisitsTable.scss | 4 + src/visits/reducers/shortUrlVisits.js | 18 ++- src/visits/services/VisitsParser.js | 52 +------- 11 files changed, 325 insertions(+), 70 deletions(-) create mode 100644 src/utils/helpers/visits.js create mode 100644 src/visits/VisitsTable.js create mode 100644 src/visits/VisitsTable.scss 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/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/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 }) => (
-
+
-
+
this.searchTermChanged(e.target.value)} diff --git a/src/utils/SearchField.scss b/src/utils/SearchField.scss index b4c73165..7ba46422 100644 --- a/src/utils/SearchField.scss +++ b/src/utils/SearchField.scss @@ -9,6 +9,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/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/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index a38b03c8..20b5f504 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -1,8 +1,10 @@ import { isEmpty, mapObjIndexed, values } from 'ramda'; import React from 'react'; -import { Card } from 'reactstrap'; +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'; @@ -11,6 +13,7 @@ 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 }, @@ -30,7 +33,12 @@ const ShortUrlVisits = ( cancelGetShortUrlVisits: PropTypes.func, }; - state = { startDate: undefined, endDate: undefined }; + state = { + startDate: undefined, + endDate: undefined, + showTable: false, + }; + loadVisits = (loadDetail = false) => { const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props; const { shortCode } = params; @@ -57,10 +65,9 @@ const ShortUrlVisits = ( render() { const { shortUrlVisits, shortUrlDetail } = this.props; + const { visits, loading, loadingLarge, error } = shortUrlVisits; const renderVisitsContent = () => { - const { visits, loading, loadingLarge, error } = shortUrlVisits; - if (loading) { const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; @@ -137,14 +144,31 @@ const ShortUrlVisits = (
- +
+
+ +
+
+ {visits.length > 0 && ( + + )} +
+
+ {!loading && visits.length > 0 && ( + + + + )} +
{renderVisitsContent()}
diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js new file mode 100644 index 00000000..82fa3261 --- /dev/null +++ b/src/visits/VisitsTable.js @@ -0,0 +1,176 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import Moment from 'react-moment'; +import classNames from 'classnames'; +import { map } 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 { visitType } from './reducers/shortUrlVisits'; +import './VisitsTable.scss'; + +const propTypes = { + visits: PropTypes.arrayOf(visitType).isRequired, + onVisitSelected: PropTypes.func, +}; + +const PAGE_SIZE = 20; +const visitMatchesSearch = ({ browser, os, referer, location }, searchTerm) => + `${browser} ${os} ${referer} ${location}`.toLowerCase().includes(searchTerm.toLowerCase()); +const calculateVisits = (allVisits, page, searchTerm, { field, dir }) => { + const end = page * PAGE_SIZE; + const start = end - PAGE_SIZE; + const filteredVisits = searchTerm ? allVisits.filter((visit) => visitMatchesSearch(visit, searchTerm)) : allVisits; + const total = filteredVisits.length; + const visits = filteredVisits + .sort((a, b) => { + if (!dir) { + return 0; + } + + const greaterThan = dir === 'ASC' ? 1 : -1; + const smallerThan = dir === 'ASC' ? -1 : 1; + + return a[field] > b[field] ? greaterThan : smallerThan; + }) + .slice(start, end); + + return { visits, start, end, total }; +}; +const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({ + date, + browser: browserFromUserAgent(userAgent), + os: osFromUserAgent(userAgent), + referer: extractDomain(referer), + location: visitLocation ? `${visitLocation.countryName} - ${visitLocation.cityName}` : '', +})); + +const VisitsTable = ({ visits, onVisitSelected }) => { + const allVisits = normalizeVisits(visits); + + const [ selectedVisit, setSelectedVisit ] = useState(undefined); + const [ page, setPage ] = useState(1); + const [ searchTerm, setSearchTerm ] = useState(undefined); + const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); + const [ currentPage, setCurrentPageVisits ] = useState(calculateVisits(allVisits, page, searchTerm, order)); + + const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); + const renderOrderIcon = (field) => { + if (!order.dir || order.field !== field) { + return null; + } + + return ( + + ); + }; + + useEffect(() => { + onVisitSelected && onVisitSelected(selectedVisit); + }, [ selectedVisit ]); + useEffect(() => { + setCurrentPageVisits(calculateVisits(allVisits, page, searchTerm, order)); + }, [ page, searchTerm, order ]); + + return ( + + + + + + + + + + + + + + + + {currentPage.visits.length === 0 && ( + + + + )} + {currentPage.visits.map((visit, index) => ( + setSelectedVisit(selectedVisit === visit ? undefined : visit)} + > + + + + + + + + ))} + + {currentPage.total >= PAGE_SIZE && ( + + + + + + )} +
+ + + {renderOrderIcon('date')} + Date + + {renderOrderIcon('location')} + Location + + {renderOrderIcon('browser')} + Browser + + {renderOrderIcon('os')} + OS + + {renderOrderIcon('referer')} + Referrer +
+ +
+ No visits found with current filtering +
+ {selectedVisit === visit && } + + {visit.date} + {visit.location}{visit.browser}{visit.os}{visit.referer}
+
+
+ +
+
+
+ Visits {currentPage.start + 1} to {currentPage.end} of {currentPage.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..93b05f0c --- /dev/null +++ b/src/visits/VisitsTable.scss @@ -0,0 +1,4 @@ +.visits-table__header-icon { + float: right; + margin-top: 3px; +} 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; }; From 2bd70fb9e6f13ca62ec727d64d7ea0f483721709 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Apr 2020 10:36:34 +0200 Subject: [PATCH 02/15] Fixed unit tests --- src/utils/helpers/numbers.js | 4 ++++ src/utils/utils.js | 4 ---- src/visits/SortableBarGraph.js | 3 ++- src/visits/VisitsTable.js | 16 +++++++++------- test/utils/helpers/numbers.test.js | 20 ++++++++++++++++++++ test/utils/utils.test.js | 18 ------------------ test/visits/ShortUrlVisits.test.js | 6 +++--- 7 files changed, 38 insertions(+), 33 deletions(-) create mode 100644 src/utils/helpers/numbers.js create mode 100644 test/utils/helpers/numbers.test.js diff --git a/src/utils/helpers/numbers.js b/src/utils/helpers/numbers.js new file mode 100644 index 00000000..7cfe751b --- /dev/null +++ b/src/utils/helpers/numbers.js @@ -0,0 +1,4 @@ +const TEN_ROUNDING_NUMBER = 10; +const { ceil } = Math; + +export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER; 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/SortableBarGraph.js b/src/visits/SortableBarGraph.js index 7e236e76..3a538296 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'; diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js index 82fa3261..0f23837d 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.js @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import Moment from 'react-moment'; import classNames from 'classnames'; -import { map } from 'ramda'; +import { map, min } from 'ramda'; import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon, @@ -90,24 +90,24 @@ const VisitsTable = ({ visits, onVisitSelected }) => { - {renderOrderIcon('date')} Date + {renderOrderIcon('date')} - {renderOrderIcon('location')} Location + {renderOrderIcon('location')} - {renderOrderIcon('browser')} Browser + {renderOrderIcon('browser')} - {renderOrderIcon('os')} OS + {renderOrderIcon('os')} - {renderOrderIcon('referer')} Referrer + {renderOrderIcon('referer')} @@ -159,7 +159,9 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
- Visits {currentPage.start + 1} to {currentPage.end} of {currentPage.total} + Visits {currentPage.start + 1} to{' '} + {min(currentPage.end, currentPage.total)} of{' '} + {currentPage.total}
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/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 785f216a..48c7abc4 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -43,7 +43,7 @@ describe('', () => { }); 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 +51,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 +59,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); From 06b63d1af206004b57f629a99d9dd72bdb4482ba Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Apr 2020 12:09:17 +0200 Subject: [PATCH 03/15] Improved rendering of visits table on mobile devices --- src/visits/ShortUrlVisits.js | 28 ++++++++++-- src/visits/VisitsTable.js | 71 +++++++++++++++++++++--------- src/visits/VisitsTable.scss | 32 ++++++++++++++ test/visits/GraphCard.test.js | 5 +-- test/visits/ShortUrlVisits.test.js | 1 + 5 files changed, 110 insertions(+), 27 deletions(-) diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 20b5f504..d18dbd66 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -31,12 +31,15 @@ const ShortUrlVisits = ( getShortUrlDetail: PropTypes.func, shortUrlDetail: shortUrlDetailType, cancelGetShortUrlVisits: PropTypes.func, + matchMedia: PropTypes.func, }; state = { startDate: undefined, endDate: undefined, showTable: false, + tableIsSticky: false, + isMobileDevice: false, }; loadVisits = (loadDetail = false) => { @@ -54,13 +57,22 @@ const ShortUrlVisits = ( } }; + setIsMobileDevice = () => { + const { matchMedia = window.matchMedia } = this.props; + + this.setState({ isMobileDevice: matchMedia('(max-width: 991px)').matches }); + }; + componentDidMount() { this.timeWhenMounted = new Date().getTime(); this.loadVisits(true); + this.setIsMobileDevice(); + window.addEventListener('resize', this.setIsMobileDevice); } componentWillUnmount() { this.props.cancelGetShortUrlVisits(); + window.removeEventListener('resize', this.setIsMobileDevice); } render() { @@ -155,7 +167,11 @@ const ShortUrlVisits = (
{visits.length > 0 && ( - )} @@ -164,8 +180,14 @@ const ShortUrlVisits = ( {!loading && visits.length > 0 && ( - - + this.setState({ tableIsSticky: true })} + onExiting={() => this.setState({ tableIsSticky: false })} + > + )} diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js index 0f23837d..44880cf8 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.js @@ -19,11 +19,13 @@ import './VisitsTable.scss'; const propTypes = { visits: PropTypes.arrayOf(visitType).isRequired, onVisitSelected: PropTypes.func, + isSticky: PropTypes.bool, + matchMedia: PropTypes.func, }; const PAGE_SIZE = 20; -const visitMatchesSearch = ({ browser, os, referer, location }, searchTerm) => - `${browser} ${os} ${referer} ${location}`.toLowerCase().includes(searchTerm.toLowerCase()); +const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) => + `${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase()); const calculateVisits = (allVisits, page, searchTerm, { field, dir }) => { const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; @@ -49,17 +51,23 @@ const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({ browser: browserFromUserAgent(userAgent), os: osFromUserAgent(userAgent), referer: extractDomain(referer), - location: visitLocation ? `${visitLocation.countryName} - ${visitLocation.cityName}` : '', + country: (visitLocation && visitLocation.countryName) || 'Unknown', + city: (visitLocation && visitLocation.cityName) || 'Unknown', })); -const VisitsTable = ({ visits, onVisitSelected }) => { +const VisitsTable = ({ visits, onVisitSelected, 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 [ selectedVisit, setSelectedVisit ] = useState(undefined); const [ page, setPage ] = useState(1); const [ searchTerm, setSearchTerm ] = useState(undefined); const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const [ currentPage, setCurrentPageVisits ] = useState(calculateVisits(allVisits, page, searchTerm, order)); + const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); const renderOrderIcon = (field) => { @@ -81,37 +89,52 @@ const VisitsTable = ({ visits, onVisitSelected }) => { useEffect(() => { setCurrentPageVisits(calculateVisits(allVisits, page, searchTerm, order)); }, [ page, searchTerm, order ]); + useEffect(() => { + const listener = () => setIsMobileDevice(matchMobile()); + + window.addEventListener('resize', listener); + + return () => window.removeEventListener('resize', listener); + }, []); return ( - - +
+ - - - - + - - - @@ -119,7 +142,7 @@ const VisitsTable = ({ visits, onVisitSelected }) => { {currentPage.visits.length === 0 && ( - @@ -137,7 +160,8 @@ const VisitsTable = ({ visits, onVisitSelected }) => { - + + @@ -147,17 +171,22 @@ const VisitsTable = ({ visits, onVisitSelected }) => { {currentPage.total >= PAGE_SIZE && ( -
+ + Date {renderOrderIcon('date')} - Location - {renderOrderIcon('location')} + + Country + {renderOrderIcon('country')} + + City + {renderOrderIcon('city')} + Browser {renderOrderIcon('browser')} + OS {renderOrderIcon('os')} + Referrer {renderOrderIcon('referer')}
+
+ No visits found with current filtering
{visit.date} {visit.location}{visit.country}{visit.city} {visit.browser} {visit.os} {visit.referer}
+
-
+
-
+
Visits {currentPage.start + 1} to{' '} {min(currentPage.end, currentPage.total)} of{' '} diff --git a/src/visits/VisitsTable.scss b/src/visits/VisitsTable.scss index 93b05f0c..d248bf59 100644 --- a/src/visits/VisitsTable.scss +++ b/src/visits/VisitsTable.scss @@ -1,4 +1,36 @@ +@import '../utils/base'; + +.visits-table { + margin: 1.5rem 0 0; + position: relative; +} + +.visits-table__sticky { + position: sticky; +} + +.visits-table__header-cell { + cursor: pointer; + top: $headerHeight - 2px; + margin-bottom: 55px; + background-color: white; + z-index: 1; + border: 1px solid #dee2e6; +} + +.visits-table__header-cell--no-action { + cursor: auto; + text-align: center; +} + .visits-table__header-icon { float: right; margin-top: 3px; } + +.visits-table__footer-cell.visits-table__footer-cell { + bottom: 0; + margin-top: 34px; + background-color: white; + padding: .5rem; +} diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js index 2b7d2645..af4e1e97 100644 --- a/test/visits/GraphCard.test.js +++ b/test/visits/GraphCard.test.js @@ -10,12 +10,11 @@ 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); @@ -43,7 +42,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); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 48c7abc4..92096a08 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -31,6 +31,7 @@ describe('', () => { shortUrlVisits={shortUrlVisits} shortUrlDetail={{}} cancelGetShortUrlVisits={identity} + matchMedia={() => ({ matches: false })} /> ); From bd4255108d65f32508a9822ea0c0b1c75ec49f64 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Apr 2020 12:58:04 +0200 Subject: [PATCH 04/15] Improved VisitsTable performance by memoizing visits lists --- src/utils/Message.js | 4 ++-- src/utils/helpers/numbers.js | 4 ++++ src/visits/ShortUrlVisits.js | 5 +++-- src/visits/VisitsTable.js | 36 ++++++++++++++---------------------- 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/utils/Message.js b/src/utils/Message.js index 67b8c087..2aa723f8 100644 --- a/src/utils/Message.js +++ b/src/utils/Message.js @@ -35,8 +35,8 @@ const Message = ({ children, loading = false, noMargin = false, type = 'default'

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

diff --git a/src/utils/helpers/numbers.js b/src/utils/helpers/numbers.js index 7cfe751b..b9e0757f 100644 --- a/src/utils/helpers/numbers.js +++ b/src/utils/helpers/numbers.js @@ -1,4 +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/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index d18dbd66..5a5ed3ec 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -78,6 +78,7 @@ const ShortUrlVisits = ( render() { const { shortUrlVisits, shortUrlDetail } = this.props; const { visits, loading, loadingLarge, error } = shortUrlVisits; + const showTableControls = !loading && visits.length > 0; const renderVisitsContent = () => { if (loading) { @@ -166,7 +167,7 @@ const ShortUrlVisits = ( />
- {visits.length > 0 && ( + {showTableControls && (
From f5cc1abe7596ab4fe7795e3026813865d8724f96 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 4 Apr 2020 20:16:20 +0200 Subject: [PATCH 05/15] Ensured info for selected visit in visits table gets highlighted in bar charts --- src/visits/GraphCard.js | 35 ++++++++++++++++++++++++++-------- src/visits/ShortUrlVisits.js | 18 +++++++++++++---- src/visits/SortableBarGraph.js | 14 ++++++++++++-- src/visits/VisitsTable.js | 2 +- test/visits/GraphCard.test.js | 2 ++ 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index 253f4011..d9be74cc 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] -= 1; + } + + 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 5a5ed3ec..25cbe5f8 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -15,6 +15,8 @@ import GraphCard from './GraphCard'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; import VisitsTable from './VisitsTable'; +const highlightedVisitToStats = (highlightedVisit, prop) => highlightedVisit && { [highlightedVisit[prop]]: 1 }; + const ShortUrlVisits = ( { processStatsFromVisits }, OpenMapModalBtn @@ -40,6 +42,7 @@ const ShortUrlVisits = ( showTable: false, tableIsSticky: false, isMobileDevice: false, + highlightedVisit: undefined, }; loadVisits = (loadDetail = false) => { @@ -114,9 +117,10 @@ const ShortUrlVisits = (
mapLocations.length > 0 && @@ -188,7 +194,11 @@ const ShortUrlVisits = ( onEntered={() => this.setState({ tableIsSticky: true })} onExiting={() => this.setState({ tableIsSticky: false })} > - + this.setState({ highlightedVisit })} + /> )} diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js index 3a538296..54a1bd9b 100644 --- a/src/visits/SortableBarGraph.js +++ b/src/visits/SortableBarGraph.js @@ -15,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, @@ -73,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 = () => ( @@ -107,6 +108,15 @@ export default class SortableBarGraph extends React.Component { ); - return ; + return ( + + ); } } diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js index 6a3c5fb1..4a8e0972 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.js @@ -98,7 +98,7 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w 'visits-table__sticky': isSticky, })} > - +
Date diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js index af4e1e97..52975c61 100644 --- a/test/visits/GraphCard.test.js +++ b/test/visits/GraphCard.test.js @@ -59,8 +59,10 @@ describe('', () => { xAxes: [ { ticks: { beginAtZero: true }, + stacked: true, }, ], + yAxes: [{ stacked: true }], }); }); }); From 66bf26f1dc93e1eb313548a80b41992b47757280 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Apr 2020 11:57:39 +0200 Subject: [PATCH 06/15] Improved highlighted data calculation so that it works with values different than 1 --- src/visits/GraphCard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index d9be74cc..d54c1674 100644 --- a/src/visits/GraphCard.js +++ b/src/visits/GraphCard.js @@ -50,7 +50,7 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats) => { const labels = keys(stats).map(dropLabelIfHidden); const data = values(!highlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => { if (acc[highlightedKey]) { - acc[highlightedKey] -= 1; + acc[highlightedKey] -= highlightedStats[highlightedKey]; } return acc; From 94c5b2c4710eba6170bbbefc18098c715e825bab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Apr 2020 12:18:41 +0200 Subject: [PATCH 07/15] Improved useToggle hook so that it also returns enabler and disabler --- src/common/MenuLayout.js | 27 +++++++++----------- src/servers/DeleteServerButton.js | 9 ++++--- src/short-urls/CreateShortUrl.js | 2 +- src/short-urls/UseExistingIfFoundInfoIcon.js | 2 +- src/short-urls/helpers/ShortUrlsRowMenu.js | 14 +++++----- src/utils/helpers/hooks.js | 3 ++- src/visits/helpers/OpenMapModalBtn.js | 16 ++++++------ 7 files changed, 36 insertions(+), 37 deletions(-) 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/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/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/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/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 - + ); }; From cb7062bb9568e1a6e80daf9a6424bd72203fdd1d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Apr 2020 16:02:42 +0200 Subject: [PATCH 08/15] Created fake border with before and after pseudoelements for sticky table cells --- src/utils/base.scss | 1 + src/utils/mixins/sticky-cell.scss | 37 +++++++++++++++++++++++++++++++ src/visits/VisitsTable.scss | 21 ++++++++++-------- 3 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 src/utils/mixins/sticky-cell.scss 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/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/visits/VisitsTable.scss b/src/visits/VisitsTable.scss index d248bf59..42b2e550 100644 --- a/src/visits/VisitsTable.scss +++ b/src/visits/VisitsTable.scss @@ -1,21 +1,20 @@ @import '../utils/base'; +@import '../utils/mixins/sticky-cell'; .visits-table { margin: 1.5rem 0 0; position: relative; } -.visits-table__sticky { - position: sticky; -} - .visits-table__header-cell { cursor: pointer; - top: $headerHeight - 2px; margin-bottom: 55px; - background-color: white; - z-index: 1; - border: 1px solid #dee2e6; + + @include sticky-cell(); + + &.visits-table__sticky { + top: $headerHeight - 2px; + } } .visits-table__header-cell--no-action { @@ -31,6 +30,10 @@ .visits-table__footer-cell.visits-table__footer-cell { bottom: 0; margin-top: 34px; - background-color: white; padding: .5rem; + @include sticky-cell(); +} + +.visits-table__sticky.visits-table__sticky { + position: sticky; } From b79333393b3de45a56ecbb60b2db63027d326f1b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Apr 2020 16:16:55 +0200 Subject: [PATCH 09/15] Converted SearchField component into funcitonal component --- src/utils/SearchField.js | 106 ++++++++++++++++-------------------- src/utils/SearchField.scss | 4 ++ src/visits/VisitsTable.scss | 1 + 3 files changed, 53 insertions(+), 58 deletions(-) diff --git a/src/utils/SearchField.js b/src/utils/SearchField.js index bb3f6026..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,69 +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, - large: PropTypes.bool, - noBorder: PropTypes.bool, +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...', - large: true, - noBorder: false, - }; - - 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, large, noBorder } = 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 7ba46422..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 { diff --git a/src/visits/VisitsTable.scss b/src/visits/VisitsTable.scss index 42b2e550..b1365b98 100644 --- a/src/visits/VisitsTable.scss +++ b/src/visits/VisitsTable.scss @@ -31,6 +31,7 @@ bottom: 0; margin-top: 34px; padding: .5rem; + @include sticky-cell(); } From 8a486d991b7c5739463742e496eec62c83f3fcdd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Apr 2020 18:04:15 +0200 Subject: [PATCH 10/15] Implemented some improvements and fixes on how visits table is split and calculated --- src/visits/VisitsTable.js | 57 ++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js index 4a8e0972..75bea25c 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.js @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import Moment from 'react-moment'; import classNames from 'classnames'; -import { map, min } from 'ramda'; +import { map, min, splitEvery } from 'ramda'; import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon, @@ -27,25 +27,20 @@ const propTypes = { const PAGE_SIZE = 20; const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) => `${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase()); -const calculateVisits = (allVisits, page, searchTerm, { field, dir }) => { - const end = page * PAGE_SIZE; - const start = end - PAGE_SIZE; - const filteredVisits = searchTerm ? allVisits.filter((visit) => visitMatchesSearch(visit, searchTerm)) : allVisits; - const total = filteredVisits.length; - const visits = filteredVisits - .sort((a, b) => { - if (!dir) { - return 0; - } +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; - 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 a[field] > b[field] ? greaterThan : smallerThan; - }) - .slice(start, end); - - return { visits, start, end, total }; + return { visitsGroups, total }; }; const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({ date, @@ -57,6 +52,7 @@ const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({ })); const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = window.matchMedia }) => { + const allVisits = normalizeVisits(visits); const headerCellsClass = classNames('visits-table__header-cell', { 'visits-table__sticky': isSticky, }); @@ -64,11 +60,13 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w const [ selectedVisit, setSelectedVisit ] = useState(undefined); const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); - const [ page, setPage ] = useState(1); const [ searchTerm, setSearchTerm ] = useState(undefined); const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); - const allVisits = useMemo(() => normalizeVisits(visits), [ visits ]); - const currentPage = useMemo(() => calculateVisits(allVisits, page, searchTerm, order), [ page, searchTerm, order ]); + 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 && ( @@ -88,6 +86,9 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w return () => window.removeEventListener('resize', listener); }, []); + useEffect(() => { + setPage(1); + }, [ searchTerm ]); return ( @@ -132,14 +133,14 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w - {currentPage.visits.length === 0 && ( + {(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && ( )} - {currentPage.visits.map((visit, index) => ( + {resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => ( ))} - {currentPage.total >= PAGE_SIZE && ( + {resultSet.total >= PAGE_SIZE && ( ))} - {resultSet.total >= PAGE_SIZE && ( + {resultSet.total > PAGE_SIZE && ( )} - {resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => ( - setSelectedVisit(selectedVisit === visit ? undefined : visit)} - > - - - - - - - - - ))} + {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 && ( diff --git a/src/visits/VisitsTable.scss b/src/visits/VisitsTable.scss index b1365b98..9249df8b 100644 --- a/src/visits/VisitsTable.scss +++ b/src/visits/VisitsTable.scss @@ -17,11 +17,6 @@ } } -.visits-table__header-cell--no-action { - cursor: auto; - text-align: center; -} - .visits-table__header-icon { float: right; margin-top: 3px; diff --git a/test/visits/VisitsTable.test.js b/test/visits/VisitsTable.test.js index e8fbab27..b9485e1c 100644 --- a/test/visits/VisitsTable.test.js +++ b/test/visits/VisitsTable.test.js @@ -63,7 +63,7 @@ describe('', () => { expect(paginator).toHaveLength(0); }); - it('selects a row when clicked', () => { + it('selected rows are highlighted', () => { const wrapper = createWrapper(rangeOf(10, () => ({ userAgent: '', date: '', referer: '' }))); expect(wrapper.find('.text-primary')).toHaveLength(0); @@ -72,9 +72,19 @@ describe('', () => { 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); - wrapper.find('tr').at(3).simulate('click'); + + // 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); }); From a74b7cdfad98d0d4e4cb0f28c95b91c858e2b66b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Apr 2020 11:00:27 +0200 Subject: [PATCH 15/15] Updated changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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
No visits found with current filtering
- Visits {prettify(currentPage.start + 1)} to{' '} - {prettify(min(currentPage.end, currentPage.total))} of{' '} - {prettify(currentPage.total)} + Visits {prettify(start + 1)} to{' '} + {prettify(min(end, resultSet.total))} of{' '} + {prettify(resultSet.total)}
From 310831a26ae064269ff5252eb5f667153b23ae65 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 7 Apr 2020 22:33:41 +0200 Subject: [PATCH 11/15] Converted ShortUrlVisits in functional component --- src/visits/ShortUrlVisits.js | 162 +++++++++++++++-------------- test/visits/ShortUrlVisits.test.js | 10 +- 2 files changed, 86 insertions(+), 86 deletions(-) diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 25cbe5f8..eddb9d30 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -1,5 +1,5 @@ -import { isEmpty, mapObjIndexed, values } from 'ramda'; -import React from 'react'; +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'; @@ -8,6 +8,7 @@ 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'; @@ -15,71 +16,74 @@ import GraphCard from './GraphCard'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; import VisitsTable from './VisitsTable'; +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, +}; + const highlightedVisitToStats = (highlightedVisit, prop) => highlightedVisit && { [highlightedVisit[prop]]: 1 }; +const format = formatDate(); +let memoizationId; +let timeWhenMounted; -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, - matchMedia: PropTypes.func, - }; +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 [ highlightedVisit, setHighlightedVisit ] = useState(undefined); + const [ isMobileDevice, setIsMobileDevice ] = useState(false); + const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); - state = { - startDate: undefined, - endDate: undefined, - showTable: false, - tableIsSticky: false, - isMobileDevice: false, - highlightedVisit: undefined, - }; - - loadVisits = (loadDetail = false) => { - const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props; + 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); - setIsMobileDevice = () => { - const { matchMedia = window.matchMedia } = this.props; + return () => { + cancelGetShortUrlVisits(); + window.removeEventListener('resize', determineIsMobileDevice); + }; + }, []); + useEffect(() => { + loadVisits(); + }, [ startDate, endDate ]); - this.setState({ isMobileDevice: matchMedia('(max-width: 991px)').matches }); - }; - - componentDidMount() { - this.timeWhenMounted = new Date().getTime(); - this.loadVisits(true); - this.setIsMobileDevice(); - window.addEventListener('resize', this.setIsMobileDevice); - } - - componentWillUnmount() { - this.props.cancelGetShortUrlVisits(); - window.removeEventListener('resize', this.setIsMobileDevice); - } - - render() { - const { shortUrlVisits, shortUrlDetail } = this.props; const { visits, loading, loadingLarge, error } = shortUrlVisits; const showTableControls = !loading && visits.length > 0; @@ -103,7 +107,7 @@ const ShortUrlVisits = ( } const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits( - { id: this.memoizationId, visits } + { id: memoizationId, visits } ); const mapLocations = values(citiesForMap); @@ -120,7 +124,7 @@ const ShortUrlVisits = ( title="Referrers" stats={referrers} withPagination={false} - highlightedStats={highlightedVisitToStats(this.state.highlightedVisit, 'referer')} + highlightedStats={highlightedVisitToStats(highlightedVisit, 'referer')} sortingItems={{ name: 'Referrer name', amount: 'Visits amount', @@ -131,7 +135,7 @@ const ShortUrlVisits = ( mapLocations.length > 0 && - + } sortingItems={{ name: 'City name', @@ -156,7 +160,6 @@ const ShortUrlVisits = ( ); }; - const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits); return ( @@ -166,20 +169,21 @@ const ShortUrlVisits = (
{showTableControls && ( )}
@@ -188,17 +192,13 @@ const ShortUrlVisits = ( {showTableControls && ( this.setState({ tableIsSticky: true })} - onExiting={() => this.setState({ tableIsSticky: false })} + onEntered={setSticky} + onExiting={unsetSticky} > - this.setState({ highlightedVisit })} - /> + )} @@ -207,7 +207,11 @@ const ShortUrlVisits = ( ); - } + }; + + ShortUrlVisitsComp.propTypes = propTypes; + + return ShortUrlVisitsComp; }; export default ShortUrlVisits; diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 92096a08..1adae436 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -38,10 +38,7 @@ describe('', () => { 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, visits: [] }); @@ -91,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', () => { From 9177bc7cef295e042cbf72b76911c15fc990f8db Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Apr 2020 09:44:14 +0200 Subject: [PATCH 12/15] Tested how hilghlighted data behaves on GraphCards --- .eslintrc | 1 + src/visits/GraphCard.js | 2 +- test/visits/GraphCard.test.js | 21 ++++++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) 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/src/visits/GraphCard.js b/src/visits/GraphCard.js index d54c1674..5814739e 100644 --- a/src/visits/GraphCard.js +++ b/src/visits/GraphCard.js @@ -54,7 +54,7 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats) => { } return acc; - }, stats)); + }, { ...stats })); const highlightedData = highlightedStats && values({ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }); const options = { diff --git a/test/visits/GraphCard.test.js b/test/visits/GraphCard.test.js index 52975c61..545e8c22 100644 --- a/test/visits/GraphCard.test.js +++ b/test/visits/GraphCard.test.js @@ -21,12 +21,14 @@ describe('', () => { 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', @@ -65,4 +67,21 @@ describe('', () => { 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(); + }); }); From ca52911e42cfc959e6a32cf932ccb4e40224ce0a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Apr 2020 10:21:38 +0200 Subject: [PATCH 13/15] Added VisitsTable test --- src/visits/VisitsTable.js | 2 +- test/visits/VisitsTable.test.js | 118 ++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 test/visits/VisitsTable.test.js diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js index 75bea25c..70f31002 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.js @@ -161,7 +161,7 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w
diff --git a/test/visits/VisitsTable.test.js b/test/visits/VisitsTable.test.js new file mode 100644 index 00000000..e8fbab27 --- /dev/null +++ b/test/visits/VisitsTable.test.js @@ -0,0 +1,118 @@ +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('selects a row when clicked', () => { + 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(2); + expect(wrapper.find('.table-primary')).toHaveLength(1); + wrapper.find('tr').at(3).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); + }); +}); From 1c3119ee761bca5e183883406dd675fea76000cf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Apr 2020 10:56:54 +0200 Subject: [PATCH 14/15] Allowed multiple selection on visits table --- src/visits/ShortUrlVisits.js | 20 +++++++---- src/visits/VisitsTable.js | 64 +++++++++++++++++++-------------- src/visits/VisitsTable.scss | 5 --- test/visits/VisitsTable.test.js | 14 ++++++-- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index eddb9d30..fd6026e2 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -31,7 +31,15 @@ const propTypes = { matchMedia: PropTypes.func, }; -const highlightedVisitToStats = (highlightedVisit, prop) => highlightedVisit && { [highlightedVisit[prop]]: 1 }; +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; @@ -51,7 +59,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => { const [ endDate, setEndDate ] = useState(undefined); const [ showTable, toggleTable ] = useToggle(); const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); - const [ highlightedVisit, setHighlightedVisit ] = useState(undefined); + const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ isMobileDevice, setIsMobileDevice ] = useState(false); const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); @@ -124,7 +132,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => { title="Referrers" stats={referrers} withPagination={false} - highlightedStats={highlightedVisitToStats(highlightedVisit, 'referer')} + highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')} sortingItems={{ name: 'Referrer name', amount: 'Visits amount', @@ -135,7 +143,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => { { mapLocations.length > 0 && @@ -198,7 +206,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => { onEntered={setSticky} onExiting={unsetSticky} > - + )} diff --git a/src/visits/VisitsTable.js b/src/visits/VisitsTable.js index 70f31002..bd4dcaf1 100644 --- a/src/visits/VisitsTable.js +++ b/src/visits/VisitsTable.js @@ -19,7 +19,7 @@ import './VisitsTable.scss'; const propTypes = { visits: PropTypes.arrayOf(visitType).isRequired, - onVisitSelected: PropTypes.func, + onVisitsSelected: PropTypes.func, isSticky: PropTypes.bool, matchMedia: PropTypes.func, }; @@ -51,14 +51,14 @@ const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({ city: (visitLocation && visitLocation.cityName) || 'Unknown', })); -const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = window.matchMedia }) => { +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 [ selectedVisit, setSelectedVisit ] = useState(undefined); + const [ selectedVisits, setSelectedVisits ] = useState([]); const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); const [ searchTerm, setSearchTerm ] = useState(undefined); const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); @@ -77,8 +77,8 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w ); useEffect(() => { - onVisitSelected && onVisitSelected(selectedVisit); - }, [ selectedVisit ]); + onVisitsSelected && onVisitsSelected(selectedVisits); + }, [ selectedVisits ]); useEffect(() => { const listener = () => setIsMobileDevice(matchMobile()); @@ -88,6 +88,7 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w }, []); useEffect(() => { setPage(1); + setSelectedVisits([]); }, [ searchTerm ]); return ( @@ -95,11 +96,14 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w
setSelectedVisits( + selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [] + )} > - + 0 })} /> Date @@ -140,26 +144,32 @@ const VisitsTable = ({ visits, onVisitSelected, isSticky = false, matchMedia = w
- {selectedVisit === visit && } - - {visit.date} - {visit.country}{visit.city}{visit.browser}{visit.os}{visit.referer}
+ {isSelected && } + + {visit.date} + {visit.country}{visit.city}{visit.browser}{visit.os}{visit.referer}