Improved rendering of visits table on mobile devices

This commit is contained in:
Alejandro Celaya 2020-04-04 12:09:17 +02:00
parent 2bd70fb9e6
commit 06b63d1af2
5 changed files with 110 additions and 27 deletions

View file

@ -31,12 +31,15 @@ const ShortUrlVisits = (
getShortUrlDetail: PropTypes.func, getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType, shortUrlDetail: shortUrlDetailType,
cancelGetShortUrlVisits: PropTypes.func, cancelGetShortUrlVisits: PropTypes.func,
matchMedia: PropTypes.func,
}; };
state = { state = {
startDate: undefined, startDate: undefined,
endDate: undefined, endDate: undefined,
showTable: false, showTable: false,
tableIsSticky: false,
isMobileDevice: false,
}; };
loadVisits = (loadDetail = 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() { componentDidMount() {
this.timeWhenMounted = new Date().getTime(); this.timeWhenMounted = new Date().getTime();
this.loadVisits(true); this.loadVisits(true);
this.setIsMobileDevice();
window.addEventListener('resize', this.setIsMobileDevice);
} }
componentWillUnmount() { componentWillUnmount() {
this.props.cancelGetShortUrlVisits(); this.props.cancelGetShortUrlVisits();
window.removeEventListener('resize', this.setIsMobileDevice);
} }
render() { render() {
@ -155,7 +167,11 @@ const ShortUrlVisits = (
</div> </div>
<div className="col-lg-4 col-xl-6 mt-4 mt-lg-0"> <div className="col-lg-4 col-xl-6 mt-4 mt-lg-0">
{visits.length > 0 && ( {visits.length > 0 && (
<Button outline onClick={() => this.setState(({ showTable }) => ({ showTable: !showTable }))}> <Button
outline
block={this.state.isMobileDevice}
onClick={() => this.setState(({ showTable }) => ({ showTable: !showTable }))}
>
Show table <FontAwesomeIcon icon={chevronDown} rotation={this.state.showTable ? 180 : undefined} /> Show table <FontAwesomeIcon icon={chevronDown} rotation={this.state.showTable ? 180 : undefined} />
</Button> </Button>
)} )}
@ -164,8 +180,14 @@ const ShortUrlVisits = (
</section> </section>
{!loading && visits.length > 0 && ( {!loading && visits.length > 0 && (
<Collapse isOpen={this.state.showTable}> <Collapse
<VisitsTable visits={visits} /> isOpen={this.state.showTable}
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
onEntered={() => this.setState({ tableIsSticky: true })}
onExiting={() => this.setState({ tableIsSticky: false })}
>
<VisitsTable visits={visits} isSticky={this.state.tableIsSticky} />
</Collapse> </Collapse>
)} )}

View file

@ -19,11 +19,13 @@ import './VisitsTable.scss';
const propTypes = { const propTypes = {
visits: PropTypes.arrayOf(visitType).isRequired, visits: PropTypes.arrayOf(visitType).isRequired,
onVisitSelected: PropTypes.func, onVisitSelected: PropTypes.func,
isSticky: PropTypes.bool,
matchMedia: PropTypes.func,
}; };
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const visitMatchesSearch = ({ browser, os, referer, location }, searchTerm) => const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) =>
`${browser} ${os} ${referer} ${location}`.toLowerCase().includes(searchTerm.toLowerCase()); `${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase());
const calculateVisits = (allVisits, page, searchTerm, { field, dir }) => { const calculateVisits = (allVisits, page, searchTerm, { field, dir }) => {
const end = page * PAGE_SIZE; const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE; const start = end - PAGE_SIZE;
@ -49,17 +51,23 @@ const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({
browser: browserFromUserAgent(userAgent), browser: browserFromUserAgent(userAgent),
os: osFromUserAgent(userAgent), os: osFromUserAgent(userAgent),
referer: extractDomain(referer), 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 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 [ selectedVisit, setSelectedVisit ] = useState(undefined);
const [ page, setPage ] = useState(1); const [ page, setPage ] = useState(1);
const [ searchTerm, setSearchTerm ] = useState(undefined); const [ searchTerm, setSearchTerm ] = useState(undefined);
const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const [ order, setOrder ] = useState({ field: undefined, dir: undefined });
const [ currentPage, setCurrentPageVisits ] = useState(calculateVisits(allVisits, page, searchTerm, order)); 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 orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
const renderOrderIcon = (field) => { const renderOrderIcon = (field) => {
@ -81,37 +89,52 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
useEffect(() => { useEffect(() => {
setCurrentPageVisits(calculateVisits(allVisits, page, searchTerm, order)); setCurrentPageVisits(calculateVisits(allVisits, page, searchTerm, order));
}, [ page, searchTerm, order ]); }, [ page, searchTerm, order ]);
useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
return ( return (
<table className="table table-striped table-bordered table-hover table-sm table-responsive-sm mt-4 mb-0"> <table className="table table-striped table-bordered table-hover table-sm visits-table">
<thead className="short-urls-list__header"> <thead className="visits-table__header">
<tr> <tr>
<th className="text-center"> <th
className={classNames('visits-table__header-cell visits-table__header-cell--no-action', {
'visits-table__sticky': isSticky,
})}
>
<FontAwesomeIcon icon={checkIcon} /> <FontAwesomeIcon icon={checkIcon} />
</th> </th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('date')}> <th className={headerCellsClass} onClick={orderByColumn('date')}>
Date Date
{renderOrderIcon('date')} {renderOrderIcon('date')}
</th> </th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('location')}> <th className={headerCellsClass} onClick={orderByColumn('country')}>
Location Country
{renderOrderIcon('location')} {renderOrderIcon('country')}
</th> </th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('browser')}> <th className={headerCellsClass} onClick={orderByColumn('city')}>
City
{renderOrderIcon('city')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
Browser Browser
{renderOrderIcon('browser')} {renderOrderIcon('browser')}
</th> </th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('os')}> <th className={headerCellsClass} onClick={orderByColumn('os')}>
OS OS
{renderOrderIcon('os')} {renderOrderIcon('os')}
</th> </th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('referer')}> <th className={headerCellsClass} onClick={orderByColumn('referer')}>
Referrer Referrer
{renderOrderIcon('referer')} {renderOrderIcon('referer')}
</th> </th>
</tr> </tr>
<tr> <tr>
<td colSpan={6} className="p-0"> <td colSpan={7} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} /> <SearchField noBorder large={false} onChange={setSearchTerm} />
</td> </td>
</tr> </tr>
@ -119,7 +142,7 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
<tbody> <tbody>
{currentPage.visits.length === 0 && ( {currentPage.visits.length === 0 && (
<tr> <tr>
<td colSpan={6} className="text-center"> <td colSpan={7} className="text-center">
No visits found with current filtering No visits found with current filtering
</td> </td>
</tr> </tr>
@ -137,7 +160,8 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
<td> <td>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment> <Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td> </td>
<td>{visit.location}</td> <td>{visit.country}</td>
<td>{visit.city}</td>
<td>{visit.browser}</td> <td>{visit.browser}</td>
<td>{visit.os}</td> <td>{visit.os}</td>
<td>{visit.referer}</td> <td>{visit.referer}</td>
@ -147,17 +171,22 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
{currentPage.total >= PAGE_SIZE && ( {currentPage.total >= PAGE_SIZE && (
<tfoot> <tfoot>
<tr> <tr>
<td colSpan={6} className="p-2"> <td colSpan={7} className={classNames('visits-table__footer-cell', { 'visits-table__sticky': isSticky })}>
<div className="row"> <div className="row">
<div className="col-6"> <div className="col-md-6">
<SimplePaginator <SimplePaginator
pagesCount={Math.ceil(currentPage.total / PAGE_SIZE)} pagesCount={Math.ceil(currentPage.total / PAGE_SIZE)}
currentPage={page} currentPage={page}
setCurrentPage={setPage} setCurrentPage={setPage}
centered={false} centered={isMobileDevice}
/> />
</div> </div>
<div className="col-6 d-flex align-items-center flex-row-reverse"> <div
className={classNames('col-md-6', {
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
'text-center mt-3': isMobileDevice,
})}
>
<div> <div>
Visits <b>{currentPage.start + 1}</b> to{' '} Visits <b>{currentPage.start + 1}</b> to{' '}
<b>{min(currentPage.end, currentPage.total)}</b> of{' '} <b>{min(currentPage.end, currentPage.total)}</b> of{' '}

View file

@ -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 { .visits-table__header-icon {
float: right; float: right;
margin-top: 3px; margin-top: 3px;
} }
.visits-table__footer-cell.visits-table__footer-cell {
bottom: 0;
margin-top: 34px;
background-color: white;
padding: .5rem;
}

View file

@ -10,12 +10,11 @@ describe('<GraphCard />', () => {
foo: 123, foo: 123,
bar: 456, bar: 456,
}; };
const matchMedia = () => ({ matches: false });
afterEach(() => wrapper && wrapper.unmount()); afterEach(() => wrapper && wrapper.unmount());
it('renders Doughnut when is not a bar chart', () => { it('renders Doughnut when is not a bar chart', () => {
wrapper = shallow(<GraphCard matchMedia={matchMedia} title="The chart" stats={stats} />); wrapper = shallow(<GraphCard title="The chart" stats={stats} />);
const doughnut = wrapper.find(Doughnut); const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar); const horizontal = wrapper.find(HorizontalBar);
@ -43,7 +42,7 @@ describe('<GraphCard />', () => {
}); });
it('renders HorizontalBar when is not a bar chart', () => { it('renders HorizontalBar when is not a bar chart', () => {
wrapper = shallow(<GraphCard matchMedia={matchMedia} isBarChart title="The chart" stats={stats} />); wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} />);
const doughnut = wrapper.find(Doughnut); const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar); const horizontal = wrapper.find(HorizontalBar);

View file

@ -31,6 +31,7 @@ describe('<ShortUrlVisits />', () => {
shortUrlVisits={shortUrlVisits} shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}} shortUrlDetail={{}}
cancelGetShortUrlVisits={identity} cancelGetShortUrlVisits={identity}
matchMedia={() => ({ matches: false })}
/> />
); );