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

mendersoftware / gui / 1350829378

27 Jun 2024 01:46PM UTC coverage: 83.494% (-16.5%) from 99.965%
1350829378

Pull #4465

gitlab-ci

mzedel
chore: test fixes

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4465: MEN-7169 - feat: added multi sorting capabilities to devices view

4506 of 6430 branches covered (70.08%)

81 of 100 new or added lines in 14 files covered. (81.0%)

1661 existing lines in 163 files now uncovered.

8574 of 10269 relevant lines covered (83.49%)

160.6 hits per line

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

83.05
/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 } from '@mui/icons-material';
18
import { Checkbox } from '@mui/material';
19
import { makeStyles } from 'tss-react/mui';
20

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

32
const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS;
8✔
33

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

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

59
const HeaderItem = ({ column, columnCount, index, sortOptions, onSort, onResizeChange, onResizeFinish, resizable }) => {
8✔
60
  const [isHovering, setIsHovering] = useState(false);
60✔
61
  const [shouldRemoveListeners, setShouldRemoveListeners] = useState(false);
60✔
62
  const { direction, key } = sortOptions.find(({ key, scope }) => column.attribute.name === key && column.attribute.scope === scope) ?? {};
60!
63
  const [sortState, setSortState] = useState({ disabled: !key, direction });
60✔
64
  const sortDown = key && direction === SORTING_OPTIONS.desc;
60!
65
  const resizeRef = useRef();
60✔
66
  const ref = useRef();
60✔
67
  const { classes } = useStyles();
60✔
68

69
  const isSortable = column.sortable && !!onSort;
60✔
70

71
  const debouncedSortState = useDebounce(sortState, TIMEOUTS.debounceShort);
60✔
72

73
  const onMouseOut = () => setIsHovering(false);
60✔
74

75
  const onMouseOver = () => setIsHovering(true);
60✔
76

77
  const mouseMove = useCallback(
60✔
78
    e => {
79
      if (resizable && resizeRef.current) {
62!
UNCOV
80
        onResizeChange(e, { index, prev: resizeRef.current, ref });
×
UNCOV
81
        resizeRef.current = e.clientX;
×
82
      }
83
    },
84
    [index, onResizeChange, resizable]
85
  );
86

87
  const mouseUp = useCallback(
60✔
88
    e => {
89
      if (resizeRef.current) {
62!
UNCOV
90
        onResizeFinish(e, { index, prev: resizeRef.current, ref });
×
UNCOV
91
        resizeRef.current = null;
×
UNCOV
92
        setShouldRemoveListeners(true);
×
93
      }
94
    },
95
    [index, onResizeFinish]
96
  );
97

98
  const mouseDown = e => (resizeRef.current = e.clientX);
60✔
99

100
  useEffect(() => {
60✔
101
    window.addEventListener('mousemove', mouseMove);
30✔
102
    window.addEventListener('mouseup', mouseUp);
30✔
103
    return () => {
30✔
104
      setShouldRemoveListeners(!!resizeRef.current);
30✔
105
    };
106
  }, [mouseMove, mouseUp]);
107

108
  useEffect(() => {
60✔
109
    if (shouldRemoveListeners) {
30!
UNCOV
110
      window.removeEventListener('mousemove', mouseMove);
×
UNCOV
111
      window.removeEventListener('mouseup', mouseUp);
×
UNCOV
112
      setShouldRemoveListeners(false);
×
113
    }
114
  }, [shouldRemoveListeners, mouseMove, mouseUp]);
115

116
  useEffect(() => {
60✔
117
    if (!onSort) {
21✔
118
      return;
4✔
119
    }
120
    onSort({ key: column.attribute.name, direction: debouncedSortState.direction, disabled: debouncedSortState.disabled, scope: column.attribute.scope });
17✔
121
  }, [column.attribute.name, column.attribute.scope, debouncedSortState.direction, debouncedSortState.disabled, onSort]);
122

123
  const onSortClick = () => {
60✔
NEW
124
    if (!isSortable) {
×
NEW
125
      return;
×
126
    }
NEW
127
    const nextDirectionIndex = SORT_DIRECTIONS.indexOf(sortState.direction) + 1;
×
NEW
128
    const direction = SORT_DIRECTIONS[nextDirectionIndex] ?? '';
×
NEW
129
    setSortState({ direction, disabled: !direction });
×
130
  };
131

132
  let resizeHandleClassName = resizable && isHovering ? 'hovering' : '';
60!
133
  resizeHandleClassName = resizeRef.current ? 'resizing' : resizeHandleClassName;
60!
134

135
  const header = (
136
    <div
60✔
137
      className={`columnHeader flexbox space-between relative ${isSortable ? 'sortable' : ''}`}
60✔
138
      style={column.style}
139
      onMouseEnter={onMouseOver}
140
      onMouseLeave={onMouseOut}
141
      ref={ref}
142
    >
143
      <div className="flexbox center-aligned" onClick={onSortClick}>
144
        {column.title}
145
        {isSortable && <SortIcon columnKey={key} disabled={sortState.disabled} sortDown={sortDown} />}
116✔
146
      </div>
147
      <div className="flexbox center-aligned full-height">
148
        {column.customize && <SettingsIcon onClick={column.customize} style={{ fontSize: 16 }} />}
71✔
149
        {index < columnCount - 2 && (
96✔
150
          <div onMouseDown={mouseDown} className={`${classes.resizer} full-height`}>
151
            <div className={`full-height ${classes.resizeHandle} ${resizeHandleClassName}`} />
152
          </div>
153
        )}
154
      </div>
155
    </div>
156
  );
157
  return isSortable && sortingNotes[column.attribute.name] ? (
60!
158
    <MenderTooltip title={sortingNotes[column.attribute.name]} placement="top-start">
159
      {header}
160
    </MenderTooltip>
161
  ) : (
162
    header
163
  );
164
};
165

