Passed
Push — main ( 16ce1d...2a5fa5 )
by Alejandro
25:26 queued 23:10
created

src/visits/VisitsTable.tsx   A

Complexity

Total Complexity 9
Complexity/F 0

Size

Lines of Code 218
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Test Coverage

Coverage 86.36%

Importance

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