Passed
Pull Request — master (#251)
by Alejandro
18:31
created

src/visits/ShortUrlVisits.js   A

Complexity

Total Complexity 8
Complexity/F 0

Size

Lines of Code 263
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Test Coverage

Coverage 69.35%

Importance

Changes 0
Metric Value
wmc 8
eloc 220
mnd 8
bc 8
fnc 0
dl 0
loc 263
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
rs 10
ccs 43
cts 62
cp 0.6935
1
import { isEmpty, propEq, values } from 'ramda';
2
import React, { useState, useEffect, useMemo } from 'react';
3
import { Button, Card, Collapse } from 'reactstrap';
4
import PropTypes from 'prop-types';
5
import qs from 'qs';
6
import classNames from 'classnames';
7
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
8
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
9
import DateRangeRow from '../utils/DateRangeRow';
10
import Message from '../utils/Message';
11
import { formatDate } from '../utils/helpers/date';
12
import { useToggle } from '../utils/helpers/hooks';
13
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
14
import { bindToMercureTopic } from '../mercure/helpers';
15
import SortableBarGraph from './SortableBarGraph';
16
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
17
import VisitsHeader from './VisitsHeader';
18
import GraphCard from './GraphCard';
19
import { shortUrlDetailType } from './reducers/shortUrlDetail';
20
import VisitsTable from './VisitsTable';
21
22 1
const propTypes = {
23
  match: PropTypes.shape({
24
    params: PropTypes.object,
25
  }),
26
  location: PropTypes.shape({
27
    search: PropTypes.string,
28
  }),
29
  getShortUrlVisits: PropTypes.func,
30
  shortUrlVisits: shortUrlVisitsType,
31
  getShortUrlDetail: PropTypes.func,
32
  shortUrlDetail: shortUrlDetailType,
33
  cancelGetShortUrlVisits: PropTypes.func,
34
  matchMedia: PropTypes.func,
35
  createNewVisit: PropTypes.func,
36
  mercureInfo: MercureInfoType,
37
};
38
39 18
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
40 2
  if (!acc[highlightedVisit[prop]]) {
41
    acc[highlightedVisit[prop]] = 0;
42
  }
43
44
  acc[highlightedVisit[prop]] += 1;
45
46
  return acc;
47
}, {});
48 1
const format = formatDate();
49
let selectedBar;
50
51 1
const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
52 7
  const ShortUrlVisitsComp = ({
53
    match,
54
    location,
55
    shortUrlVisits,
56
    shortUrlDetail,
57
    getShortUrlVisits,
58
    getShortUrlDetail,
59
    cancelGetShortUrlVisits,
60
    matchMedia = window.matchMedia,
61
    createNewVisit,
62
    mercureInfo,
63
  }) => {
64 10
    const [ startDate, setStartDate ] = useState(undefined);
65 10
    const [ endDate, setEndDate ] = useState(undefined);
66 10
    const [ showTable, toggleTable ] = useToggle();
67 10
    const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle();
68 10
    const [ highlightedVisits, setHighlightedVisits ] = useState([]);
69 10
    const [ isMobileDevice, setIsMobileDevice ] = useState(false);
70 10
    const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
71 10
    const setSelectedVisits = (selectedVisits) => {
72
      selectedBar = undefined;
73
      setHighlightedVisits(selectedVisits);
74
    };
75 18
    const highlightVisitsForProp = (prop) => (value) => {
76
      const newSelectedBar = `${prop}_${value}`;
77
78 2
      if (selectedBar === newSelectedBar) {
79
        setHighlightedVisits([]);
80
        selectedBar = undefined;
81
      } else {
82
        setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
83
        selectedBar = newSelectedBar;
84
      }
85
    };
86
87 10
    const { params } = match;
88 10
    const { shortCode } = params;
89 10
    const { search } = location;
90 10
    const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
91
92 10
    const { visits, loading, loadingLarge, error } = shortUrlVisits;
93 10
    const showTableControls = !loading && visits.length > 0;
94 10
    const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
95 10
    const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
96 7
      () => processStatsFromVisits(normalizedVisits),
97
      [ normalizedVisits ]
98
    );
99 10
    const mapLocations = values(citiesForMap);
100
101 10
    const loadVisits = () =>
102
      getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