166
const getRelevantColumns = (columnElements, selectable) => [...columnElements].slice(selectable ? 1 : 0, columnElements.length - 1);
13✔
167

168
export const calculateResizeChange = ({ columnElements, columnHeaders, e, index, prev, selectable }) => {
8✔
169
  const isShrinkage = prev > e.clientX ? -1 : 1;
3✔
170
  const columnDelta = Math.abs(e.clientX - prev) * isShrinkage;
3✔
171
  const relevantColumns = getRelevantColumns(columnElements, selectable);
3✔
172
  const canModifyNextColumn = index + 1 < columnHeaders.length - 1;
3✔
173

174
  return relevantColumns.reduce((accu, element, columnIndex) => {
3✔
175
    const currentWidth = element.offsetWidth;
15✔
176
    let column = { attribute: columnHeaders[columnIndex + 1].attribute, size: currentWidth };
15✔
177
    if (canModifyNextColumn && index === columnIndex) {
15✔
178
      column.size = currentWidth + columnDelta;
2✔
179
    } else if (canModifyNextColumn && index + 1 === columnIndex) {
13✔
180
      column.size = currentWidth - columnDelta;
2✔
181
    }
182
    accu.push(column);
15✔
183
    return accu;
15✔
184
  }, []);
185
};
186

187
export const minCellWidth = 150;
8✔
188
const getTemplateColumns = (columns, selectable) =>
8✔
189
  selectable ? `52px ${columns} minmax(${minCellWidth}px, 1fr)` : `${columns} minmax(${minCellWidth}px, 1fr)`;
10✔
190

191
const getColumnsStyle = (columns, defaultSize, selectable) => {
8✔
192
  const template = columns.map(({ size }) => `minmax(${minCellWidth}px, ${size ? `${size}px` : defaultSize})`);
32✔
193
  // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
194
  return getTemplateColumns(template.join(' '), selectable);
10✔
195
};
196

