Created VisitsTable

This commit is contained in:
Alejandro Celaya 2020-04-03 23:00:57 +02:00
parent c8ba6764c2
commit e6034dfb14
11 changed files with 325 additions and 70 deletions

View file

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

View file

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

View file

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

View file

@ -12,10 +12,14 @@ export default class SearchField extends React.Component {
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
placeholder: PropTypes.string,
large: PropTypes.bool,
noBorder: PropTypes.bool,
};
static defaultProps = {
className: '',
placeholder: 'Search...',
large: true,
noBorder: false,
};
state = { showClearBtn: false, searchTerm: '' };
@ -41,13 +45,16 @@ export default class SearchField extends React.Component {
}
render() {
const { className, placeholder } = this.props;
const { className, placeholder, large, noBorder } = this.props;
return (
<div className={classNames('search-field', className)}>
<input
type="text"
className="form-control form-control-lg search-field__input"
className={classNames('form-control search-field__input', {
'form-control-lg': large,
'search-field__input--no-border': noBorder,
})}
placeholder={placeholder}
value={this.state.searchTerm}
onChange={(e) => this.searchTermChanged(e.target.value)}

View file

@ -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();

View file

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

View file

@ -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 = (
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<section className="mt-4">
<DateRangeRow
startDate={this.state.startDate}
endDate={this.state.endDate}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
<div className="row flex-md-row-reverse">
<div className="col-lg-8 col-xl-6">
<DateRangeRow
startDate={this.state.startDate}
endDate={this.state.endDate}
onStartDateChange={setDate('startDate')}
onEndDateChange={setDate('endDate')}
/>
</div>
<div className="col-lg-4 col-xl-6 mt-4 mt-lg-0">
{visits.length > 0 && (
<Button outline onClick={() => this.setState(({ showTable }) => ({ showTable: !showTable }))}>
Show table <FontAwesomeIcon icon={chevronDown} rotation={this.state.showTable ? 180 : undefined} />
</Button>
)}
</div>
</div>
</section>
{!loading && visits.length > 0 && (
<Collapse isOpen={this.state.showTable}>
<VisitsTable visits={visits} />
</Collapse>
)}
<section>
{renderVisitsContent()}
</section>

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

@ -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 (
<FontAwesomeIcon
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
className="visits-table__header-icon"
/>
);
};
useEffect(() => {
onVisitSelected && onVisitSelected(selectedVisit);
}, [ selectedVisit ]);
useEffect(() => {
setCurrentPageVisits(calculateVisits(allVisits, page, searchTerm, order));
}, [ page, searchTerm, order ]);
return (
<table className="table table-striped table-bordered table-hover table-sm table-responsive-sm mt-4 mb-0">
<thead className="short-urls-list__header">
<tr>
<th className="text-center">
<FontAwesomeIcon icon={checkIcon} />
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('date')}>
{renderOrderIcon('date')}
Date
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('location')}>
{renderOrderIcon('location')}
Location
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('browser')}>
{renderOrderIcon('browser')}
Browser
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('os')}>
{renderOrderIcon('os')}
OS
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('referer')}>
{renderOrderIcon('referer')}
Referrer
</th>
</tr>
<tr>
<td colSpan={6} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
</thead>
<tbody>
{currentPage.visits.length === 0 && (
<tr>
<td colSpan={6} className="text-center">
No visits found with current filtering
</td>
</tr>
)}
{currentPage.visits.map((visit, index) => (
<tr
key={index}
style={{ cursor: 'pointer' }}
className={classNames({ 'table-primary': selectedVisit === visit })}
onClick={() => setSelectedVisit(selectedVisit === visit ? undefined : visit)}
>
<td className="text-center">
{selectedVisit === visit && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td>
<td>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td>
<td>{visit.location}</td>
<td>{visit.browser}</td>
<td>{visit.os}</td>
<td>{visit.referer}</td>
</tr>
))}
</tbody>
{currentPage.total >= PAGE_SIZE && (
<tfoot>
<tr>
<td colSpan={6} className="p-2">
<div className="row">
<div className="col-6">
<SimplePaginator
pagesCount={Math.ceil(currentPage.total / PAGE_SIZE)}
currentPage={page}
setCurrentPage={setPage}
centered={false}
/>
</div>
<div className="col-6 d-flex align-items-center flex-row-reverse">
<div>
Visits <b>{currentPage.start + 1}</b> to <b>{currentPage.end}</b> of <b>{currentPage.total}</b>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
)}
</table>
);
};
VisitsTable.propTypes = propTypes;
export default VisitsTable;

View file

@ -0,0 +1,4 @@
.visits-table__header-icon {
float: right;
margin-top: 3px;
}

View file

@ -10,8 +10,24 @@ export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_V
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
/* eslint-enable padding-line-between-statements */
export const visitType = PropTypes.shape({
referer: PropTypes.string,
date: PropTypes.string,
userAgent: PropTypes.string,
visitLocations: PropTypes.shape({
countryCode: PropTypes.string,
countryName: PropTypes.string,
regionName: PropTypes.string,
cityName: PropTypes.string,
latitude: PropTypes.number,
longitude: PropTypes.number,
timezone: PropTypes.string,
isEmpty: PropTypes.bool,
}),
});
export const shortUrlVisitsType = PropTypes.shape({
visits: PropTypes.array,
visits: PropTypes.arrayOf(visitType),
loading: PropTypes.bool,
error: PropTypes.bool,
});

View file

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