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

mendersoftware / mender-server / 10425

11 Nov 2025 05:02PM UTC coverage: 78.221% (+3.8%) from 74.435%
10425

Pull #1022

gitlab-ci

mzedel
chore(gui): aligned snapshots w/ updated design

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #1022: MEN-8452 - device filters + device page design adjustments

3865 of 5388 branches covered (71.73%)

Branch coverage included in aggregate %.

34 of 38 new or added lines in 13 files covered. (89.47%)

7 existing lines in 1 file now uncovered.

6845 of 8304 relevant lines covered (82.43%)

68.06 hits per line

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

68.21
/frontend/src/js/common-ui/List.tsx
1
// Copyright 2024 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 { CSSProperties, ComponentType, MutableRefObject, ReactElement, useCallback, useEffect, useRef, useState } from 'react';
15

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

20
import { DEVICE_LIST_DEFAULTS, IdAttribute, SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/constants';
21
import { SortOptions } from '@northern.tech/store/organizationSlice/types';
22
import { isDarkMode } from '@northern.tech/store/utils';
23
import { toggle } from '@northern.tech/utils/helpers';
24
import { useWindowSize } from '@northern.tech/utils/resizehook';
25

26
import Loader from './Loader';
27
import Pagination from './Pagination';
28
import MenderTooltip from './helptips/MenderTooltip';
29

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

32
interface Attribute {
33
  name: string;
34
  scope: string;
35
}
36
export interface RendererProp<T> {
37
  [key: string]: any;
38
  column: ColumnHeader<T>;
39
  item?: T;
40
}
41

42
export interface ClassesOverrides {
43
  classes: Record<string, string>;
44
}
45
export interface ColumnHeader<T> {
46
  attribute: Attribute;
47
  classes?: ClassesOverrides;
48
  component: ComponentType<RendererProp<T> & ClassesOverrides>;
49
  customize?: () => void;
50
  sortable: boolean;
51
  style?: CSSProperties;
52
  textRender?: (props: RendererProp<T>) => string | ReactElement;
53
  title: string;
54
}
55

56
interface ListState {
57
  page?: number;
58
  perPage?: number;
59
  selection?: number[];
60
  sort?: SortOptions;
61
  // selectedAttributes: unknown[];
62
  // selectedIssues: unknown[];
63
  // state: string;
64
  total: number;
65
  // setOnly: boolean;
66
  // refreshTrigger: boolean;
67
  // detailsTab: string;
68
  // isLoading: boolean;
69
}
70

71
type wID = { id: string };
72

73
interface CommonListProps<T extends wID> {
74
  columnHeaders: ColumnHeader<T>[];
75
  customColumnSizes?: Attribute[];
76
  idAttribute?: IdAttribute;
77
  ListItemComponent: ComponentType<ListItemComponentProps<T>>;
78
  listItems: T[];
79
  listState: ListState;
80
  onChangeRowsPerPage: (perPage: number) => void;
81
  onExpandClick: (item: T) => void;
82
  onPageChange: (event: MouseEvent | null, page: number) => void;
83
  onResizeColumns: ((columns: { attribute: Attribute; size: number }) => void) | false;
84
  onSelect: ((rows: number[]) => void) | false;
85
  onSort?: (attr: Attribute | object) => void;
86
  pageLoading: boolean;
87
  PaginationProps?: object;
88
  sortingNotes?: { [key: string]: string };
89
}
90
export interface ListItemComponentProps<T> {
91
  columnHeaders: ColumnHeader<T>[];
92
  idAttribute?: IdAttribute;
93
  index: number;
94
  key: string;
95
  listItem: T;
96
  listState: ListState;
97
  onClick: (item: T) => void;
98
  onRowSelect: (selectedRow: T) => void;
99
  selectable: boolean;
100
  selected: boolean;
101
}
102

103
const useStyles = makeStyles()(theme => ({
42✔
104
  header: {
105
    [`.${typographyClasses.body1}`]: { fontWeight: theme.typography.fontWeightMedium }
106
  },
107
  resizer: {
108
    cursor: 'col-resize',
109
    paddingLeft: 5,
110
    paddingRight: 5
111
  },
112
  resizeHandle: {
113
    width: 4,
114
    background: 'initial',
115
    ['&.hovering']: {
116
      background: theme.palette.grey[600]
117
    },
118
    ['&.resizing']: {
119
      background: isDarkMode(theme.palette.mode) ? theme.palette.grey[200] : theme.palette.grey[900]
42!
120
    }
121
  }
122
}));
123

124
export const minCellWidth = 150;
11✔
125

126
export const calculateResizeChange = ({ columnElements, columnHeaders, e, index, prev, selectable }) => {
11✔
127
  const isShrinkage = prev > e.clientX ? -1 : 1;
×
128
  const columnDelta = Math.abs(e.clientX - prev) * isShrinkage;
×
129
  const relevantColumns = getRelevantColumns(columnElements, selectable);
×
130
  const canModifyNextColumn = index + 1 < columnHeaders.length - 1;
×
131

132
  return relevantColumns.reduce((accu, element, columnIndex) => {
×
133
    const currentWidth = element.offsetWidth;
×
134
    const column = { attribute: columnHeaders[columnIndex + 1].attribute, size: currentWidth };
×
135
    if (canModifyNextColumn && index === columnIndex) {
×
136
      column.size = currentWidth + columnDelta;
×
137
    } else if (canModifyNextColumn && index + 1 === columnIndex) {
×
138
      column.size = currentWidth - columnDelta;
×
139
    }
140
    accu.push(column);
×
141
    return accu;
×
142
  }, []);
143
};
144
const getRelevantColumns = (columnElements, selectable) => [...columnElements].slice(selectable ? 1 : 0, columnElements.length - 1);
16✔
145
const getTemplateColumns = (columns, selectable) =>
11✔
146
  selectable ? `52px ${columns} minmax(${minCellWidth}px, 1fr)` : `${columns} minmax(${minCellWidth}px, 1fr)`;
16✔
147

148
const getColumnsStyle = (columns, defaultSize, selectable) => {
11✔
149
  const template = columns.map(({ size }) => `minmax(${minCellWidth}px, ${size ? `${size}px` : defaultSize})`);
56✔
150
  // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
151
  return getTemplateColumns(template.join(' '), selectable);
16✔
152
};
153
export const CommonList = <T extends wID>(props: CommonListProps<T>) => {
11✔
154
  const {
155
    columnHeaders,
156
    customColumnSizes = [],
21✔
157
    listItems,
158
    listState,
159
    idAttribute,
160
    onChangeRowsPerPage,
161
    PaginationProps = {},
21✔
162
    onExpandClick,
163
    onResizeColumns,
164
    onPageChange,
165
    onSelect,
166
    onSort = () => {},
×
167
    pageLoading,
168
    sortingNotes,
169
    ListItemComponent
170
  } = props;
21✔
171
  const { page: pageNo = defaultPage, perPage: pageLength = defaultPerPage, selection: selectedRows = [], sort = {}, total: pageTotal = 1 } = listState;
21✔
172
  const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol } = sort;
21✔
173
  const listRef = useRef<HTMLDivElement | null>(null);
21✔
174
  const selectedRowsRef = useRef(selectedRows);
21✔
175
  const initRef = useRef<number | null>(null);
21✔
176
  const [resizeTrigger, setResizeTrigger] = useState(false);
21✔
177

178
  const size = useWindowSize();
21✔
179
  const selectable = !!onSelect;
21✔
180
  const { classes } = useStyles();
21✔
181

182
  useEffect(() => {
21✔
183
    selectedRowsRef.current = selectedRows;
8✔
184
  }, [selectedRows]);
185

186
  useEffect(() => {
21✔
187
    if (!listRef.current) {
16!
188
      return;
×
189
    }
190
    const relevantColumns = getRelevantColumns(listRef.current?.querySelector('.deviceListRow')?.children, selectable);
16✔
191
    listRef.current.style.gridTemplateColumns = getColumnsStyle(
16✔
192
      customColumnSizes.length && customColumnSizes.length === relevantColumns.length ? customColumnSizes : relevantColumns,
37✔
193
      '1.5fr',
194
      selectable
195
    );
196
  }, [customColumnSizes, columnHeaders, selectable, resizeTrigger, size.width]);
197

198
  useEffect(() => {
21✔
199
    clearTimeout(initRef.current || undefined);
8✔
200
    initRef.current = setTimeout(() => setResizeTrigger(toggle), TIMEOUTS.debounceDefault) as unknown as number;
8✔
201
    return () => {
8✔
202
      clearTimeout(initRef?.current || undefined);
8!
203
    };
204
  }, [customColumnSizes.length]);
205

206
  const onRowSelection = selectedRow => {
21✔
207
    const updatedSelection = [...selectedRowsRef.current];
1✔
208
    const selectedIndex = updatedSelection.indexOf(selectedRow);
1✔
209
    if (selectedIndex === -1) {
1!
210
      updatedSelection.push(selectedRow);
1✔
211
    } else {
212
      updatedSelection.splice(selectedIndex, 1);
×
213
    }
214
    if (onSelect) {
1!
215
      onSelect(updatedSelection);
1✔
216
    }
217
  };
218

219
  const onSelectAllClick = () => {
21✔
220
    let newSelectedRows: number[] = Array.from({ length: listItems.length }, (_, i) => i);
4✔
221
    if (selectedRows.length && selectedRows.length <= listItems.length) {
2!
222
      newSelectedRows = [];
×
223
    }
224
    if (onSelect) {
2!
225
      onSelect(newSelectedRows);
2✔
226
    }
227
  };
228

229
  const handleResizeChange = useCallback(
21✔
230
    (e, { index, prev, ref }) => {
231
      const changedColumns = calculateResizeChange({
×
232
        columnElements: [...ref.current.parentElement.children],
233
        columnHeaders,
234
        e,
235
        index,
236
        prev,
237
        selectable
238
      });
239
      // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
240
      if (listRef.current) listRef.current.style.gridTemplateColumns = getColumnsStyle(changedColumns, undefined, selectable);
×
241
    },
242
    [columnHeaders, selectable]
243
  );
244

245
  const handleResizeFinish = useCallback(
21✔
246
    (e, { index, prev, ref }) => {
247
      const changedColumns = calculateResizeChange({
×
248
        columnElements: ref.current.parentElement.children,
249
        columnHeaders,
250
        e,
251
        index,
252
        prev,
253
        selectable
254
      });
255
      if (onResizeColumns) {
×
256
        onResizeColumns(changedColumns);
×
257
      }
258
    },
259
    [columnHeaders, onResizeColumns, selectable]
260
  );
261

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

325
interface HeaderItemProps<T> {
326
  column: ColumnHeader<T>;
327
  columnCount: number;
328
  index: number;
329
  onResizeChange: (
330
    e: MouseEvent,
331
    eventData: {
332
      index: number;
333
      prev: number;
334
      ref: MutableRefObject<HTMLDivElement | null>;
335
    }
336
  ) => void;
337
  onResizeFinish: (
338
    e: MouseEvent,
339
    eventData: {
340
      index: number;
341
      prev: number;
342
      ref: MutableRefObject<HTMLDivElement | null>;
343
    }
344
  ) => void;
345
  onSort: (attr: Attribute | object) => void;
346
  resizable: boolean;
347
  sortCol?: string;
348
  sortDown?: string;
349
  sortingNotes?: { [key: string]: string };
350
}
351

352
const HeaderItem = <T extends wID>(props: HeaderItemProps<T>) => {
11✔
353
  const { sortingNotes, column, columnCount, index, sortCol, sortDown = undefined, onSort, onResizeChange, onResizeFinish, resizable } = props;
110✔
354
  const [isHovering, setIsHovering] = useState(false);
110✔
355
  const [shouldRemoveListeners, setShouldRemoveListeners] = useState(false);
110✔
356
  const resizeRef = useRef<null | number>(null);
110✔
357
  const ref = useRef<HTMLDivElement | null>(null);
110✔
358
  const { classes } = useStyles();
110✔
359

360
  const onMouseOut = () => setIsHovering(false);
110✔
361

362
  const onMouseOver = () => setIsHovering(true);
110✔
363

364
  const mouseMove = useCallback(
110✔
365
    (e: MouseEvent) => {
366
      if (resizable && resizeRef.current) {
72!
UNCOV
367
        onResizeChange(e, { index, prev: resizeRef.current, ref });
×
UNCOV
368
        resizeRef.current = e.clientX;
×
369
      }
370
    },
371
    [index, onResizeChange, resizable]
372
  );
373

374
  const mouseUp = useCallback(
110✔
375
    (e: MouseEvent) => {
376
      if (resizeRef.current) {
72!
UNCOV
377
        onResizeFinish(e, { index, prev: resizeRef.current, ref });
×
UNCOV
378
        resizeRef.current = null;
×
379
        setShouldRemoveListeners(true);
×
380
      }
381
    },
382
    [index, onResizeFinish]
383
  );
384

385
  const mouseDown = e => (resizeRef.current = e.clientX);
110✔
386

387
  useEffect(() => {
110✔
388
    window.addEventListener('mousemove', mouseMove);
65✔
389
    window.addEventListener('mouseup', mouseUp);
65✔
390
    return () => {
65✔
391
      setShouldRemoveListeners(!!resizeRef.current);
65✔
392
    };
393
  }, [mouseMove, mouseUp]);
394

395
  useEffect(() => {
110✔
396
    if (shouldRemoveListeners) {
65!
UNCOV
397
      window.removeEventListener('mousemove', mouseMove);
×
UNCOV
398
      window.removeEventListener('mouseup', mouseUp);
×
399
      setShouldRemoveListeners(false);
×
400
    }
401
  }, [shouldRemoveListeners, mouseMove, mouseUp]);
402

403
  let resizeHandleClassName = resizable && isHovering ? 'hovering' : '';
110!
404
  resizeHandleClassName = resizeRef.current ? 'resizing' : resizeHandleClassName;
110!
405
  const header = (
406
    <div className="columnHeader flexbox space-between relative" style={column.style} onMouseEnter={onMouseOver} onMouseLeave={onMouseOut} ref={ref}>
110✔
NEW
UNCOV
407
      <Typography className="flexbox center-aligned" onClick={() => onSort(column.attribute ? column.attribute : {})}>
×
408
        {column.title}
409
        {column.sortable && (
201✔
410
          <SortIcon
411
            className={`sortIcon ${sortCol === column.attribute.name ? 'selected' : ''} ${(sortDown === SORTING_OPTIONS.desc).toString()}`}
91✔
412
            style={{ fontSize: 16 }}
413
          />
414
        )}
415
      </Typography>
416
      <div className="flexbox center-aligned full-height">
417
        {column.customize && <SettingsIcon onClick={column.customize} style={{ fontSize: 16 }} />}
128✔
418
        {index < columnCount - 2 && resizable && (
239✔
419
          <div onMouseDown={mouseDown} className={`${classes.resizer} full-height`}>
420
            <div className={`full-height ${classes.resizeHandle} ${resizeHandleClassName}`} />
421
          </div>
422
        )}
423
      </div>
424
    </div>
425
  );
426
  return column.sortable && sortingNotes && sortingNotes[column.attribute.name] ? (
110!
427
    <MenderTooltip title={sortingNotes[column.attribute.name]} placement="top-start">
428
      {header}
429
    </MenderTooltip>
430
  ) : (
431
    header
432
  );
433
};
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