• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / gui / 1081664682

22 Nov 2023 02:11PM UTC coverage: 82.798% (-17.2%) from 99.964%
1081664682

Pull #4214

gitlab-ci

tranchitella
fix: Fixed the infinite page redirects when the back button is pressed

Remove the location and navigate from the useLocationParams.setValue callback
dependencies as they change the set function that is presented in other
useEffect dependencies. This happens when the back button is clicked, which
leads to the location changing infinitely.

Changelog: Title
Ticket: MEN-6847
Ticket: MEN-6796

Signed-off-by: Ihor Aleksandrychiev <ihor.aleksandrychiev@northern.tech>
Signed-off-by: Fabio Tranchitella <fabio.tranchitella@northern.tech>
Pull Request #4214: fix: Fixed the infinite page redirects when the back button is pressed

4319 of 6292 branches covered (0.0%)

8332 of 10063 relevant lines covered (82.8%)

191.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.76
/src/js/components/devices/devicelist.js
1
// Copyright 2015 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
15

16
// material ui
17
import { Settings as SettingsIcon, Sort as SortIcon } from '@mui/icons-material';
18
import { Checkbox } from '@mui/material';
19
import { makeStyles } from 'tss-react/mui';
20

21
import { SORTING_OPTIONS, TIMEOUTS } from '../../constants/appConstants';
22
import { DEVICE_LIST_DEFAULTS } from '../../constants/deviceConstants';
23
import { deepCompare, isDarkMode, toggle } from '../../helpers';
24
import useWindowSize from '../../utils/resizehook';
25
import Loader from '../common/loader';
26
import MenderTooltip from '../common/mendertooltip';
27
import Pagination from '../common/pagination';
28
import DeviceListItem from './devicelistitem';
29

30
const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS;
8✔
31

32
const sortingNotes = {
8✔
33
  name: 'Sorting by Name will only work properly with devices that already have a device name defined'
34
};
35

36
const useStyles = makeStyles()(theme => ({
23✔
37
  header: {
38
    color: theme.palette.text.hint
39
  },
40
  resizer: {
41
    cursor: 'col-resize',
42
    paddingLeft: 5,
43
    paddingRight: 5
44
  },
45
  resizeHandle: {
46
    width: 4,
47
    background: 'initial',
48
    ['&.hovering']: {
49
      background: theme.palette.grey[600]
50
    },
51
    ['&.resizing']: {
52
      background: isDarkMode(theme.palette.mode) ? theme.palette.grey[200] : theme.palette.grey[900]
23!
53
    }
54
  }
55
}));
56

57
const HeaderItem = ({ column, columnCount, index, sortCol, sortDown, onSort, onResizeChange, onResizeFinish, resizable }) => {
8✔
58
  const [isHovering, setIsHovering] = useState(false);
54✔
59
  const [shouldRemoveListeners, setShouldRemoveListeners] = useState(false);
54✔
60
  const resizeRef = useRef();
54✔
61
  const ref = useRef();
54✔
62
  const { classes } = useStyles();
54✔
63

64
  const onMouseOut = () => setIsHovering(false);
54✔
65

66
  const onMouseOver = () => setIsHovering(true);
54✔
67

68
  const mouseMove = useCallback(
54✔
69
    e => {
70
      if (resizable && resizeRef.current) {
82!
71
        onResizeChange(e, { index, prev: resizeRef.current, ref });
×
72
        resizeRef.current = e.clientX;
×
73
      }
74
    },
75
    [index, onResizeChange, resizable]
76
  );
77

78
  const mouseUp = useCallback(
54✔
79
    e => {
80
      if (resizeRef.current) {
82!
81
        onResizeFinish(e, { index, prev: resizeRef.current, ref });
×
82
        resizeRef.current = null;
×
83
        setShouldRemoveListeners(true);
×
84
      }
85
    },
86
    [index, onResizeFinish]
87
  );
88

89
  const mouseDown = e => (resizeRef.current = e.clientX);
54✔
90

91
  useEffect(() => {
54✔
92
    window.addEventListener('mousemove', mouseMove);
24✔
93
    window.addEventListener('mouseup', mouseUp);
24✔
94
    return () => {
24✔
95
      setShouldRemoveListeners(!!resizeRef.current);
24✔
96
    };
97
  }, [mouseMove, mouseUp]);
98

99
  useEffect(() => {
54✔
100
    if (shouldRemoveListeners) {
24!
101
      window.removeEventListener('mousemove', mouseMove);
×
102
      window.removeEventListener('mouseup', mouseUp);
×
103
      setShouldRemoveListeners(false);
×
104
    }
105
  }, [shouldRemoveListeners, mouseMove, mouseUp]);
106

107
  let resizeHandleClassName = resizable && isHovering ? 'hovering' : '';
54!
108
  resizeHandleClassName = resizeRef.current ? 'resizing' : resizeHandleClassName;
54!
109

110
  const header = (
111
    <div className="columnHeader flexbox space-between relative" style={column.style} onMouseEnter={onMouseOver} onMouseLeave={onMouseOut} ref={ref}>
54✔
112
      <div className="flexbox center-aligned" onClick={() => onSort(column.attribute ? column.attribute : {})}>
×
113
        {column.title}
114
        {column.sortable && (
104✔
115
          <SortIcon
116
            className={`sortIcon ${sortCol === column.attribute.name ? 'selected' : ''} ${(sortDown === SORTING_OPTIONS.desc).toString()}`}
50✔
117
            style={{ fontSize: 16 }}
118
          />
119
        )}
120
      </div>
121
      <div className="flexbox center-aligned full-height">
122
        {column.customize && <SettingsIcon onClick={column.customize} style={{ fontSize: 16 }} />}
64✔
123
        {index < columnCount - 2 && (
86✔
124
          <div onMouseDown={mouseDown} className={`${classes.resizer} full-height`}>
125
            <div className={`full-height ${classes.resizeHandle} ${resizeHandleClassName}`} />
126
          </div>
127
        )}
128
      </div>
129
    </div>
130
  );
131
  return column.sortable && sortingNotes[column.attribute.name] ? (
54!
132
    <MenderTooltip title={sortingNotes[column.attribute.name]} placement="top-start">
133
      {header}
134
    </MenderTooltip>
135
  ) : (
136
    header
137
  );
138
};
139

140
const getRelevantColumns = (columnElements, selectable) => [...columnElements].slice(selectable ? 1 : 0, columnElements.length - 1);
12✔
141

142
export const calculateResizeChange = ({ columnElements, columnHeaders, e, index, prev, selectable }) => {
8✔
143
  const isShrinkage = prev > e.clientX ? -1 : 1;
3✔
144
  const columnDelta = Math.abs(e.clientX - prev) * isShrinkage;
3✔
145
  const relevantColumns = getRelevantColumns(columnElements, selectable);
3✔
146
  const canModifyNextColumn = index + 1 < columnHeaders.length - 1;
3✔
147

148
  return relevantColumns.reduce((accu, element, columnIndex) => {
3✔
149
    const currentWidth = element.offsetWidth;
15✔
150
    let column = { attribute: columnHeaders[columnIndex + 1].attribute, size: currentWidth };
15✔
151
    if (canModifyNextColumn && index === columnIndex) {
15✔
152
      column.size = currentWidth + columnDelta;
2✔
153
    } else if (canModifyNextColumn && index + 1 === columnIndex) {
13✔
154
      column.size = currentWidth - columnDelta;
2✔
155
    }
156
    accu.push(column);
15✔
157
    return accu;
15✔
158
  }, []);
159
};
160

161
export const minCellWidth = 150;
8✔
162
const getTemplateColumns = (columns, selectable) =>
8✔
163
  selectable ? `52px ${columns} minmax(${minCellWidth}px, 1fr)` : `${columns} minmax(${minCellWidth}px, 1fr)`;
9✔
164

165
const getColumnsStyle = (columns, defaultSize, selectable) => {
8✔
166
  const template = columns.map(({ size }) => `minmax(${minCellWidth}px, ${size ? `${size}px` : defaultSize})`);
27✔
167
  // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
168
  return getTemplateColumns(template.join(' '), selectable);
9✔
169
};
170

171
export const DeviceList = ({
8✔
172
  columnHeaders,
173
  customColumnSizes,
174
  devices,
175
  deviceListState,
176
  idAttribute,
177
  onChangeRowsPerPage,
178
  PaginationProps = {},
13✔
179
  onExpandClick,
180
  onResizeColumns,
181
  onPageChange,
182
  onSelect,
183
  onSort,
184
  pageLoading,
185
  pageTotal
186
}) => {
187
  const { page: pageNo = defaultPage, perPage: pageLength = defaultPerPage, selection: selectedRows = [], sort = {} } = deviceListState;
13!
188
  const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol } = sort;
13!
189
  const deviceListRef = useRef();
13✔
190
  const selectedRowsRef = useRef(selectedRows);
13✔
191
  const initRef = useRef();
13✔
192
  const [resizeTrigger, setResizeTrigger] = useState(false);
13✔
193

194
  const size = useWindowSize();
13✔
195
  const selectable = !!onSelect;
13✔
196
  const { classes } = useStyles();
13✔
197

198
  useEffect(() => {
13✔
199
    selectedRowsRef.current = selectedRows;
5✔
200
  }, [selectedRows]);
201

202
  useEffect(() => {
13✔
203
    if (!deviceListRef.current) {
9!
204
      return;
×
205
    }
206
    const relevantColumns = getRelevantColumns(deviceListRef.current.querySelector('.deviceListRow').children, selectable);
9✔
207
    deviceListRef.current.style.gridTemplateColumns = getColumnsStyle(
9✔
208
      customColumnSizes.length && customColumnSizes.length === relevantColumns.length ? customColumnSizes : relevantColumns,
23✔
209
      '1.5fr',
210
      selectable
211
    );
212
  }, [customColumnSizes, columnHeaders, selectable, resizeTrigger, size.width]);
