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