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

mendersoftware / mender-server / 1495380963

14 Oct 2024 03:35PM UTC coverage: 70.373% (-2.5%) from 72.904%
1495380963

Pull #101

gitlab-ci

mineralsfree
feat: tenant list added

Ticket: MEN-7568
Changelog: None

Signed-off-by: Mikita Pilinka <mikita.pilinka@northern.tech>
Pull Request #101: feat: tenant list added

4406 of 6391 branches covered (68.94%)

Branch coverage included in aggregate %.

88 of 183 new or added lines in 10 files covered. (48.09%)

2623 existing lines in 65 files now uncovered.

36673 of 51982 relevant lines covered (70.55%)

31.07 hits per line

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

63.73
/frontend/src/js/components/common/list.tsx
1
import { CSSProperties, ComponentType, MutableRefObject, ReactElement, useCallback, useEffect, useRef, useState } from 'react';
2

3
import { Settings as SettingsIcon, Sort as SortIcon } from '@mui/icons-material';
4
import { Checkbox } from '@mui/material';
5
import { makeStyles } from 'tss-react/mui';
6

7
import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/commonConstants';
8
import { isDarkMode } from '@northern.tech/store/utils';
9

10
import { toggle } from '../../helpers';
11
import useWindowSize from '../../utils/resizehook';
12
import { Tenant } from '../tenants/types';
13
import Loader from './loader';
14
import MenderTooltip from './mendertooltip';
15
import Pagination from './pagination';
16

17
const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS;
7✔
18

19
interface Attribute {
20
  name: string;
21
  scope: string;
22
}
23
interface RendererProp {
24
  column: ColumnHeader;
25
  tenant: Tenant;
26
}
27
export interface ColumnHeader {
28
  component?: ComponentType;
29
  title: string;
30
  attribute: Attribute;
31
  sortable: boolean;
32
  customize?: () => void;
33
  style?: CSSProperties;
34
  textRender: (props: RendererProp) => string | ReactElement;
35
}
36

37
interface SortOptions {
38
  direction?: string;
39
  key?: string;
40
}
41

42
interface ListState {
43
  page?: number;
44
  perPage?: number;
45
  selection?: number[];
46
  sort?: SortOptions;
47
  // selectedAttributes: unknown[];
48
  // selectedIssues: unknown[];
49
  // state: string;
50
  total: number;
51
  // setOnly: boolean;
52
  // refreshTrigger: boolean;
53
  // detailsTab: string;
54
  // isLoading: boolean;
55
}
56

57
interface IdAttribute {
58
  attribute: string;
59
  scope: string;
60
}
61
type wID = { id: string };
62

63
interface CommonListProps<T extends wID> {
64
  listItems: T[];
65
  columnHeaders: ColumnHeader[];
66
  customColumnSizes: Attribute[];
67
  onExpandClick: (item: T) => void;
68
  onResizeColumns: ((columns: { size: number; attribute: Attribute }) => void) | false;
69
  onPageChange: (event: MouseEvent | null, page: number) => void;
70
  onSelect: ((rows: number[]) => void) | false;
71
  onSort: (attr: Attribute | object) => void;
72
  onChangeRowsPerPage: (perPage: number) => void;
73
  pageLoading: boolean;
74
  PaginationProps: object;
75
  idAttribute: IdAttribute;
76
  listState: ListState;
77
  sortingNotes: { [p: string]: string };
78
  ListItemComponent: ComponentType<ListItemComponentProps<T>>;
79
}
80
export interface ListItemComponentProps<T> {
81
  columnHeaders: ColumnHeader[];
82
  listItem: T;
83
  listState: ListState;
84
  idAttribute: IdAttribute;
85
  index: number;
86
  key: string;
87
  onClick: (item: T) => void;
88
  onRowSelect: (selectedRow: T) => void;
89
  selectable: boolean;
90
  selected: boolean;
91
}
92