213

214
  useEffect(() => {
13✔
215
    clearTimeout(initRef.current);
5✔
216
    initRef.current = setTimeout(() => setResizeTrigger(toggle), TIMEOUTS.debounceDefault);
5✔
217
    return () => {
5✔
218
      clearTimeout(initRef.current);
5✔
219
    };
220
  }, [customColumnSizes.length]);
221

222
  const onRowSelection = selectedRow => {
13✔
223
    let updatedSelection = [...selectedRowsRef.current];
1✔
224
    const selectedIndex = updatedSelection.indexOf(selectedRow);
1✔
225
    if (selectedIndex === -1) {
1!
226
      updatedSelection.push(selectedRow);
1✔
227
    } else {
228
      updatedSelection.splice(selectedIndex, 1);
×
229
    }
230
    onSelect(updatedSelection);
1✔
231
  };
232

233
  const onSelectAllClick = () => {
13✔
234
    let newSelectedRows = Array.apply(null, { length: devices.length }).map(Number.call, Number);
2✔
235
    if (selectedRows.length && selectedRows.length <= devices.length) {
2!
236
      newSelectedRows = [];
×
237
    }
238
    onSelect(newSelectedRows);
2✔
239
  };
240

241
  const handleResizeChange = useCallback(
13✔
242
    (e, { index, prev, ref }) => {
243
      const changedColumns = calculateResizeChange({ columnElements: [...ref.current.parentElement.children], columnHeaders, e, index, prev, selectable });
×
244
      // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
245
      deviceListRef.current.style.gridTemplateColumns = getColumnsStyle(changedColumns, undefined, selectable);
×
246
    },
247
    [columnHeaders, selectable]
248
  );
249

250
  const handleResizeFinish = useCallback(
13✔
251
    (e, { index, prev, ref }) => {
252
      const changedColumns = calculateResizeChange({ columnElements: ref.current.parentElement.children, columnHeaders, e, index, prev, selectable });
×
253
      onResizeColumns(changedColumns);
×
254
    },
255
    [columnHeaders, onResizeColumns, selectable]
256
  );
257

258
  const numSelected = (selectedRows || []).length;
13!
259
  return (
13✔
260
    <div className={`deviceList ${selectable ? 'selectable' : ''}`} ref={deviceListRef}>
13✔
261
      <div className={`header ${classes.header}`}>
262
        <div className="deviceListRow">
263
          {selectable && (
25✔
264
            <div>
265
              <Checkbox indeterminate={numSelected > 0 && numSelected < devices.length} checked={numSelected === devices.length} onChange={onSelectAllClick} />
19✔
266
            </div>
267
          )}
268
          {columnHeaders.map((item, index) => (
269
            <HeaderItem
54✔
270
              column={item}
271
              columnCount={columnHeaders.length}
272
              index={index}
273
              key={`columnHeader-${index}`}
274
              onSort={onSort}
275
              resizable={!!onResizeColumns}
276
              sortCol={sortCol}
277
              sortDown={sortDown}
278
              onResizeChange={handleResizeChange}
279
              onResizeFinish={handleResizeFinish}
280
            />
281
          ))}
282
        </div>
283
      </div>
284
      <div className="body">
285
        {devices.map((device, index) => (
286
          <DeviceListItem
20✔
287
            columnHeaders={columnHeaders}
288
            device={device}
289
            deviceListState={deviceListState}
290
            idAttribute={idAttribute.attribute}
291
            index={index}
292
            key={device.id}
293
            onClick={onExpandClick}
294
            onRowSelect={onRowSelection}
295
            selectable={selectable}
296
            selected={selectedRows.indexOf(index) !== -1}
297
          />
298
        ))}
299
      </div>
300
      <div className="footer flexbox margin-top">
301
        <Pagination
302
          className="margin-top-none"
303
          count={pageTotal}
304
          rowsPerPage={pageLength}
305
          onChangeRowsPerPage={onChangeRowsPerPage}
306
          page={pageNo}
307
          onChangePage={onPageChange}
308
          {...PaginationProps}
309
        />
310
        <Loader show={pageLoading} small />
311
      </div>
312
    </div>
313
  );
314
};
315

316
const areEqual = (prevProps, nextProps) => {
8✔
317
  if (
18✔
318
    prevProps.pageTotal != nextProps.pageTotal ||
89✔
319
    prevProps.pageLoading != nextProps.pageLoading ||
320
    prevProps.idAttribute != nextProps.idAttribute ||
321
    !deepCompare(prevProps.columnHeaders, nextProps.columnHeaders) ||
322
    !deepCompare(prevProps.customColumnSizes, nextProps.customColumnSizes) ||
323
    !deepCompare(prevProps.devices, nextProps.devices)
324
  ) {
325
    return false;
6✔
326
  }
327
  return deepCompare(prevProps.deviceListState, nextProps.deviceListState);
12✔
328
};
329

330
export default memo(DeviceList, areEqual);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc