• 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

99.26
/frontend/src/js/components/devices/widgets/DeviceQuickActions.tsx
1
// Copyright 2021 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 { useEffect, useRef, useState } from 'react';
2✔
15
import { useSelector } from 'react-redux';
2✔
16

2✔
17
import {
2✔
18
  AddCircle as AddCircleIcon,
2✔
19
  CheckCircle as CheckCircleIcon,
2✔
20
  HeightOutlined as HeightOutlinedIcon,
2✔
21
  HighlightOffOutlined as HighlightOffOutlinedIcon,
2✔
22
  RemoveCircleOutline as RemoveCircleOutlineIcon,
2✔
23
  Replay as ReplayIcon
2✔
24
} from '@mui/icons-material';
2✔
25
import { ClickAwayListener, SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material';
2✔
26
import { speedDialActionClasses } from '@mui/material/SpeedDialAction';
2✔
27
import { makeStyles } from 'tss-react/mui';
2✔
28

2✔
29
import { mdiTrashCanOutline as TrashCan } from '@mdi/js';
2✔
30
import MaterialDesignIcon from '@northern.tech/common-ui/MaterialDesignIcon';
2✔
31
import { DEVICE_STATES, TIMEOUTS, UNGROUPED_GROUP, onboardingSteps } from '@northern.tech/store/constants';
2✔
32
import { advanceOnboarding } from '@northern.tech/store/onboardingSlice/thunks';
2✔
33
import {
2✔
34
  getDeviceById,
2✔
35
  getFeatures,
2✔
36
  getMappedDevicesList,
2✔
37
  getOnboardingState,
2✔
38
  getTenantCapabilities,
2✔
39
  getUserCapabilities
2✔
40
} from '@northern.tech/store/selectors';
2✔
41
import { useAppDispatch } from '@northern.tech/store/store';
2✔
42
import { stringToBoolean, toggle } from '@northern.tech/utils/helpers';
2✔
43
import pluralize from 'pluralize';
2✔
44

2✔
45
import GatewayIcon from '../../../../assets/img/gateway.svg';
2✔
46
import { getOnboardingComponentFor } from '../../../utils/onboardingManager';
2✔
47

2✔
48
const defaultActions = {
11✔
49
  accept: {
2✔
50
    icon: <CheckCircleIcon className="green" />,
2✔
51
    key: 'accept',
2✔
52
    title: pluralized => `Accept ${pluralized}`,
3✔
UNCOV
53
    action: ({ onAuthorizationChange, selection }) => onAuthorizationChange(selection, DEVICE_STATES.accepted),
2✔
54
    checkRelevance: ({ device, userCapabilities: { canWriteDevices } }) =>
2✔
55
      canWriteDevices && [DEVICE_STATES.pending, DEVICE_STATES.rejected].includes(device.status)
23✔
56
  },
2✔
57
  dismiss: {
2✔
58
    icon: <RemoveCircleOutlineIcon className="red" />,
2✔
59
    key: 'dismiss',
2✔
60
    title: pluralized => `Dismiss ${pluralized}`,
10✔
UNCOV
61
    action: ({ onDeviceDismiss, selection }) => onDeviceDismiss(selection),
2✔
62
    checkRelevance: ({ device, userCapabilities: { canWriteDevices } }) =>
2✔
63
      canWriteDevices && [DEVICE_STATES.accepted, DEVICE_STATES.pending, DEVICE_STATES.preauth, DEVICE_STATES.rejected, 'noauth'].includes(device.status)
29✔
64
  },
2✔
65
  reject: {
2✔
66
    icon: <HighlightOffOutlinedIcon className="red" />,
2✔
67
    key: 'reject',
2✔
68
    title: pluralized => `Reject ${pluralized}`,
10✔
UNCOV
69
    action: ({ onAuthorizationChange, selection }) => onAuthorizationChange(selection, DEVICE_STATES.rejected),
2✔
70
    checkRelevance: ({ device, userCapabilities: { canWriteDevices } }) =>
2✔
71
      canWriteDevices && [DEVICE_STATES.accepted, DEVICE_STATES.pending].includes(device.status)
29✔
72
  },
2✔
73
  addToGroup: {
2✔
74
    icon: <AddCircleIcon className="green" />,
2✔
75
    key: 'group-add',
2✔
76
    title: pluralized => `Add selected ${pluralized} to a group`,
9✔
UNCOV
77
    action: ({ onAddDevicesToGroup, selection }) => onAddDevicesToGroup(selection),
2✔
78
    checkRelevance: ({ selectedGroup, userCapabilities: { canWriteDevices } }) => canWriteDevices && !selectedGroup
29✔
79
  },
2✔
80
  moveToGroup: {
2✔
81
    icon: <HeightOutlinedIcon className="rotated ninety" />,
2✔
82
    key: 'group-change',
2✔
83
    title: pluralized => `Move selected ${pluralized} to another group`,
3✔
UNCOV
84
    action: ({ onAddDevicesToGroup, selection }) => onAddDevicesToGroup(selection),
2✔
85
    checkRelevance: ({ selectedGroup, userCapabilities: { canWriteDevices } }) => canWriteDevices && !!selectedGroup
23✔
86
  },
2✔
87
  removeFromGroup: {
2✔
88
    icon: <MaterialDesignIcon path={TrashCan} />,
2✔
89
    key: 'group-remove',
2✔
90
    title: pluralized => `Remove selected ${pluralized} from this group`,
3✔
UNCOV
91
    action: ({ onRemoveDevicesFromGroup, selection }) => onRemoveDevicesFromGroup(selection),
2✔
92
    checkRelevance: ({ selectedGroup, userCapabilities: { canWriteDevices } }) => canWriteDevices && selectedGroup && selectedGroup !== UNGROUPED_GROUP.id
23✔
93
  },
2✔
94
  promoteToGateway: {
2✔
95
    icon: <GatewayIcon style={{ width: 20 }} />,
2✔
96
    key: 'promote-to-gateway',
2✔
97
    title: () => 'Promote to gateway',
8✔
UNCOV
98
    action: ({ onPromoteGateway, selection }) => onPromoteGateway(selection),
2✔
99
    checkRelevance: ({ device, features, tenantCapabilities: { isEnterprise } }) =>
2✔
100
      features.isHosted && isEnterprise && !stringToBoolean(device.attributes?.mender_is_gateway) && device.status === DEVICE_STATES.accepted
29✔
101
  },
2✔
102
  createDeployment: {
2✔
103
    icon: <ReplayIcon />,
2✔
104
    key: 'create-deployment',
2✔
105
    title: (pluralized, count) => `Create deployment for ${pluralize('this', count)} ${pluralized}`,
9✔
UNCOV
106
    action: ({ onCreateDeployment, selection }) => onCreateDeployment(selection),
2✔
107
    checkRelevance: ({ device, userCapabilities: { canDeploy, canReadReleases } }) =>
2✔
108
      canDeploy && canReadReleases && device && device.status === DEVICE_STATES.accepted && device.attributes?.device_type?.length
29✔
109
  }
2✔
110
};
2✔
111

2✔
112
const useStyles = makeStyles()(theme => ({
11✔
113
  container: {
2✔
114
    position: 'fixed',
2✔
115
    bottom: theme.spacing(6.5),
2✔
116
    right: theme.spacing(6.5),
2✔
117
    zIndex: 10,
2✔
118
    minWidth: 400,
2✔
119
    pointerEvents: 'none',
2✔
120
    [`.${speedDialActionClasses.staticTooltipLabel}`]: {
2✔
121
      minWidth: 'max-content'
2✔
122
    }
2✔
123
  },
2✔
124
  fab: { margin: `${theme.spacing(2)} ${theme.spacing(2)} ${theme.spacing(2)} ${theme.spacing(0.5)}` },
2✔
125
  innerContainer: {
2✔
126
    display: 'flex',
2✔
127
    alignItems: 'flex-end',
2✔
128
    justifyContent: 'flex-end'
2✔
129
  },
2✔
130
  label: {
2✔
131
    background: theme.palette.background.default,
2✔
132
    opacity: 0.97,
2✔
133
    borderRadius: theme.spacing(0.5),
2✔
134
    padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
2✔
135
    marginBottom: theme.spacing(3),
2✔
136
    cursor: 'pointer',
2✔
137
    pointerEvents: 'auto'
2✔
138
  }
2✔
139
}));
2✔
140

2✔
141
export const DeviceQuickActions = ({ actionCallbacks, deviceId, selectedGroup }) => {
11✔
142
  const dispatch = useAppDispatch();
23✔
143
  const [showActions, setShowActions] = useState(false);
23✔
144
  const features = useSelector(getFeatures);
23✔
145
  const tenantCapabilities = useSelector(getTenantCapabilities);
23✔
146
  const userCapabilities = useSelector(getUserCapabilities);
23✔
147
  const { selection: selectedRows } = useSelector(state => state.devices.deviceList);
43✔
148
  const singleDevice = useSelector(state => getDeviceById(state, deviceId));
43✔
149
  const devices = useSelector(state => getMappedDevicesList(state, 'deviceList'));
43✔
150
  const { classes } = useStyles();
23✔
151
  const deviceActionRef = useRef<HTMLDivElement>();
23✔
152
  const deploymentActionRef = useRef<HTMLDivElement>(null);
23✔
153
  const onboardingState = useSelector(getOnboardingState);
23✔
154
  const [isInitialized, setIsInitialized] = useState(false);
23✔
155
  const timer = useRef();
23✔
156

2✔
157
  const handleShowActions = e => {
23✔
158
    e.stopPropagation();
2✔
159
    setShowActions(!showActions);
2✔
160
    dispatch(advanceOnboarding(onboardingSteps.DEVICES_DEPLOY_RELEASE_ONBOARDING));
2✔
161
  };
2✔
162

2✔
163
  const handleClickAway = () => {
23✔
164
    setShowActions(false);
8✔
165
  };
2✔
166

2✔
167
  useEffect(() => {
23✔
168
    clearTimeout(timer.current);
5✔
169
    timer.current = setTimeout(() => setIsInitialized(toggle), TIMEOUTS.debounceDefault);
5✔
170
    return () => {
5✔
171
      clearTimeout(timer.current);
5✔
172
    };
2✔
173
  }, []);
2✔
174

2✔
175
  const selectedDevices = deviceId ? [singleDevice] : selectedRows.map(row => devices[row]);
41✔
176
  const actions = Object.values(defaultActions).reduce((accu, action) => {
23✔
177
    if (selectedDevices.every(device => device && action.checkRelevance({ device, features, selectedGroup, tenantCapabilities, userCapabilities }))) {
265✔
178
      accu.push(action);
41✔
179
    }
2✔
180
    return accu;
170✔
181
  }, []);
2✔
182

2✔
183
  const pluralized = pluralize('devices', selectedDevices.length);
23✔
184

2✔
185
  let onboardingComponent;
2✔
186
  let anchor;
2✔
187
  if (deploymentActionRef.current && isInitialized && showActions) {
23!
188
    anchor = {
2✔
189
      left: deploymentActionRef.current.parentElement.parentElement.offsetLeft - deploymentActionRef.current.offsetWidth - 45,
2✔
190
      top: deploymentActionRef.current.parentElement.parentElement.offsetTop + deploymentActionRef.current.parentElement.offsetHeight
2✔
191
    };
2✔
192
    onboardingComponent = getOnboardingComponentFor(onboardingSteps.DEVICES_DEPLOY_RELEASE_ONBOARDING_STEP_2, onboardingState, { anchor, place: 'left' }, null);
2✔
193
  } else if (deviceActionRef.current && isInitialized) {
23✔
194
    const deviceActionDiv = deviceActionRef.current;
10✔
195
    anchor = {
10✔
196
      left: deviceActionDiv.firstElementChild.offsetLeft - 15,
2✔
197
      top: deviceActionDiv.offsetTop + deviceActionDiv.firstElementChild.offsetTop + deviceActionDiv.firstElementChild.offsetHeight / 2
2✔
198
    };
2✔
199
    onboardingComponent = getOnboardingComponentFor(onboardingSteps.DEVICES_DEPLOY_RELEASE_ONBOARDING, onboardingState, { anchor, place: 'left' }, null);
10✔
200
  }
2✔
201
  return (
23✔
202
    <div className={classes.container}>
2✔
203
      <div className="relative">
2✔
204
        <div className={classes.innerContainer} ref={deviceActionRef}>
2✔
205
          <div className={classes.label} onClick={handleShowActions}>
2✔
206
            {deviceId ? 'Device actions' : `${selectedDevices.length} ${pluralized} selected`}
2✔
207
          </div>
2✔
208
          <ClickAwayListener onClickAway={handleClickAway}>
2✔
209
            <SpeedDial className={classes.fab} ariaLabel="device-actions" icon={<SpeedDialIcon />} onClick={handleShowActions} open={Boolean(showActions)}>
2✔
210
              {actions.map(action => (
2✔
211
                <SpeedDialAction
41✔
212
                  key={action.key}
2✔
213
                  aria-label={action.key}
2✔
214
                  icon={action.icon}
2✔
215
                  tooltipTitle={
2✔
216
                    <div ref={action.key === 'create-deployment' ? deploymentActionRef : undefined}>{action.title(pluralized, selectedDevices.length)}</div>
2✔
217
                  }
2✔
218
                  tooltipOpen
2✔
UNCOV
219
                  onClick={() => action.action({ ...actionCallbacks, selection: selectedDevices })}
2✔
220
                />
2✔
221
              ))}
2✔
222
            </SpeedDial>
2✔
223
          </ClickAwayListener>
2✔
224
        </div>
2✔
225
        {onboardingComponent}
2✔
226
      </div>
2✔
227
    </div>
2✔
228
  );
2✔
229
};
2✔
230

2✔
231
export default DeviceQuickActions;
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