93
const useStyles = makeStyles()(theme => ({
24✔
94
  header: {
95
    // @ts-ignore
96
    color: theme.palette.text.hint
97
  },
98
  resizer: {
99
    cursor: 'col-resize',
100
    paddingLeft: 5,
101
    paddingRight: 5
102
  },
103
  resizeHandle: {
104
    width: 4,
105
    background: 'initial',
106
    ['&.hovering']: {
107
      background: theme.palette.grey[600]
108
    },
109
    ['&.resizing']: {
110
      background: isDarkMode(theme.palette.mode) ? theme.palette.grey[200] : theme.palette.grey[900]
24!
111
    }
112
  }
113
}));
114

115
export const minCellWidth = 150;
7✔
116

117
export const calculateResizeChange = ({ columnElements, columnHeaders, e, index, prev, selectable }) => {
7✔
NEW
118
  const isShrinkage = prev > e.clientX ? -1 : 1;
×
NEW
119
  const columnDelta = Math.abs(e.clientX - prev) * isShrinkage;
×
NEW
120
  const relevantColumns = getRelevantColumns(columnElements, selectable);
×
NEW
121
  const canModifyNextColumn = index + 1 < columnHeaders.length - 1;
×
122

NEW
123
  return relevantColumns.reduce((accu, element, columnIndex) => {
×
NEW
124
    const currentWidth = element.offsetWidth;
×
NEW
125
    const column = { attribute: columnHeaders[columnIndex + 1].attribute, size: currentWidth };
×
NEW
126
    if (canModifyNextColumn && index === columnIndex) {
×
NEW
127
      column.size = currentWidth + columnDelta;
×
NEW
128
    } else if (canModifyNextColumn && index + 1 === columnIndex) {
×
NEW
129
      column.size = currentWidth - columnDelta;
×
130
    }
NEW
131
    accu.push(column);
×
NEW
132
    return accu;
×
133
  }, []);
134
};
135
const getRelevantColumns = (columnElements, selectable) => [...columnElements].slice(selectable ? 1 : 0, columnElements.length - 1);
9✔
136
const getTemplateColumns = (columns, selectable) =>
7✔
137
  selectable ? `52px ${columns} minmax(${minCellWidth}px, 1fr)` : `${columns} minmax(${minCellWidth}px, 1fr)`;
9✔
138

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

169
  const size = useWindowSize();
14✔
170
  const selectable = !!onSelect;
14✔
171
  const { classes } = useStyles();
14✔
172

173
  useEffect(() => {
14✔
174
    selectedRowsRef.current = selectedRows;
5✔
175
  }, [selectedRows]);
176

177
  useEffect(() => {
14✔
178
    if (!listRef.current) {
9!
NEW
179
      return;
×
180
    }
181
    const relevantColumns = getRelevantColumns(listRef.current?.querySelector('.deviceListRow')?.children, selectable);
9✔
182
    listRef.current.style.gridTemplateColumns = getColumnsStyle(
9✔
183
      customColumnSizes.length && customColumnSizes.length === relevantColumns.length ? customColumnSizes : relevantColumns,
23✔
184
      '1.5fr',
185
      selectable
186
    );
187
  }, [customColumnSizes, columnHeaders, selectable, resizeTrigger, size.width]);
188

189
  useEffect(() => {
14✔
190
    clearTimeout(initRef.current || undefined);
5✔
191
    initRef.current = setTimeout(() => setResizeTrigger(toggle), TIMEOUTS.debounceDefault) as unknown as number;
5✔
192
    return () => {
5✔
193
      clearTimeout(initRef?.current || undefined);
5!
194
    };
195
  }, [customColumnSizes.length]);
196

197
  const onRowSelection = selectedRow => {
14✔
198
    const updatedSelection = [...selectedRowsRef.current];
1✔
199
    const selectedIndex = updatedSelection.indexOf(selectedRow);
1✔
200
    if (selectedIndex === -1) {
1!
201
      updatedSelection.push(selectedRow);
1✔
202
    } else {
NEW
203
      updatedSelection.splice(selectedIndex, 1);
×
204
    }
205
    if (onSelect) {
1!
206
      onSelect(updatedSelection);
1✔
207
    }
208
  };
