mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-24 01:48:18 +03:00
Improved rendering of visits table on mobile devices
This commit is contained in:
parent
2bd70fb9e6
commit
06b63d1af2
5 changed files with 110 additions and 27 deletions
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -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{' '}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ describe('<ShortUrlVisits />', () => {
|
||||||
shortUrlVisits={shortUrlVisits}
|
shortUrlVisits={shortUrlVisits}
|
||||||
shortUrlDetail={{}}
|
shortUrlDetail={{}}
|
||||||
cancelGetShortUrlVisits={identity}
|
cancelGetShortUrlVisits={identity}
|
||||||
|
matchMedia={() => ({ matches: false })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue