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

mendersoftware / mender-server / 10423

11 Nov 2025 04:53PM UTC coverage: 74.435% (-0.1%) from 74.562%
10423

push

gitlab-ci

web-flow
Merge pull request #1071 from mendersoftware/dependabot/npm_and_yarn/frontend/main/development-dependencies-92732187be

3868 of 5393 branches covered (71.72%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

176 existing lines in 95 files now uncovered.

64605 of 86597 relevant lines covered (74.6%)

7.74 hits per line

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

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

2✔
18
import { AddCircle as AddIcon } from '@mui/icons-material';
2✔
19
import { DialogContent } from '@mui/material';
2✔
20

2✔
21
import { BaseDialog } from '@northern.tech/common-ui/dialogs/BaseDialog';
2✔
22
import storeActions from '@northern.tech/store/actions';
2✔
23
import { DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, SORTING_OPTIONS, emptyFilter, onboardingSteps } from '@northern.tech/store/constants';
2✔
24
import { useLocationParams } from '@northern.tech/store/liststatehook';
2✔
25
import {
2✔
26
  getAcceptedDevices,
2✔
27
  getDeviceCountsByStatus,
2✔
28
  getDeviceFilters,
2✔
29
  getDeviceLimit,
2✔
30
  getFeatures,
2✔
31
  getGroups as getGroupsSelector,
2✔
32
  getIsEnterprise,
2✔
33
  getIsPreview,
2✔
34
  getLimitMaxed,
2✔
35
  getOnboardingState,
2✔
36
  getSelectedGroupInfo,
2✔
37
  getSortedFilteringAttributes,
2✔
38
  getTenantCapabilities,
2✔
39
  getUserCapabilities
2✔
40
} from '@northern.tech/store/selectors';
2✔
41
import {
2✔
42
  addDynamicGroup,
2✔
43
  addStaticGroup,
2✔
44
  removeDevicesFromGroup,
2✔
45
  removeDynamicGroup,
2✔
46
  removeStaticGroup,
2✔
47
  selectGroup,
2✔
48
  setDeviceListState,
2✔
49
  setOfflineThreshold,
2✔
50
  updateDynamicGroup
2✔
51
} from '@northern.tech/store/thunks';
2✔
52
import { toggle } from '@northern.tech/utils/helpers';
2✔
53
import pluralize from 'pluralize';
2✔
54

2✔
55
import { getOnboardingComponentFor } from '../../utils/onboardingManager';
2✔
56
import Global from '../settings/Global';
2✔
57
import AuthorizedDevices from './AuthorizedDevices';
2✔
58
import DeviceStatusNotification from './DeviceStatusNotification';
2✔
59
import Groups from './Groups';
2✔
60
import MakeGatewayDialog from './dialogs/MakeGatewayDialog';
2✔
61
import PreauthDialog, { DeviceLimitWarning } from './dialogs/PreauthDialog';
2✔
62
import CreateGroup from './group-management/CreateGroup';
2✔
63
import CreateGroupExplainer from './group-management/CreateGroupExplainer';
2✔
64
import RemoveGroup from './group-management/RemoveGroup';
2✔
65
import DeviceAdditionWidget from './widgets/DeviceAdditionWidget';
2✔
66

2✔
67
const { setDeviceFilters, setShowConnectingDialog } = storeActions;
6✔
68

2✔
69
export const DeviceGroups = () => {
6✔
70
  const [createGroupExplanation, setCreateGroupExplanation] = useState(false);
15✔
71
  const [fromFilters, setFromFilters] = useState(false);
15✔
72
  const [modifyGroupDialog, setModifyGroupDialog] = useState(false);
15✔
73
  const [openIdDialog, setOpenIdDialog] = useState(false);
15✔
74
  const [openPreauth, setOpenPreauth] = useState(false);
15✔
75
  const [showMakeGateway, setShowMakeGateway] = useState(false);
15✔
76
  const [removeGroup, setRemoveGroup] = useState(false);
15✔
77
  const [tmpDevices, setTmpDevices] = useState([]);
15✔
78
  const deviceConnectionRef = useRef();
15✔
79
  const { status: statusParam } = useParams();
15✔
80

2✔
81
  const { groupCount, selectedGroup, groupFilters = [] } = useSelector(getSelectedGroupInfo);
15✔
82
  const filteringAttributes = useSelector(getSortedFilteringAttributes);
15✔
83
  const { canManageDevices } = useSelector(getUserCapabilities);
15✔
84
  const tenantCapabilities = useSelector(getTenantCapabilities);
15✔
85
  const { groupNames, ...groupsByType } = useSelector(getGroupsSelector);
15✔
86
  const groups = groupNames;
15✔
87
  const { total: acceptedCount = 0 } = useSelector(getAcceptedDevices);
15✔
88
  const authRequestCount = useSelector(state => state.monitor.issueCounts.byType[DEVICE_ISSUE_OPTIONS.authRequests.key].total);
30✔
89
  const canPreview = useSelector(getIsPreview);
15✔
90
  const deviceLimit = useSelector(getDeviceLimit);
15✔
91
  const deviceListState = useSelector(state => state.devices.deviceList);
30✔
92
  const features = useSelector(getFeatures);
15✔
93
  const { hasReporting } = features;
15✔
94
  const filters = useSelector(getDeviceFilters);
15✔
95
  const limitMaxed = useSelector(getLimitMaxed);
15✔
96
  const { pending: pendingCount } = useSelector(getDeviceCountsByStatus);
15✔
97
  const showDeviceConnectionDialog = useSelector(state => state.users.showConnectDeviceDialog);
30✔
98
  const onboardingState = useSelector(getOnboardingState);
15✔
99
  const isEnterprise = useSelector(getIsEnterprise);
15✔
100
  const dispatch = useDispatch();
15✔
101
  const isInitialized = useRef(false);
15✔
102
  const location = useLocation();
15✔
103

2✔
104
  const [locationParams, setLocationParams] = useLocationParams('devices', {
15✔
105
    filteringAttributes,
2✔
106
    filters,
2✔
107
    defaults: { sort: { direction: SORTING_OPTIONS.desc } }
2✔
108
  });
2✔
109

2✔
110
  const { refreshTrigger, selectedId, state: selectedState } = deviceListState;
15✔
111

2✔
112
  useEffect(() => {
15✔
113
    if (!isInitialized.current) {
4!
114
      return;
4✔
115
    }
2✔
116
    setLocationParams({ pageState: deviceListState, filters, selectedGroup });
2✔
117
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
118
  }, [
2✔
119
    deviceListState.detailsTab,
2✔
120
    deviceListState.page,
2✔
121
    deviceListState.perPage,
2✔
122
    deviceListState.selectedIssues,
2✔
123
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
124
    JSON.stringify(deviceListState.sort),
2✔
125
    selectedId,
2✔
126
    filters,
2✔
127
    selectedGroup,
2✔
128
    selectedState,
2✔
129
    setLocationParams
2✔
130
  ]);
2✔
131

2✔
132
  useEffect(() => {
15✔
133
    // set isInitialized ref to false when location changes, otherwise when you go back setLocationParams will be set with a duplicate item
2✔
134
    isInitialized.current = false;
3✔
135
  }, [location]);
2✔
136

2✔
137
  useEffect(() => {
15✔
138
    const { groupName, filters = [], id = [], ...remainder } = locationParams;
3✔
139
    const { hasFullFiltering } = tenantCapabilities;
3✔
140
    if (groupName) {
3!
141
      if (groupName != selectedGroup) {
2!
142
        dispatch(selectGroup({ group: groupName, filters }));
2✔
143
      }
2✔
144
    } else if (filters.length) {
3!
145
      // dispatch setDeviceFilters even when filters are empty, otherwise filter will not be reset
2✔
146
      dispatch(setDeviceFilters(filters));
2✔
147
    }
2✔
148
    // preset selectedIssues and selectedId with empty values, in case if remain properties are missing them
2✔
149
    const listState = { ...remainder };
3✔
150
    if (statusParam && (Object.values(DEVICE_STATES).some(state => state === statusParam) || statusParam === 'any')) {
3!
151
      listState.state = statusParam;
2✔
152
    }
2✔
153

2✔
154
    if (id.length === 1 && Boolean(locationParams.open)) {
3!
155
      listState.selectedId = id[0];
2✔
156
    } else if (id.length && hasFullFiltering) {
3!
157
      dispatch(setDeviceFilters([...filters, { ...emptyFilter, key: 'id', operator: DEVICE_FILTERING_OPTIONS.$in.key, value: id }]));
2✔
158
    }
2✔
159
    dispatch(setDeviceListState(listState)).then(() => {
3✔
160
      if (isInitialized.current) {
3!
161
        return;
2✔
162
      }
2✔
163
      isInitialized.current = true;
3✔
164
      dispatch(setDeviceListState({ shouldSelectDevices: true, forceRefresh: true }));
3✔
165
      dispatch(setOfflineThreshold());
3✔
166
    });
2✔
167
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
168
  }, [dispatch, JSON.stringify(tenantCapabilities), JSON.stringify(locationParams), statusParam]);
2✔
169

2✔
170
  /*
2✔
171
   * Groups
2✔
172
   */
2✔
173
  const removeCurrentGroup = () => {
15✔
174
    const request = groupFilters.length ? dispatch(removeDynamicGroup(selectedGroup)) : dispatch(removeStaticGroup(selectedGroup));
2!
175
    return request.then(toggleGroupRemoval).catch(console.log);
2✔
176
  };
2✔
177

2✔
178
  // Edit groups from device selection
2✔
179
  const addDevicesToGroup = tmpDevices => {
15✔
180
    // (save selected devices in state, open dialog)
2✔
181
    setTmpDevices(tmpDevices);
2✔
182
    setModifyGroupDialog(toggle);
2✔
183
  };
2✔
184

2✔
185
  const createGroupFromDialog = (devices, group) => {
15✔
186
    const request = fromFilters ? dispatch(addDynamicGroup({ groupName: group, filterPredicates: filters })) : dispatch(addStaticGroup({ group, devices }));
2!
187
    return request.then(() => {
2✔
188
      // reached end of list
2✔
189
      setCreateGroupExplanation(false);
2✔
190
      setModifyGroupDialog(false);
2✔
191
      setFromFilters(false);
2✔
192
    });
2✔
193
  };
2✔
194

2✔
195
  const onGroupClick = () => {
15✔
196
    if (selectedGroup && groupFilters.length) {
2!
197
      return dispatch(updateDynamicGroup({ groupName: selectedGroup, filterPredicates: filters }));
2✔
198
    }
2✔
199
    setModifyGroupDialog(true);
2✔
200
    setFromFilters(true);
2✔
201
  };
2✔
202

2✔
203
  const onRemoveDevicesFromGroup = devices => {
15✔
204
    const isGroupRemoval = devices.length >= groupCount;
2✔
205
    let request;
2✔
206
    if (isGroupRemoval) {
2!
207
      request = dispatch(removeStaticGroup(selectedGroup));
2✔
208
    } else {
2✔
209
      request = dispatch(removeDevicesFromGroup({ group: selectedGroup, deviceIds: devices }));
2✔
210
    }
2✔
211
    return request.catch(console.log);
2✔
212
  };
2✔
213

2✔
214
  const openSettingsDialog = e => {
15✔
215
    e.preventDefault();
2✔
216
    setOpenIdDialog(toggle);
2✔
217
  };
2✔
218

2✔
219
  const onCreateGroupClose = () => {
15✔
220
    setModifyGroupDialog(false);
2✔
221
    setFromFilters(false);
2✔
222
    setTmpDevices([]);
2✔
223
  };
2✔
224

2✔
225
  const onPreauthSaved = addMore => {
15✔
226
    setOpenPreauth(!addMore);
2✔
227
    dispatch(setDeviceListState({ page: 1, refreshTrigger: !refreshTrigger }));
2✔
228
  };
2✔
229

2✔
230
  const onShowDeviceStateClick = state => {
15✔
231
    dispatch(selectGroup());
2✔
232
    dispatch(setDeviceListState({ state }));
2✔
233
  };
2✔
234

2✔
235
  const onGroupSelect = groupName => {
15✔
236
    dispatch(selectGroup({ group: groupName }));
2✔
237
    dispatch(setDeviceListState({ page: 1, refreshTrigger: !refreshTrigger, selection: [] }));
2✔
238
  };
2✔
239

2✔
240
  const onShowAuthRequestDevicesClick = () => {
15✔
241
    dispatch(setDeviceFilters([]));
2✔
242
    dispatch(setDeviceListState({ selectedIssues: [DEVICE_ISSUE_OPTIONS.authRequests.key], page: 1 }));
2✔
243
  };
2✔
244

2✔
245
  const toggleGroupRemoval = () => setRemoveGroup(toggle);
15✔
246

2✔
247
  const toggleMakeGatewayClick = () => setShowMakeGateway(toggle);
15✔
248

2✔
249
  const changeLocation = useCallback(
15✔
250
    (newLocation: string) => {
2✔
251
      isInitialized.current = false;
2✔
252
      setLocationParams({ pageState: { ...deviceListState, state: newLocation }, filters, selectedGroup });
2✔
253
    },
2✔
254
    [setLocationParams, deviceListState, filters, selectedGroup]
2✔
255
  );
2✔
256

2✔
257
  let onboardingComponent;
2✔
258
  if (deviceConnectionRef.current && !(pendingCount || acceptedCount)) {
15!
259
    const anchor = { top: deviceConnectionRef.current.offsetTop + deviceConnectionRef.current.offsetHeight / 2, left: deviceConnectionRef.current.offsetLeft };
2✔
260
    onboardingComponent = getOnboardingComponentFor(
2✔
261
      onboardingSteps.DEVICES_DELAYED_ONBOARDING,
2✔
262
      onboardingState,
2✔
263
      { anchor, place: 'left' },
2✔
264
      onboardingComponent
2✔
265
    );
2✔
266
  }
2✔
267
  return (
15✔
268
    <>
2✔
269
      <div className="tab-container with-sub-panels" style={{ paddingTop: 0, paddingBottom: 45, minHeight: 'max-content', alignContent: 'center' }}>
2✔
270
        <h3 className="flexbox center-aligned" style={{ marginBottom: 0, marginTop: 0, flexWrap: 'wrap' }}>
2✔
271
          Devices
2✔
272
        </h3>
2✔
273
        <span className="flexbox space-between margin-left-large margin-right center-aligned padding-top-small">
2✔
274
          {hasReporting && !!authRequestCount && (
2!
275
            <a className="flexbox center-aligned margin-right-large" onClick={onShowAuthRequestDevicesClick}>
2✔
276
              <AddIcon fontSize="small" style={{ marginRight: 6 }} />
2✔
277
              {authRequestCount} new device authentication {pluralize('request', authRequestCount)}
2✔
278
            </a>
2✔
279
          )}
2✔
280
          {!!pendingCount && !selectedGroup && selectedState !== DEVICE_STATES.pending ? (
2!
281
            <DeviceStatusNotification deviceCount={pendingCount} state={DEVICE_STATES.pending} onClick={onShowDeviceStateClick} />
2✔
282
          ) : (
2✔
283
            <div />
2✔
284
          )}
2✔
285
          {canManageDevices && (
2✔
286
            <DeviceAdditionWidget
2✔
287
              features={features}
2✔
UNCOV
288
              onConnectClick={() => dispatch(setShowConnectingDialog(true))}
2✔
289
              onMakeGatewayClick={toggleMakeGatewayClick}
2✔
290
              onPreauthClick={setOpenPreauth}
2✔
291
              tenantCapabilities={tenantCapabilities}
2✔
292
              innerRef={deviceConnectionRef}
2✔
293
            />
2✔
294
          )}
2✔
295
          {onboardingComponent}
2✔
296
        </span>
2✔
297
      </div>
2✔
298
      <div className="tab-container with-sub-panels" style={{ padding: 0, height: '100%' }}>
2✔
299
        <Groups
2✔
300
          className="leftFixed"
2✔
301
          acceptedCount={acceptedCount}
2✔
302
          changeGroup={onGroupSelect}
2✔
303
          groups={groupsByType}
2✔
304
          openGroupDialog={setCreateGroupExplanation}
2✔
305
          selectedGroup={selectedGroup}
2✔
306
        />
2✔
307
        <div className="rightFluid relative" style={{ paddingTop: 0 }}>
2✔
308
          {limitMaxed && <DeviceLimitWarning acceptedDevices={acceptedCount} deviceLimit={deviceLimit} />}
2!
309
          <AuthorizedDevices
2✔
310
            changeLocation={changeLocation}
2✔
311
            addDevicesToGroup={addDevicesToGroup}
2✔
312
            onGroupClick={onGroupClick}
2✔
313
            onGroupRemoval={toggleGroupRemoval}
2✔
314
            onMakeGatewayClick={toggleMakeGatewayClick}
2✔
315
            onPreauthClick={setOpenPreauth}
2✔
316
            openSettingsDialog={openSettingsDialog}
2✔
317
            removeDevicesFromGroup={onRemoveDevicesFromGroup}
2✔
318
            showsDialog={showDeviceConnectionDialog || removeGroup || modifyGroupDialog || createGroupExplanation || openIdDialog || openPreauth}
2✔
319
          />
2✔
320
        </div>
2✔
321
        {removeGroup && <RemoveGroup onClose={toggleGroupRemoval} onRemove={removeCurrentGroup} />}
2!
322
        {modifyGroupDialog && (
2!
323
          <CreateGroup
2✔
324
            addListOfDevices={createGroupFromDialog}
2✔
325
            fromFilters={fromFilters}
2✔
326
            isCreation={fromFilters || !groups.length}
2!
327
            selectedDevices={tmpDevices}
2✔
328
            onClose={onCreateGroupClose}
2✔
329
          />
2✔
330
        )}
2✔
UNCOV
331
        {createGroupExplanation && <CreateGroupExplainer isEnterprise={isEnterprise} onClose={() => setCreateGroupExplanation(false)} />}
2!
332
        {openIdDialog && (
2!
333
          <BaseDialog open title="Default device identity attribute" onClose={openSettingsDialog}>
2✔
334
            <DialogContent style={{ overflow: 'hidden' }}>
2✔
335
              <Global dialog closeDialog={openSettingsDialog} />
2✔
336
            </DialogContent>
2✔
337
          </BaseDialog>
2✔
338
        )}
2✔
339
        {openPreauth && (
2!
340
          <PreauthDialog
2✔
341
            acceptedDevices={acceptedCount}
2✔
342
            deviceLimit={deviceLimit}
2✔
343
            limitMaxed={limitMaxed}
2✔
344
            onSubmit={onPreauthSaved}
2✔
345
            onCancel={() => setOpenPreauth(false)}
2✔
346
          />
2✔
347
        )}
2✔
348
        {showMakeGateway && <MakeGatewayDialog isPreRelease={canPreview} onCancel={toggleMakeGatewayClick} />}
2!
349
      </div>
2✔
350
    </>
2✔
351
  );
2✔
352
};
2✔
353

2✔
354
export default DeviceGroups;
2✔
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