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,
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 = (
</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 }))}>
<Button
outline
block={this.state.isMobileDevice}
onClick={() => this.setState(({ showTable }) => ({ showTable: !showTable }))}
>
Show table <FontAwesomeIcon icon={chevronDown} rotation={this.state.showTable ? 180 : undefined} />
</Button>
)}
@ -164,8 +180,14 @@ const ShortUrlVisits = (
</section>
{!loading && visits.length > 0 && (
<Collapse isOpen={this.state.showTable}>
<VisitsTable visits={visits} />
<Collapse
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>
)}

View file

@ -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 (
<table className="table table-striped table-bordered table-hover table-sm table-responsive-sm mt-4 mb-0">
<thead className="short-urls-list__header">
<table className="table table-striped table-bordered table-hover table-sm visits-table">
<thead className="visits-table__header">
<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} />
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('date')}>
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('location')}>
Location
{renderOrderIcon('location')}
<th className={headerCellsClass} onClick={orderByColumn('country')}>
Country
{renderOrderIcon('country')}
</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
{renderOrderIcon('browser')}
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('os')}>
<th className={headerCellsClass} onClick={orderByColumn('os')}>
OS
{renderOrderIcon('os')}
</th>
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('referer')}>
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
Referrer
{renderOrderIcon('referer')}
</th>
</tr>
<tr>
<td colSpan={6} className="p-0">
<td colSpan={7} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
@ -119,7 +142,7 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
<tbody>
{currentPage.visits.length === 0 && (
<tr>
<td colSpan={6} className="text-center">
<td colSpan={7} className="text-center">
No visits found with current filtering
</td>
</tr>
@ -137,7 +160,8 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
<td>
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
</td>
<td>{visit.location}</td>
<td>{visit.country}</td>
<td>{visit.city}</td>
<td>{visit.browser}</td>
<td>{visit.os}</td>
<td>{visit.referer}</td>
@ -147,17 +171,22 @@ const VisitsTable = ({ visits, onVisitSelected }) => {
{currentPage.total >= PAGE_SIZE && (
<tfoot>
<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="col-6">
<div className="col-md-6">
<SimplePaginator
pagesCount={Math.ceil(currentPage.total / PAGE_SIZE)}
currentPage={page}
setCurrentPage={setPage}
centered={false}
centered={isMobileDevice}
/>
</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>
Visits <b>{currentPage.start + 1}</b> to{' '}
<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 {
float: right;
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,
bar: 456,
};
const matchMedia = () => ({ matches: false });
afterEach(() => wrapper && wrapper.unmount());
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 horizontal = wrapper.find(HorizontalBar);
@ -43,7 +42,7 @@ describe('<GraphCard />', () => {
});
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 horizontal = wrapper.find(HorizontalBar);

View file

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