Completed
Push — master ( c8ba67...05deb1 )
by Alejandro
21s queued 12s
created

src/visits/VisitsTable.js   A

Complexity

Total Complexity 8
Complexity/F 0

Size

Lines of Code 211
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Test Coverage

Coverage 89.13%

Importance

Changes 0
Metric Value
wmc 8
eloc 189
mnd 8
bc 8
fnc 0
dl 0
loc 211
ccs 41
cts 46
cp 0.8913
rs 10
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
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