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

mendersoftware / gui / 947088195

pending completion
947088195

Pull #2661

gitlab-ci

mzedel
chore: improved device filter scrolling behaviour

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #2661: chore: added lint rules for hooks usage

4411 of 6415 branches covered (68.76%)

297 of 440 new or added lines in 62 files covered. (67.5%)

1617 existing lines in 163 files now uncovered.

8311 of 10087 relevant lines covered (82.39%)

192.12 hits per line

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

84.62
/src/js/components/deployments/createdeployment.js
1
// Copyright 2019 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, { useEffect, useRef, useState } from 'react';
15
import { useDispatch, useSelector } from 'react-redux';
16
import { useNavigate } from 'react-router-dom';
17

18
import { Close as CloseIcon, ExpandMore } from '@mui/icons-material';
19
import {
20
  Accordion,
21
  AccordionDetails,
22
  AccordionSummary,
23
  Button,
24
  Checkbox,
25
  Divider,
26
  Drawer,
27
  FormControlLabel,
28
  FormGroup,
29
  IconButton,
30
  Typography,
31
  accordionClasses
32
} from '@mui/material';
33
import { makeStyles } from 'tss-react/mui';
34

35
import moment from 'moment';
36
import pluralize from 'pluralize';
37

38
import DeltaIcon from '../../../assets/img/deltaicon.svg';
39
import { createDeployment, getDeploymentsConfig } from '../../actions/deploymentActions';
40
import { getGroupDevices } from '../../actions/deviceActions';
41
import { advanceOnboarding } from '../../actions/onboardingActions';
42
import { getReleases } from '../../actions/releaseActions';
43
import { ALL_DEVICES } from '../../constants/deviceConstants';
44
import { onboardingSteps } from '../../constants/onboardingConstants';
45
import { toggle, validatePhases } from '../../helpers';
46
import {
47
  getAcceptedDevices,
48
  getDeviceCountsByStatus,
49
  getDevicesById,
50
  getDocsVersion,
51
  getGlobalSettings,
52
  getGroupNames,
53
  getGroupsByIdWithoutUngrouped,
54
  getIdAttribute,
55
  getIsEnterprise,
56
  getOnboardingState,
57
  getReleasesById,
58
  getTenantCapabilities
59
} from '../../selectors';
60
import { getOnboardingComponentFor } from '../../utils/onboardingmanager';
61
import Confirm from '../common/confirm';
62
import { RolloutPatternSelection } from './deployment-wizard/phasesettings';
63
import { ForceDeploy, Retries, RolloutOptions } from './deployment-wizard/rolloutoptions';
64
import { ScheduleRollout } from './deployment-wizard/schedulerollout';
65
import { Devices, ReleasesWarning, Software } from './deployment-wizard/softwaredevices';
66

67
const useStyles = makeStyles()(theme => ({
12✔
68
  accordion: {
69
    backgroundColor: theme.palette.grey[400],
70
    marginTop: theme.spacing(4),
71
    '&:before': {
72
      display: 'none'
73
    },
74
    [`&.${accordionClasses.expanded}`]: {
75
      margin: 'auto',
76
      marginTop: theme.spacing(4)
77
    }
78
  },
79
  columns: {
80
    alignItems: 'start',
81
    columnGap: 30,
82
    display: 'grid',
83
    gridTemplateColumns: 'max-content 1fr',
84
    '&>p': {
85
      marginTop: theme.spacing(3)
86
    }
87
  },
88
  disabled: { color: theme.palette.text.disabled }
89
}));
90

91
const getAnchor = (element, heightAdjustment = 3) => ({
603✔
92
  top: element.offsetTop + element.offsetHeight / heightAdjustment,
93
  left: element.offsetLeft + element.offsetWidth
94
});
95

96
export const getPhaseStartTime = (phases, index, startDate) => {
12✔
97
  if (index < 1) {
139✔
98
    return startDate?.toISOString ? startDate.toISOString() : startDate;
56✔
99
  }
100
  // since we don't want to get stale phase start times when the creation dialog is open for a long time
101
  // we have to ensure start times are based on delay from previous phases
102
  // since there likely won't be 1000s of phases this should still be fine to recalculate
103
  const newStartTime = phases.slice(0, index).reduce((accu, phase) => moment(accu).add(phase.delay, phase.delayUnit), startDate);
120✔
104
  return newStartTime.toISOString();
83✔
105
};
106

107
export const CreateDeployment = props => {
12✔
108
  const { deploymentObject = {}, onDismiss, onScheduleSubmit, open = true, setDeploymentSettings } = props;
372!
109

110
  const { canRetry, canSchedule, hasFullFiltering } = useSelector(getTenantCapabilities);
371✔
111
  const { createdGroup, groups, hasDynamicGroups } = useSelector(state => {
371✔
112
    const groups = getGroupsByIdWithoutUngrouped(state);
677✔
113
    const createdGroup = Object.keys(groups).length ? Object.keys(groups)[0] : undefined;
677!
114
    const hasDynamicGroups = Object.values(groups).some(group => !!group.id);
1,212✔
115
    return { createdGroup, hasDynamicGroups, groups };
677✔
116
  });
117
  const { hasDelta: hasDeltaEnabled } = useSelector(state => state.deployments.config) ?? {};
677!
118
  const { total: acceptedDeviceCount } = useSelector(getAcceptedDevices);
371✔
119
  const hasDevices = !!acceptedDeviceCount;
371✔
120
  const devicesById = useSelector(getDevicesById);
371✔
121
  const docsVersion = useSelector(getDocsVersion);
371✔
122
  const { pending: hasPending } = useSelector(getDeviceCountsByStatus);
371✔
123
  const { attribute: idAttribute } = useSelector(getIdAttribute);
371✔
124
  const isEnterprise = useSelector(getIsEnterprise);
371✔
125
  const { needsDeploymentConfirmation: needsCheck, previousPhases = [], retries: previousRetries = 0 } = useSelector(getGlobalSettings);
371✔
126
  const onboardingState = useSelector(getOnboardingState) || {};
371!
127
  const { complete: isOnboardingComplete } = onboardingState;
371✔
128
  const releases = useSelector(state => state.releases.releasesList.searchedIds);
677✔
129
  const releasesById = useSelector(getReleasesById);
371✔
130
  const groupNames = useSelector(getGroupNames);
371✔
131
  const dispatch = useDispatch();
371✔
132

133
  const isCreating = useRef(false);
371✔
134
  const [releaseSelectionLocked, setReleaseSelectionLocked] = useState(Boolean(deploymentObject.release));
371✔
135
  const [hasNewRetryDefault, setHasNewRetryDefault] = useState(false);
371✔
136
  const [isChecking, setIsChecking] = useState(false);
371✔
137
  const [isExpanded, setIsExpanded] = useState(false);
371✔
138
  const navigate = useNavigate();
371✔
139
  const releaseRef = useRef();
371✔
140
  const groupRef = useRef();
371✔
141
  const deploymentAnchor = useRef();
371✔
142
  const { classes } = useStyles();
371✔
143

144
  useEffect(() => {
371✔
145
    dispatch(getReleases({ page: 1, perPage: 100, searchOnly: true }));
6✔
146
    dispatch(getDeploymentsConfig());
6✔
147
  }, [dispatch]);
148

149
  useEffect(() => {
371✔
150
    const { devices = [], group, release } = deploymentObject;
19✔
151
    if (release) {
19✔
152
      setReleaseSelectionLocked(Boolean(deploymentObject.release));
3✔
153
      dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_ARTIFACT_SELECTION));
3✔
154
    }
155
    dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_GROUP_SELECTION));
19✔
156
    let nextDeploymentObject = { deploymentDeviceCount: devices.length ? devices.length : 0 };