103
104 10
    useEffect(() => {
105
      getShortUrlDetail(shortCode, domain);
106
      determineIsMobileDevice();
107
      window.addEventListener('resize', determineIsMobileDevice);
108
109
      return () => {
110
        cancelGetShortUrlVisits();
111
        window.removeEventListener('resize', determineIsMobileDevice);
112
      };
113
    }, []);
114 10
    useEffect(() => {
115
      loadVisits();
116
    }, [ startDate, endDate ]);
117 10
    useEffect(
118
      bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit),
119
      [ mercureInfo ],
120
    );
121
122 10
    const renderVisitsContent = () => {
123 10
      if (loading) {
124 2
        const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
125
126 2
        return <Message loading>{message}</Message>;
127
      }
128
129 8
      if (error) {
130 1
        return (
131
          <Card className="mt-4" body inverse color="danger">
132
            An error occurred while loading visits :(
133
          </Card>
134
        );
135
      }
136
137 7
      if (isEmpty(visits)) {
138 1
        return <Message>There are no visits matching current filter  :(</Message>;
139
      }
140
141 6
      return (
142
        <div className="row">
143
          <div className="col-xl-4 col-lg-6">
144
            <GraphCard title="Operating systems" stats={os} />
145
          </div>
146
          <div className="col-xl-4 col-lg-6">
147
            <GraphCard title="Browsers" stats={browsers} />
148
          </div>
149
          <div className="col-xl-4">
150
            <SortableBarGraph
151
              title="Referrers"
152
              stats={referrers}
153
              withPagination={false}
154
              highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
155
              sortingItems={{
156
                name: 'Referrer name',
157
                amount: 'Visits amount',
158
              }}
159
              onClick={highlightVisitsForProp('referer')}
160
            />
161
          </div>
162
          <div className="col-lg-6">
163
            <SortableBarGraph
164
              title="Countries"
165
              stats={countries}
166
              highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
167
              sortingItems={{
168
                name: 'Country name',
169
                amount: 'Visits amount',
170
              }}
171
              onClick={highlightVisitsForProp('country')}
172
            />
173
          </div>
174
          <div className="col-lg-6">
175
            <SortableBarGraph
176
              title="Cities"
177
              stats={cities}
178
              highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
179
              extraHeaderContent={(activeCities) =>
180 2
                mapLocations.length > 0 &&
181
                <OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
182
              }
183
              sortingItems={{
184
                name: 'City name',
185
                amount: 'Visits amount',
186
              }}
187
              onClick={highlightVisitsForProp('city')}
188
            />
189
          </div>
190
        </div>
191
      );
192
    };
193
194 10
    return (
195
      <React.Fragment>
196
        <VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
197
198
        <section className="mt-4">
199
          <div className="row flex-md-row-reverse">
200
            <div className="col-lg-7 col-xl-6">
201
              <DateRangeRow
202
                disabled={loading}
203
                startDate={startDate}
204
                endDate={endDate}
205
                onStartDateChange={setStartDate}
206
                onEndDateChange={setEndDate}
207
              />
208
            </div>
209
            <div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
210
              {showTableControls && (
211
                <span className={classNames({ row: isMobileDevice })}>
212
                  <span className={classNames({ 'col-6': isMobileDevice })}>
213
                    <Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
214
                      {showTable ? 'Hide' : 'Show'} table
215
                      <FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
216
                    </Button>
217
                  </span>
218
                  <span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
219
                    <Button
220
                      outline
221
                      disabled={highlightedVisits.length === 0}
222
                      block={isMobileDevice}
223
                      onClick={() => setSelectedVisits([])}
224
                    >
225
                      Reset selection
226
                    </Button>
227
                  </span>
228
                </span>
229
              )}
230
            </div>
231
          </div>
232
        </section>
233
234
        {showTableControls && (
235
          <Collapse
236
            isOpen={showTable}
237
            // Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
238
            onEntered={setSticky}
239
            onExiting={unsetSticky}
240
          >
241
            <VisitsTable
242
              visits={normalizedVisits}
243
              selectedVisits={highlightedVisits}
244
              setSelectedVisits={setSelectedVisits}
245
              isSticky={tableIsSticky}
246
            />
247
          </Collapse>
248
        )}
249
250
        <section>
251
          {renderVisitsContent()}
252
        </section>
253
      </React.Fragment>
254
    );
255
  };
256
257 7
  ShortUrlVisitsComp.propTypes = propTypes;
258
259 7
  return ShortUrlVisitsComp;
260
};
261
262
export default ShortUrlVisits;
263