1
|
|
|
import React, { useEffect, useMemo, useState } from 'react'; |
2
|
|
|
import PropTypes from 'prop-types'; |
3
|
|
|
import Moment from 'react-moment'; |
4
|
|
|
import classNames from 'classnames'; |
5
|
|
|
import { map, min, splitEvery } from 'ramda'; |
6
|
|
|
import { |
7
|
|
|
faCaretDown as caretDownIcon, |
8
|
|
|
faCaretUp as caretUpIcon, |
9
|
|
|
faCheck as checkIcon, |
10
|
|
|
} from '@fortawesome/free-solid-svg-icons'; |
11
|
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; |
12
|
|
|
import SimplePaginator from '../common/SimplePaginator'; |
13
|
|
|
import SearchField from '../utils/SearchField'; |
14
|
|
|
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../utils/helpers/visits'; |
15
|
|
|
import { determineOrderDir } from '../utils/utils'; |
16
|
|
|
import { prettify } from '../utils/helpers/numbers'; |
17
|
|
|
import { visitType } from './reducers/shortUrlVisits'; |
18
|
|
|
import './VisitsTable.scss'; |
19
|
|
|
|
20
|
2 |
|
const propTypes = { |
21
|
|
|
visits: PropTypes.arrayOf(visitType).isRequired, |
22
|
|
|
onVisitsSelected: PropTypes.func, |
23
|
|
|
isSticky: PropTypes.bool, |
24
|
|
|
matchMedia: PropTypes.func, |
25
|
|
|
}; |
26
|
|
|
|
27
|
2 |
|
const PAGE_SIZE = 20; |
28
|
2 |
|
const visitMatchesSearch = ({ browser, os, referer, country, city }, searchTerm) => |
29
|
18 |
|
`${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase()); |
30
|
18 |
|
const searchVisits = (searchTerm, visits) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); |
31
|
3 |
|
const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => { |
32
|
24 |
|
const greaterThan = dir === 'ASC' ? 1 : -1; |
33
|
24 |
|
const smallerThan = dir === 'ASC' ? -1 : 1; |
34
|
|
|
|
35
|
24 |
|
return a[field] > b[field] ? greaterThan : smallerThan; |
36
|
|
|
}); |
37
|
2 |
|
const calculateVisits = (allVisits, searchTerm, order) => { |
38
|
37 |
|
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : allVisits; |
39
|
37 |
|
const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits; |
40
|
37 |
|
const total = sortedVisits.length; |
41
|
37 |
|
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits); |
42
|
|
|
|
43
|
37 |
|
return { visitsGroups, total }; |
44
|
|
|
}; |
45
|
627 |
|
const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({ |
46
|
|
|
date, |
47
|
|
|
browser: browserFromUserAgent(userAgent), |
48
|
|
|
os: osFromUserAgent(userAgent), |
49
|
|
|
referer: extractDomain(referer), |
50
|
|
|
country: (visitLocation && visitLocation.countryName) || 'Unknown', |
51
|
|
|
city: (visitLocation && visitLocation.cityName) || 'Unknown', |
52
|
|
|
})); |
53
|
|
|
|
54
|
2 |
|
const VisitsTable = ({ visits, onVisitsSelected, isSticky = false, matchMedia = window.matchMedia }) => { |
55
|
42 |
|
const allVisits = normalizeVisits(visits); |
56
|
42 |
|
const headerCellsClass = classNames('visits-table__header-cell', { |
57
|
|
|
'visits-table__sticky': isSticky, |
58
|
|
|
}); |
59
|
42 |
|
const matchMobile = () => matchMedia('(max-width: 767px)').matches; |
60
|
|
|
|
61
|
42 |
|
const [ selectedVisits, setSelectedVisits ] = useState([]); |
62
|
42 |
|
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); |
63
|
42 |
|
const [ searchTerm, setSearchTerm ] = useState(undefined); |
64
|
42 |
|
const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); |
65
|
42 |
|
const resultSet = useMemo(() => calculateVisits(allVisits, searchTerm, order), [ searchTerm, order ]); |
66
|
|
|
|
67
|
42 |
|
const [ page, setPage ] = useState(1); |
68
|
42 |
|
const end = page * PAGE_SIZE; |
69
|
42 |
|
const start = end - PAGE_SIZE; |
70
|
|
|
|
71
|
252 |
|
const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); |
72
|
252 |
|
const renderOrderIcon = (field) => order.dir && order.field === field && ( |
73
|
|
|
<FontAwesomeIcon |
74
|
|
|
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon} |
75
|
|
|
className="visits-table__header-icon" |
76
|
|
|
/> |
77
|
|
|
); |
78
|
|
|
|
79
|
42 |
|
useEffect(() => { |
80
|
2 |
|
onVisitsSelected && onVisitsSelected(selectedVisits); |
81
|
|
|
}, [ selectedVisits ]); |
82
|
42 |
|
useEffect(() => { |
83
|
|
|
const listener = () => setIsMobileDevice(matchMobile()); |
84
|
|
|
|
85
|
|
|
window.addEventListener('resize', listener); |
86
|
|
|
|
87
|
|
|
return () => window.removeEventListener('resize', listener); |
88
|
|
|
}, []); |
89
|
42 |
|
useEffect(() => { |
90
|
|
|
setPage(1); |
91
|
|
|
setSelectedVisits([]); |
92
|
|
|
}, [ searchTerm ]); |
93
|
|
|
|
94
|
42 |
|
return ( |
95
|
|
|
<table className="table table-striped table-bordered table-hover table-sm visits-table"> |
96
|
|
|
<thead className="visits-table__header"> |
97
|
|
|
<tr> |
98
|
|
|
<th |
99
|
|
|
className={classNames('visits-table__header-cell text-center', { |
100
|
|
|
'visits-table__sticky': isSticky, |
101
|
|
|
})} |
102
|
2 |
|
onClick={() => setSelectedVisits( |
103
|
|
|
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [] |
104
|
|
|
)} |
105
|
|
|
> |
106
|
|
|
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} /> |
107
|
|
|
</th> |
108
|
|
|
<th className={headerCellsClass} onClick={orderByColumn('date')}> |
109
|
|
|
Date |
110
|
|
|
{renderOrderIcon('date')} |
111
|
|
|
</th> |
112
|
|
|
<th className={headerCellsClass} onClick={orderByColumn('country')}> |
113
|
|
|
Country |
114
|
|
|
{renderOrderIcon('country')} |
115
|
|
|
</th> |
116
|
|
|
<th className={headerCellsClass} onClick={orderByColumn('city')}> |
117
|
|
|
City |
118
|
|
|
{renderOrderIcon('city')} |
119
|
|
|
</th> |
120
|
|
|
<th className={headerCellsClass} onClick={orderByColumn('browser')}> |
121
|
|
|
Browser |
122
|
|
|
{renderOrderIcon('browser')} |
123
|
|
|
</th> |
124
|
|
|
<th className={headerCellsClass} onClick={orderByColumn('os')}> |
125
|
|
|
OS |
126
|
|
|
{renderOrderIcon('os')} |
127
|
|
|
</th> |
128
|
|
|
<th className={headerCellsClass} onClick={orderByColumn('referer')}> |
129
|
|
|
Referrer |
130
|
|
|
{renderOrderIcon('referer')} |
131
|
|
|
</th> |
132
|
|
|
</tr> |
133
|
|
|
<tr> |
134
|
|
|
<td colSpan={7} className="p-0"> |
135
|
|
|
<SearchField noBorder large={false} onChange={setSearchTerm} /> |
136
|
|
|
</td> |
137
|
|
|
</tr> |
138
|
|
|
</thead> |
139
|
|
|
<tbody> |
140
|
|
|
{(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && ( |
141
|
|
|
<tr> |
142
|
|
|
<td colSpan={7} className="text-center"> |
143
|
|
|
No visits found with current filtering |
144
|
|
|
</td> |
145
|
|
|
</tr> |
146
|
|
|
)} |
147
|
|
|
{resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => { |
148
|
442 |
|
const isSelected = selectedVisits.includes(visit); |
149
|
|
|
|
150
|
442 |
|
return ( |
151
|
|
|
<tr |
152
|
|
|
key={index} |
153
|
|
|
style={{ cursor: 'pointer' }} |
154
|
|
|
className={classNames({ 'table-primary': isSelected })} |
155
|
3 |
|
onClick={() => setSelectedVisits( |
156
|
2 |
|
isSelected ? selectedVisits.filter((v) => v !== visit) : [ ...selectedVisits, visit ] |
157
|
|
|
)} |
158
|
|
|
> |
159
|
|
|
<td className="text-center"> |
160
|
|
|
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />} |
161
|
|
|
</td> |
162
|
|
|
<td> |
163
|
|
|
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment> |
164
|
|
|
</td> |
165
|
|
|
<td>{visit.country}</td> |
166
|
|
|
<td>{visit.city}</td> |
167
|
|
|
<td>{visit.browser}</td> |
168
|
|
|
<td>{visit.os}</td> |
169
|
|
|
<td>{visit.referer}</td> |
170
|
|
|
</tr> |
171
|
|
|
); |
172
|
|
|
})} |
173
|
|
|
</tbody> |
174
|
|
|
{resultSet.total > PAGE_SIZE && ( |
175
|
|
|
<tfoot> |
176
|
|
|
<tr> |
177
|
|
|
<td colSpan={7} className={classNames('visits-table__footer-cell', { 'visits-table__sticky': isSticky })}> |
178
|
|
|
<div className="row"> |
179
|
|
|
<div className="col-md-6"> |
180
|
|
|
<SimplePaginator |
181
|
|
|
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)} |
182
|
|
|
currentPage={page} |
183
|
|
|
setCurrentPage={setPage} |
184
|
|
|
centered={isMobileDevice} |
185
|
|
|
/> |
186
|
|
|
</div> |
187
|
|
|
<div |
188
|
|
|
className={classNames('col-md-6', { |
189
|
|
|
'd-flex align-items-center flex-row-reverse': !isMobileDevice, |
190
|
|
|
'text-center mt-3': isMobileDevice, |
191
|
|
|
})} |
192
|
|
|
> |
193
|
|
|
<div> |
194
|
|
|
Visits <b>{prettify(start + 1)}</b> to{' '} |
195
|
|
|
<b>{prettify(min(end, resultSet.total))}</b> of{' '} |
196
|
|
|
<b>{prettify(resultSet.total)}</b> |
197
|
|
|
</div> |
198
|
|
|
</div> |
199
|
|
|
</div> |
200
|
|
|
</td> |
201
|
|
|
</tr> |
202
|
|
|
</tfoot> |
203
|
|
|
)} |
204
|
|
|
</table> |
205
|
|
|
); |
206
|
|
|
}; |
207
|
|
|
|
208
|
2 |
|
VisitsTable.propTypes = propTypes; |
209
|
|
|
|
210
|
|
|
export default VisitsTable; |
211
|
|
|
|