19!
157
    if (group === ALL_DEVICES) {
19✔
158
      dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_ALL_DEVICES_SELECTION));
3✔
159
      nextDeploymentObject.deploymentDeviceCount = acceptedDeviceCount;
3✔
160
    }
161
    if (groups[group]) {
19!
NEW
162
      return dispatch(getGroupDevices(group, { perPage: 1 })).then(({ group: { total: deploymentDeviceCount } }) =>
×
NEW
163
        setDeploymentSettings({ deploymentDeviceCount })
×
164
      );
165
    }
166
    setDeploymentSettings(nextDeploymentObject);
19✔
167
    // eslint-disable-next-line react-hooks/exhaustive-deps
168
  }, [acceptedDeviceCount, deploymentObject.group, deploymentObject.release?.Name, dispatch, JSON.stringify(groups), setDeploymentSettings]);
169

170
  useEffect(() => {
371✔
171
    let { deploymentDeviceCount: deviceCount, deploymentDeviceIds: deviceIds = [], devices = [] } = deploymentObject;
52✔
172
    if (devices.length) {
52!
UNCOV
173
      deviceIds = devices.map(({ id }) => id);
×
UNCOV
174
      deviceCount = deviceIds.length;
×
UNCOV
175
      devices = devices.map(({ id }) => ({ id, ...(devicesById[id] ?? {}) }));
×
176
    } else if (deploymentObject.group === ALL_DEVICES) {
52✔
177
      deviceCount = acceptedDeviceCount;
17✔
178
    }
179
    setDeploymentSettings({ deploymentDeviceIds: deviceIds, deploymentDeviceCount: deviceCount, devices });
52✔
180
    // eslint-disable-next-line react-hooks/exhaustive-deps
181
  }, [acceptedDeviceCount, JSON.stringify(deploymentObject), JSON.stringify(devicesById), setDeploymentSettings]);
182

183
  const cleanUpDeploymentsStatus = () => {
371✔
184
    if (!window.location.search) {
3!
185
      return;
3✔
186
    }
UNCOV
187
    const location = window.location.pathname.slice('/ui'.length);
×
UNCOV
188
    navigate(location); // lgtm [js/client-side-unvalidated-url-redirection]
×
189
  };
190

191
  const onSaveRetriesSetting = hasNewRetryDefault => setHasNewRetryDefault(hasNewRetryDefault);
371✔
192

193
  const closeWizard = () => {
371✔
194
    cleanUpDeploymentsStatus();
1✔
195
    onDismiss();
1✔
196
  };
197

198
  const onDeltaToggle = ({ target: { checked } }) => setDeploymentSettings({ delta: checked });
371✔
199

200
  const onScheduleSubmitClick = settings => {
371✔
201
    if (needsCheck && !isChecking) {
2!
UNCOV
202
      return setIsChecking(true);
×
203
    }
204
    isCreating.current = true;
2✔
205
    const { delta, deploymentDeviceIds, devices, filterId, forceDeploy = false, group, phases, release, retries, update_control_map } = settings;
2!
206
    const startTime = phases?.length ? phases[0].start_ts : undefined;
2✔
207
    const retrySetting = canRetry && retries ? { retries } : {};
2✔
208
    const newDeployment = {
2✔
209
      artifact_name: release.Name,
210
      autogenerate_delta: delta,
211
      devices: (filterId || group) && !devices.length ? undefined : deploymentDeviceIds,
8!
212
      filter_id: filterId,
213
      all_devices: !filterId && group === ALL_DEVICES,
4✔
214
      group: group === ALL_DEVICES || devices.length ? undefined : group,
4!
215
      name: devices[0]?.id || (group ? decodeURIComponent(group) : ALL_DEVICES),
6!
216
      phases: phases
2✔
217
        ? phases.map((phase, i, origPhases) => {
218
            phase.start_ts = getPhaseStartTime(origPhases, i, startTime);
3✔
219
            return phase;
3✔
220
          })
221
        : phases,
222
      ...retrySetting,
223
      force_installation: forceDeploy,
224
      update_control_map
225
    };
226
    if (!isOnboardingComplete) {
2!
227
      dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_RELEASE_TO_DEVICES));
