From e6034dfb1493d0e9ad6a43c5911cdfd58aa7d39d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 3 Apr 2020 23:00:57 +0200 Subject: [PATCH] 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; };