197
export const DeviceList = ({
8✔
198
  columnHeaders,
199
  customColumnSizes,
200
  devices,
201
  deviceListState,
202
  idAttribute,
203
  onChangeRowsPerPage,
204
  PaginationProps = {},
15✔
205
  onExpandClick,
206
  onResizeColumns,
207
  onPageChange,
208
  onSelect,
209
  onSort,
210
  pageLoading,
211
  pageTotal
212
}) => {
213
  const { page: pageNo = defaultPage, perPage: pageLength = defaultPerPage, selection: selectedRows = [], sort = [] } = deviceListState;
15!
214
  const deviceListRef = useRef();
15✔
215
  const selectedRowsRef = useRef(selectedRows);
15✔
216
  const initRef = useRef();
15✔
217
  const [resizeTrigger, setResizeTrigger] = useState(false);
15✔
218

219
  const size = useWindowSize();
15✔
220
  const selectable = !!onSelect;
15✔
221
  const { classes } = useStyles();
15✔
222

223
  useEffect(() => {
15✔
224
    selectedRowsRef.current = selectedRows;
5✔
225
  }, [selectedRows]);
226

227
  useEffect(() => {
15✔
228
    if (!deviceListRef.current) {
10!
UNCOV
229
      return;
×
230
    }
231
    const relevantColumns = getRelevantColumns(deviceListRef.current.querySelector('.deviceListRow').children, selectable);
10✔
232
    deviceListRef.current.style.gridTemplateColumns = getColumnsStyle(
10✔
233
      customColumnSizes.length && customColumnSizes.length === relevantColumns.length ? customColumnSizes : relevantColumns,
25✔
234
      '1.5fr',
235
      selectable
236
    );
237
  }, [customColumnSizes, columnHeaders, selectable, resizeTrigger, size.width]);
238

239
  useEffect(() => {
15✔
240
    clearTimeout(initRef.current);
5✔
241
    initRef.current = setTimeout(() => setResizeTrigger(toggle), TIMEOUTS.debounceDefault);
5✔
242
    return () => {
5✔
243
      clearTimeout(initRef.current);
5✔
244
    };
245
  }, [customColumnSizes.length]);
246

247
  const onRowSelection = selectedRow => {
15✔
248
    let updatedSelection = [...selectedRowsRef.current];
1✔
249
    const selectedIndex = updatedSelection.indexOf(selectedRow);
1✔
250
    if (selectedIndex === -1) {
1!
251
      updatedSelection.push(selectedRow);
1✔
252
    } else {
UNCOV
253
      updatedSelection.splice(selectedIndex, 1);
×
254
    }
255
    onSelect(updatedSelection);
1✔
256
  };
257

258
  const onSelectAllClick = () => {
15✔
259
    let newSelectedRows = Array.apply(null, { length: devices.length }).map(Number.call, Number);
2✔
260
    if (selectedRows.length && selectedRows.length <= devices.length) {
2!
UNCOV
261
      newSelectedRows = [];
×
262
    }
263
    onSelect(newSelectedRows);
2✔
264
  };
265

266
  const handleResizeChange = useCallback(
15✔
267
    (e, { index, prev, ref }) => {
UNCOV
268
      const changedColumns = calculateResizeChange({ columnElements: [...ref.current.parentElement.children], columnHeaders, e, index, prev, selectable });
×
269
      // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
UNCOV
270
      deviceListRef.current.style.gridTemplateColumns = getColumnsStyle(changedColumns, undefined, selectable);
×
271
    },
272
    [columnHeaders, selectable]
273
  );
274

275
  const handleResizeFinish = useCallback(
15✔
276
    (e, { index, prev, ref }) => {
UNCOV
277
      const changedColumns = calculateResizeChange({ columnElements: ref.current.parentElement.children, columnHeaders, e, index, prev, selectable });
×
UNCOV
278
      onResizeColumns(changedColumns);
×
279
    },
280
    [columnHeaders, onResizeColumns, selectable]
281
  );
282

283
  const numSelected = (selectedRows || []).length;
15!
284
  return (
15✔
285
    <div className={`deviceList ${selectable ? 'selectable' : ''}`} ref={deviceListRef}>
15✔
286
      <div className={`header ${classes.header}`}>
287
        <div className="deviceListRow">
288
          {selectable && (
29✔
289
            <div>
290
              <Checkbox indeterminate={numSelected > 0 && numSelected < devices.length} checked={numSelected === devices.length} onChange={onSelectAllClick} />
20✔
291
            </div>
292
          )}
293
          {columnHeaders.map((item, index) => (
294
            <HeaderItem
60✔
295
              column={item}
296
              columnCount={columnHeaders.length}
297
              index={index}
298
              key={`columnHeader-${index}`}
299
              onSort={onSort}
300
              resizable={!!onResizeColumns}
301
              onResizeChange={handleResizeChange}
302
              onResizeFinish={handleResizeFinish}
303
              sortOptions={sort}
304
            />
305
          ))}
306
        </div>
307
      </div>
308
      <div className="body">
309
        {devices.map((device, index) => (
310
          <DeviceListItem
25✔
311
            columnHeaders={columnHeaders}
312
            device={device}
313
            deviceListState={deviceListState}
314
            idAttribute={idAttribute.attribute}
315
            index={index}
316
            key={device.id}
317
            onClick={onExpandClick}
318
            onRowSelect={onRowSelection}
319
            selectable={selectable}
320
            selected={selectedRows.indexOf(index) !== -1}
321
          />
322
        ))}
323
      </div>
324
      <div className="footer flexbox margin-top">
325
        <Pagination
326
          className="margin-top-none"
327
          count={pageTotal}
328
          rowsPerPage={pageLength}
329
          onChangeRowsPerPage={onChangeRowsPerPage}
330
          page={pageNo}
331
          onChangePage={onPageChange}
332
          {...PaginationProps}
333
        />
334
        <Loader show={pageLoading} small />
335
      </div>
336
    </div>
337
  );
338
};
339

340
const areEqual = (prevProps, nextProps) => {
8✔
341
  if (
17✔
342
    prevProps.pageTotal != nextProps.pageTotal ||
77✔
343
    prevProps.pageLoading != nextProps.pageLoading ||
344
    prevProps.idAttribute != nextProps.idAttribute ||
345
    !deepCompare(prevProps.columnHeaders, nextProps.columnHeaders) ||
346
    !deepCompare(prevProps.customColumnSizes, nextProps.customColumnSizes) ||
347
    !deepCompare(prevProps.devices, nextProps.devices)
348
  ) {
349
    return false;
8✔
350
  }
351
  return deepCompare(prevProps.deviceListState, nextProps.deviceListState);
9✔
352
};
353

354
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