2✔
228
    }
229
    return dispatch(createDeployment(newDeployment, hasNewRetryDefault))
2✔
230
      .then(() => {
231
        // successfully retrieved new deployment
232
        cleanUpDeploymentsStatus();
2✔
233
        onScheduleSubmit();
2✔
234
      })
235
      .finally(() => {
236
        isCreating.current = false;
2✔
237
        setIsChecking(false);
2✔
238
      });
239
  };
240

241
  const { delta, deploymentDeviceCount, group, phases } = deploymentObject;
371✔
242

243
  const deploymentSettings = {
371✔
244
    ...deploymentObject,
245
    filterId: groups[group] ? groups[group].id : undefined
371!
246
  };
247
  const disabled =
248
    isCreating.current ||
371✔
249
    !(deploymentSettings.release && (deploymentSettings.deploymentDeviceCount || deploymentSettings.filterId || deploymentSettings.group)) ||
369✔
250
    !validatePhases(phases, deploymentSettings.deploymentDeviceCount, deploymentSettings.filterId);
251

252
  const sharedProps = {
371✔
253
    ...props,
254
    canRetry,
255
    canSchedule,
256
    docsVersion,
257
    groupNames,
258
    groupRef,
259
    groups,
260
    hasDevices,
261
    hasDynamicGroups,
262
    hasFullFiltering,
263
    hasPending,
264
    idAttribute,
265
    isEnterprise,
266
    previousPhases,
267
    previousRetries,
268
    releaseRef,
269
    releases,
270
    releasesById,
271
    commonClasses: classes,
272
    deploymentObject: deploymentSettings,
273
    hasNewRetryDefault,
274
    onSaveRetriesSetting,
275
    open: false,
276
    releaseSelectionLocked,
277
    setDeploymentSettings
278
  };
279
  const hasReleases = !!Object.keys(releasesById).length;
371✔
280
  return (
371✔
281
    <Drawer anchor="right" open={open} onClose={closeWizard} PaperProps={{ style: { minWidth: '50vw' } }}>
282
      <div className="flexbox space-between center-aligned">
283
        <h3>Create a deployment</h3>
284
        <IconButton onClick={closeWizard} aria-label="close" size="large">
285
          <CloseIcon />
286
        </IconButton>
287
      </div>
288
      <Divider className="margin-bottom" />
289
      <FormGroup>
290
        {!hasReleases ? (
371!
291
          <ReleasesWarning />
292
        ) : (
293
          <>
294
            <Devices {...sharedProps} groupRef={groupRef} />
295
            <Software {...sharedProps} releaseRef={releaseRef} />
296
          </>
297
        )}
298
        <ScheduleRollout {...sharedProps} />
299
        <Accordion className={classes.accordion} square expanded={isExpanded} onChange={() => setIsExpanded(toggle)}>
1✔
300
          <AccordionSummary expandIcon={<ExpandMore />}>
301
            <Typography className={classes.disabled} variant="subtitle2">
302
              {isExpanded ? 'Hide' : 'Show'} advanced options
371✔
303
            </Typography>
304
          </AccordionSummary>
305
          <AccordionDetails>
306
            <RolloutPatternSelection {...sharedProps} />
307
            <RolloutOptions {...sharedProps} />
308
            <Retries {...sharedProps} />
309
            <ForceDeploy {...sharedProps} />
310
            {hasDeltaEnabled && (
611✔
311
              <FormControlLabel
312
                control={<Checkbox color="primary" checked={delta} onChange={onDeltaToggle} size="small" />}
313
                label={
314
                  <>
315
                    Generate and deploy Delta Artifacts (where available) <DeltaIcon />
316
                  </>
317
                }
318
              />
319
            )}
320
          </AccordionDetails>
321
        </Accordion>
322
      </FormGroup>
323
      <div className="margin-top">
324
        {isChecking && (
371!
325
          <Confirm
326
            classes="confirmation-overlay"
UNCOV
327
            cancel={() => setIsChecking(false)}
×
UNCOV
328
            action={() => onScheduleSubmitClick(deploymentSettings)}
×
329
            message={`This will deploy ${deploymentSettings.release?.Name} to ${deploymentDeviceCount} ${pluralize(
330
              'device',
331
              deploymentDeviceCount
332
            )}. Are you sure?`}
333
            style={{ paddingLeft: 12, justifyContent: 'flex-start', maxHeight: 44 }}
334
          />
335
        )}
336
        <Button onClick={closeWizard} style={{ marginRight: 10 }}>
337
          Cancel
338
        </Button>
339
        <Button variant="contained" color="primary" ref={deploymentAnchor} disabled={disabled} onClick={() => onScheduleSubmitClick(deploymentSettings)}>
2✔
340
          Create deployment
341
        </Button>
342
      </div>
343
      <OnboardingComponent
344
        releaseRef={releaseRef}
345
        groupRef={groupRef}
346
        deploymentObject={deploymentObject}
347
        deploymentAnchor={deploymentAnchor}
348
        onboardingState={onboardingState}
349
        createdGroup={createdGroup}
350
        releasesById={releasesById}
351
        releases={releases}
352
        hasDevices={hasDevices}
353
      />
354
    </Drawer>
355
  );
356
};
357

358
export default CreateDeployment;
359

360
const OnboardingComponent = ({
12✔
361
  releaseRef,
362
  groupRef,
363
  deploymentAnchor,
364
  deploymentObject,
365
  onboardingState,
366
  createdGroup,
367
  releasesById,
368
  releases,
369
  hasDevices
370
}) => {
371
  const { deploymentDeviceCount, devices, group, release: deploymentRelease = null } = deploymentObject;
275✔
372

373
  let onboardingComponent = null;
275✔
374
  if (releaseRef.current && groupRef.current && deploymentAnchor.current) {
275✔
375
    const anchor = getAnchor(releaseRef.current);
271✔
376
    const groupAnchor = getAnchor(groupRef.current);
271✔
377
    onboardingComponent = getOnboardingComponentFor(onboardingSteps.SCHEDULING_ALL_DEVICES_SELECTION, onboardingState, { anchor: groupAnchor, place: 'right' });
271✔
378
    if (createdGroup) {
271!
379
      onboardingComponent = getOnboardingComponentFor(
271✔
380
        onboardingSteps.SCHEDULING_GROUP_SELECTION,
381
        { ...onboardingState, createdGroup },
382
        { anchor: groupAnchor, place: 'right' },
383
        onboardingComponent
384
      );
385
    }
386
    if (deploymentDeviceCount && !deploymentRelease) {
271✔
387
      onboardingComponent = getOnboardingComponentFor(
2✔
388
        onboardingSteps.SCHEDULING_ARTIFACT_SELECTION,
389
        { ...onboardingState, selectedRelease: releasesById[releases[0]] || {} },
2!
390
        { anchor, place: 'right' },
391
        onboardingComponent
392
      );
393
    }
394
    if (hasDevices && (deploymentDeviceCount || devices?.length) && deploymentRelease) {
271✔
395
      const buttonAnchor = getAnchor(deploymentAnchor.current, 2);
61✔
396
      onboardingComponent = getOnboardingComponentFor(
61✔
397
        onboardingSteps.SCHEDULING_RELEASE_TO_DEVICES,
398
        { ...onboardingState, selectedDevice: devices.length ? devices[0] : undefined, selectedGroup: group, selectedRelease: deploymentRelease },
61!
399
        { anchor: buttonAnchor, place: 'right' },
400
        onboardingComponent
401
      );
402
    }
403
  }
404
  return onboardingComponent;
275✔
405
};
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