209

210
  const onSelectAllClick = () => {
14✔
211
    let newSelectedRows: number[] = Array.from({ length: listItems.length }, (_, i) => i);
4✔
212
    if (selectedRows.length && selectedRows.length <= listItems.length) {
2!
NEW
213
      newSelectedRows = [];
×
214
    }
215
    if (onSelect) {
2!
216
      onSelect(newSelectedRows);
2✔
217
    }
218
  };
219

220
  const handleResizeChange = useCallback(
14✔
221
    (e, { index, prev, ref }) => {
NEW
222
      const changedColumns = calculateResizeChange({
×
223
        columnElements: [...ref.current.parentElement.children],
224
        columnHeaders,
225
        e,
226
        index,
227
        prev,
228
        selectable
229
      });
230
      // applying styles via state changes would lead to less smooth changes, so we set the style directly on the components
NEW
231
      if (listRef.current) listRef.current.style.gridTemplateColumns = getColumnsStyle(changedColumns, undefined, selectable);
×
232
    },
233
    [columnHeaders, selectable]
234
  );
235

236
  const handleResizeFinish = useCallback(
14✔
237
    (e, { index, prev, ref }) => {
NEW
238
      const changedColumns = calculateResizeChange({
×
239
        columnElements: ref.current.parentElement.children,
240
        columnHeaders,
241
        e,
242
        index,
243
        prev,
244
        selectable
245
      });
NEW
246
      if (onResizeColumns) {
×
NEW
247
        onResizeColumns(changedColumns);
×
248
      }
249
    },
250
    [columnHeaders, onResizeColumns, selectable]
251
  );
252

253
  const numSelected = (selectedRows || []).length;
14!
254
  return (
14✔
255
    <div className={`deviceList ${selectable ? 'selectable' : ''}`} ref={listRef}>
14✔
256
      <div className={`header ${classes.header}`}>
257
        <div className="deviceListRow">
258
          {selectable && (
27✔
259
            <div>
260
              <Checkbox
261
                indeterminate={numSelected > 0 && numSelected < listItems.length}
19✔
262
                checked={numSelected === listItems.length}
263
                onChange={onSelectAllClick}
264
              />
265
            </div>
266
          )}
267
          {columnHeaders.map((item, index) => (
268
            <HeaderItem
55✔
269
              column={item}
270
              columnCount={columnHeaders.length}
271
              index={index}
272
              key={`columnHeader-${index}`}
273
              onSort={onSort}
274
              resizable={!!onResizeColumns}
275
              sortCol={sortCol}
276
              sortDown={sortDown}
277
              onResizeChange={handleResizeChange}
278
              onResizeFinish={handleResizeFinish}
279
              sortingNotes={sortingNotes}
280
            />
281
          ))}
282
        </div>
283
      </div>
284
      <div className="body">
285
        {listItems.map((item, index) => (
286
          <ListItemComponent
24✔
287
            columnHeaders={columnHeaders}
288
            listItem={item}
289
            listState={listState}
290
            idAttribute={idAttribute}
291
            index={index}
292
            key={item.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
interface HeaderItemProps {
317
  sortingNotes: { [p: string]: string };
318
  column: ColumnHeader;
319
  columnCount: number;
320
  index: number;
321
  resizable: boolean;
322
  onSort: (attr: Attribute | object) => void;
323
  onResizeChange: (
324
    e: MouseEvent,
325
    o: {
326
      index: number;
327
      prev: number;
328
      ref: MutableRefObject<HTMLDivElement | null>;
329
    }
330
  ) => void;
331
  onResizeFinish: (
332
    e: MouseEvent,
333
    o: {
334
      index: number;
335
      prev: number;
336
      ref: MutableRefObject<HTMLDivElement | null>;
337
    }
338
  ) => void;
339
  sortCol?: string;
340
  sortDown?: string;
341
}
342

343
const HeaderItem = (props: HeaderItemProps) => {
7✔
344
  const { sortingNotes, column, columnCount, index, sortCol, sortDown = undefined, onSort, onResizeChange, onResizeFinish, resizable } = props;
55!
345
  const [isHovering, setIsHovering] = useState(false);
55✔
346
  const [shouldRemoveListeners, setShouldRemoveListeners] = useState(false);
55✔
347
  const resizeRef = useRef<null | number>(null);
55✔
348
  const ref = useRef<HTMLDivElement | null>(null);
55✔
349
  const { classes } = useStyles();
55✔
350

351
  const onMouseOut = () => setIsHovering(false);
55✔
352

353
  const onMouseOver = () => setIsHovering(true);
55✔
354

355
  const mouseMove = useCallback(
55✔
356
    (e: MouseEvent) => {
357
      if (resizable && resizeRef.current) {
62!
NEW
358
        onResizeChange(e, { index, prev: resizeRef.current, ref });
×
NEW
359
        resizeRef.current = e.clientX;
×
360
      }
361
    },
362
    [index, onResizeChange, resizable]
363
  );
364

365
  const mouseUp = useCallback(
55✔
366
    (e: MouseEvent) => {
367
      if (resizeRef.current) {
62!
NEW
368
        onResizeFinish(e, { index, prev: resizeRef.current, ref });
×
NEW
369
        resizeRef.current = null;
×
NEW
370
        setShouldRemoveListeners(true);
×
371
      }
372
    },
373
    [index, onResizeFinish]
374
  );
375

376
  const mouseDown = e => (resizeRef.current = e.clientX);
55✔
377

378
  useEffect(() => {
55✔
379
    window.addEventListener('mousemove', mouseMove);
25✔
380
    window.addEventListener('mouseup', mouseUp);
25✔
381
    return () => {
25✔
382
      setShouldRemoveListeners(!!resizeRef.current);
25✔
383
    };
384
  }, [mouseMove, mouseUp]);
385

386
  useEffect(() => {
55✔
387
    if (shouldRemoveListeners) {
25!
NEW
388
      window.removeEventListener('mousemove', mouseMove);
×
NEW
389
      window.removeEventListener('mouseup', mouseUp);
×
NEW
390
      setShouldRemoveListeners(false);
×
391
    }
392
  }, [shouldRemoveListeners, mouseMove, mouseUp]);
393

394
  let resizeHandleClassName = resizable && isHovering ? 'hovering' : '';
55!
395
  resizeHandleClassName = resizeRef.current ? 'resizing' : resizeHandleClassName;
55!
396
  const header = (
397
    <div className="columnHeader flexbox space-between relative" style={column.style} onMouseEnter={onMouseOver} onMouseLeave={onMouseOut} ref={ref}>
55✔
NEW
398
      <div className="flexbox center-aligned" onClick={() => onSort(column.attribute ? column.attribute : {})}>
×
399
        {column.title}
400
        {column.sortable && (
106✔
401
          <SortIcon
402
            className={`sortIcon ${sortCol === column.attribute.name ? 'selected' : ''} ${(sortDown === SORTING_OPTIONS.desc).toString()}`}
51✔
403
            style={{ fontSize: 16 }}
404
          />
405
        )}
406
      </div>
407
      <div className="flexbox center-aligned full-height">
408
        {column.customize && <SettingsIcon onClick={column.customize} style={{ fontSize: 16 }} />}
65✔
409
        {index < columnCount - 2 && resizable && (
121✔
410
          <div onMouseDown={mouseDown} className={`${classes.resizer} full-height`}>
411
            <div className={`full-height ${classes.resizeHandle} ${resizeHandleClassName}`} />
412
          </div>
413
        )}
414
      </div>
415
    </div>
416
  );
417
  return column.sortable && sortingNotes[column.attribute.name] ? (
55!
418
    <MenderTooltip title={sortingNotes[column.attribute.name]} placement="top-start">
419
      {header}
420
    </MenderTooltip>
421
  ) : (
422
    header
423
  );
424
};
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