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

mendersoftware / gui / 1091795320

01 Dec 2023 04:32AM UTC coverage: 82.784% (-17.2%) from 99.964%
1091795320

Pull #4229

gitlab-ci

web-flow
chore: Bump node from 21.1.0-alpine to 21.2.0-alpine

Bumps node from 21.1.0-alpine to 21.2.0-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Pull Request #4229: chore: Bump node from 21.1.0-alpine to 21.2.0-alpine

4316 of 6292 branches covered (0.0%)

8333 of 10066 relevant lines covered (82.78%)

188.98 hits per line

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

77.88
/src/js/components/releases/releasedetails.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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
import { FormProvider, useForm } from 'react-hook-form';
16
import { useDispatch, useSelector } from 'react-redux';
17
import { useNavigate } from 'react-router-dom';
18

19
// material ui
20
import {
21
  Close as CloseIcon,
22
  HighlightOffOutlined as HighlightOffOutlinedIcon,
23
  Link as LinkIcon,
24
  Replay as ReplayIcon,
25
  Sort as SortIcon
26
} from '@mui/icons-material';
27
import { ClickAwayListener, Divider, Drawer, IconButton, SpeedDial, SpeedDialAction, SpeedDialIcon, TextField, Tooltip } from '@mui/material';
28
import { speedDialActionClasses } from '@mui/material/SpeedDialAction';
29
import { makeStyles } from 'tss-react/mui';
30

31
import copy from 'copy-to-clipboard';
32

33
import { setSnackbar } from '../../actions/appActions';
34
import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '../../actions/releaseActions';
35
import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants';
36
import { FileSize, customSort, formatTime, toggle } from '../../helpers';
37
import { getReleaseTags, getSelectedRelease, getUserCapabilities } from '../../selectors';
38
import useWindowSize from '../../utils/resizehook';
39
import ChipSelect from '../common/chipselect';
40
import { ConfirmationButtons, EditButton } from '../common/confirm';
41
import ExpandableAttribute from '../common/expandable-attribute';
42
import { RelativeTime } from '../common/time';
43
import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips';
44
import Artifact from './artifact';
45
import RemoveArtifactDialog from './dialogs/removeartifact';
46

47
const DeviceTypeCompatibility = ({ artifact }) => {
6✔
48
  const compatible = artifact.artifact_depends ? artifact.artifact_depends.device_type.join(', ') : artifact.device_types_compatible.join(', ');
27✔
49
  return (
27✔
50
    <Tooltip title={compatible} placement="top-start">
51
      <div className="text-overflow">{compatible}</div>
52
    </Tooltip>
53
  );
54
};
55

56
export const columns = [
6✔
57
  {
58
    title: 'Device type compatibility',
59
    name: 'device_types',
60
    sortable: false,
61
    render: DeviceTypeCompatibility,
62
    tooltip: <MenderHelpTooltip id={HELPTOOLTIPS.expandArtifact.id} className="margin-left-small" />
63
  },
64
  {
65
    title: 'Type',
66
    name: 'type',
67
    sortable: false,
68
    render: ({ artifact }) => <div style={{ maxWidth: '100vw' }}>{artifact.updates.reduce((accu, item) => (accu ? accu : item.type_info.type), '')}</div>
27!
69
  },
70
  { title: 'Size', name: 'size', sortable: true, render: ({ artifact }) => <FileSize fileSize={artifact.size} /> },
27✔
71
  { title: 'Last modified', name: 'modified', sortable: true, render: ({ artifact }) => <RelativeTime updateTime={formatTime(artifact.modified)} /> }
27✔
72
];
73

74
const defaultActions = [
6✔
75
  {
76
    action: ({ onCreateDeployment, selection }) => onCreateDeployment(selection),
×
77
    icon: <ReplayIcon />,
78
    isApplicable: ({ userCapabilities: { canDeploy } }) => canDeploy,
2✔
79
    key: 'deploy',
80
    title: 'Create a deployment for this release'
81
  },
82
  {
83
    action: ({ onDeleteRelease, selection }) => onDeleteRelease(selection),
×
84
    icon: <HighlightOffOutlinedIcon className="red" />,
85
    isApplicable: ({ userCapabilities: { canManageReleases } }) => canManageReleases,
2✔
86
    key: 'delete',
87
    title: 'Delete release'
88
  }
89
];
90

91
const useStyles = makeStyles()(theme => ({
10✔
92
  container: {
93
    display: 'flex',
94
    position: 'fixed',
95
    bottom: theme.spacing(6.5),
96
    right: theme.spacing(6.5),
97
    zIndex: 10,
98
    minWidth: 'max-content',
99
    alignItems: 'flex-end',
100
    justifyContent: 'flex-end',
101
    pointerEvents: 'none',
102
    [`.${speedDialActionClasses.staticTooltipLabel}`]: {
103
      minWidth: 'max-content'
104
    }
105
  },
106
  fab: { margin: theme.spacing(2) },
107
  tagSelect: { marginRight: theme.spacing(2), maxWidth: 350 },
108
  label: {
109
    marginRight: theme.spacing(2),
110
    marginBottom: theme.spacing(4)
111
  },
112
  notes: { display: 'block', whiteSpace: 'pre-wrap' },
113
  notesWrapper: { minWidth: theme.components?.MuiFormControl?.styleOverrides?.root?.minWidth }
114
}));
115

116
export const ReleaseQuickActions = ({ actionCallbacks, innerRef, selectedRelease, userCapabilities }) => {
6✔
117
  const [showActions, setShowActions] = useState(false);
29✔
118
  const { classes } = useStyles();
29✔
119

120
  const actions = useMemo(() => {
29✔
121
    return Object.values(defaultActions).reduce((accu, action) => {
2✔
122
      if (action.isApplicable({ userCapabilities })) {
4!
123
        accu.push(action);
4✔
124
      }
125
      return accu;
4✔
126
    }, []);
127
    // eslint-disable-next-line react-hooks/exhaustive-deps
128
  }, [JSON.stringify(userCapabilities)]);
129

130
  const handleShowActions = () => {
29✔
131
    setShowActions(!showActions);
×
132
  };
133

134
  const handleClickAway = () => {
29✔
135
    setShowActions(false);
9✔
136
  };
137

138
  return (
29✔
139
    <div className={classes.container} ref={innerRef}>
140
      <div className={classes.label}>Release actions</div>
141
      <ClickAwayListener onClickAway={handleClickAway}>
142
        <SpeedDial className={classes.fab} ariaLabel="device-actions" icon={<SpeedDialIcon />} onClick={handleShowActions} open={Boolean(showActions)}>
143
          {actions.map(action => (
144
            <SpeedDialAction
58✔
145
              key={action.key}
146
              aria-label={action.key}
147
              icon={action.icon}
148
              tooltipTitle={action.title}
149
              tooltipOpen
150
              onClick={() => action.action({ ...actionCallbacks, selection: selectedRelease })}
×
151
            />
152
          ))}
153
        </SpeedDial>
154
      </ClickAwayListener>
155
    </div>
156
  );
157
};
158

159
export const EditableLongText = ({ contentFallback = '', fullWidth, original, onChange, placeholder = '-' }) => {
6✔
160
  const [isEditing, setIsEditing] = useState(false);
57✔
161
  const [value, setValue] = useState(original);
57✔
162
  const { classes } = useStyles();
57✔
163

164
  useEffect(() => {
57✔
165
    setValue(original);
6✔
166
  }, [original]);
167

168
  const onCancelClick = () => {
57✔
169
    setValue(original);
×
170
    setIsEditing(false);
×
171
  };
172

173
  const onEdit = ({ target: { value } }) => setValue(value);
57✔
174

175
  const onEditClick = () => setIsEditing(true);
57✔
176

177
  const onToggleEditing = useCallback(
57✔
178
    event => {
179
      event.stopPropagation();
×
180
      if (event.key && (event.key !== 'Enter' || event.shiftKey)) {
×
181
        return;
×
182
      }
183
      if (isEditing) {
×
184
        // save change
185
        onChange(value);
×
186
      }
187
      setIsEditing(toggle);
×
188
    },
189
    [isEditing, onChange, value]
190
  );
191

192
  const fullWidthClass = fullWidth ? 'full-width' : '';
57✔
193

194
  return (
57✔
195
    <div className="flexbox" style={{ alignItems: 'end' }}>
196
      {isEditing ? (
57!
197
        <>
198
          <TextField
199
            className={`margin-right ${fullWidthClass}`}
200
            multiline
201
            onChange={onEdit}
202
            onKeyDown={onToggleEditing}
203
            placeholder={placeholder}
204
            value={value}
205
          />
206
          <ConfirmationButtons onCancel={onCancelClick} onConfirm={onToggleEditing} />
207
        </>
208
      ) : (
209
        <>
210
          <ExpandableAttribute
211
            className={`${fullWidthClass} margin-right ${classes.notesWrapper}`}
212
            component="div"
213
            dense
214
            disableGutters
215
            primary=""
216
            secondary={original || value || contentFallback}
117✔
217
            textClasses={{ secondary: classes.notes }}
218
          />
219
          <EditButton onClick={onEditClick} />
220
        </>
221
      )}
222
    </div>
223
  );
224
};
225

226
const ReleaseNotes = ({ onChange, release: { notes = '' } }) => (
6✔
227
  <>
29✔
228
    <h4>Release notes</h4>
229
    <EditableLongText contentFallback="Add release notes here" original={notes} onChange={onChange} placeholder="Release notes" />
230
  </>
231
);
232

233
const ReleaseTags = ({ existingTags = [], release: { tags = [] }, onChange }) => {
6!
234
  const [isEditing, setIsEditing] = useState(false);
29✔
235
  const [initialValues] = useState({ tags });
29✔
236
  const { classes } = useStyles();
29✔
237

238
  const methods = useForm({ mode: 'onChange', defaultValues: initialValues });
29✔
239
  const { setValue, getValues } = methods;
29✔
240

241
  useEffect(() => {
29✔
242
    if (!initialValues.tags.length) {
29!
243
      setValue('tags', tags);
29✔
244
    }
245
  }, [initialValues.tags, setValue, tags]);
246

247
  const onToggleEdit = useCallback(() => {
29✔
248
    setValue('tags', tags);
×
249
    setIsEditing(toggle);
×
250
  }, [setValue, tags]);
251

252
  const onSave = () => {
29✔
253
    onChange(getValues('tags'));
×
254
    setIsEditing(false);
×
255
  };
256

257
  return (
29✔
258
    <div className="margin-bottom margin-top" style={{ maxWidth: 500 }}>
259
      <div className="flexbox center-aligned">
260
        <h4 className="margin-right">Tags</h4>
261
        {!isEditing && <EditButton onClick={onToggleEdit} />}
58✔
262
      </div>
263
      <div className="flexbox" style={{ alignItems: 'end' }}>
264
        <FormProvider {...methods}>
265
          <form noValidate>
266
            <ChipSelect
267
              className={classes.tagSelect}
268
              disabled={!isEditing}
269
              label=""
270
              name="tags"
271
              options={existingTags}
272
              placeholder={isEditing ? 'Enter release tags' : 'Click edit to add release tags'}
29!
273
            />
274
          </form>
275
        </FormProvider>
276
        {isEditing && <ConfirmationButtons onConfirm={onSave} onCancel={onToggleEdit} />}
29!
277
      </div>
278
    </div>
279
  );
280
};
281

282
const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setShowRemoveArtifactDialog }) => {
6✔
283
  const [sortCol, setSortCol] = useState('modified');
29✔
284
  const [sortDown, setSortDown] = useState(true);
29✔
285

286
  const onRowSelection = artifact => {
29✔
287
    if (artifact?.id === selectedArtifact?.id) {
2!
288
      return setSelectedArtifact();
×
289
    }
290
    setSelectedArtifact(artifact);
2✔
291
  };
292

293
  const sortColumn = col => {
29✔
294
    if (!col.sortable) {
×
295
      return;
×
296
    }
297
    // sort table
298
    setSortDown(toggle);
×
299
    setSortCol(col);
×
300
  };
301

302
  if (!artifacts.length) {
29✔
303
    return null;
3✔
304
  }
305

306
  const items = artifacts.sort(customSort(sortDown, sortCol));
26✔
307

308
  return (
26✔
309
    <>
310
      <h4>Artifacts in this Release:</h4>
311
      <div>
312
        <div className="release-repo-item repo-item repo-header">
313
          {columns.map(item => (
314
            <div className="columnHeader" key={item.name} onClick={() => sortColumn(item)}>
104✔
315
              <Tooltip title={item.title} placement="top-start">
316
                <>{item.title}</>
317
              </Tooltip>
318
              {item.sortable ? <SortIcon className={`sortIcon ${sortCol === item.name ? 'selected' : ''} ${sortDown.toString()}`} /> : null}
156✔
319
              {item.tooltip}
320
            </div>
321
          ))}
322
          <div style={{ width: 48 }} />
323
        </div>
324
        {items.map((artifact, index) => {
325
          const expanded = !!(selectedArtifact?.id === artifact.id);
26✔
326
          return (
26✔
327
            <Artifact
328
              key={`repository-item-${index}`}
329
              artifact={artifact}
330
              columns={columns}
331
              expanded={expanded}
332
              index={index}
333
              onRowSelection={() => onRowSelection(artifact)}
2✔
334
              // this will be run after expansion + collapse and both need some time to fully settle
335
              // otherwise the measurements are off
336
              showRemoveArtifactDialog={setShowRemoveArtifactDialog}
337
            />
338
          );
339
        })}
340
      </div>
341
    </>
342
  );
343
};
344

345
export const ReleaseDetails = () => {
6✔
346
  const [showRemoveDialog, setShowRemoveArtifactDialog] = useState(false);
53✔
347
  const [confirmReleaseDeletion, setConfirmReleaseDeletion] = useState(false);
53✔
348
  const [selectedArtifact, setSelectedArtifact] = useState();
53✔
349

350
  // eslint-disable-next-line no-unused-vars
351
  const windowSize = useWindowSize();
53✔
352
  const creationRef = useRef();
53✔
353
  const drawerRef = useRef();
53✔
354
  const navigate = useNavigate();
53✔
355
  const dispatch = useDispatch();
53✔
356
  const release = useSelector(getSelectedRelease);
53✔
357
  const existingTags = useSelector(getReleaseTags);
53✔
358
  const userCapabilities = useSelector(getUserCapabilities);
53✔
359

360
  const { name: releaseName, artifacts = [] } = release;
53✔
361

362
  const onRemoveArtifact = artifact => dispatch(removeArtifact(artifact.id)).finally(() => setShowRemoveArtifactDialog(false));
53✔
363

364
  const copyLinkToClipboard = () => {
53✔
365
    const location = window.location.href.substring(0, window.location.href.indexOf('/releases') + '/releases'.length);
×
366
    copy(`${location}/${releaseName}`);
×
367
    dispatch(setSnackbar('Link copied to clipboard'));
×
368
  };
369

370
  const onCloseClick = () => dispatch(selectRelease());
53✔
371

372
  const onCreateDeployment = () => navigate(`${DEPLOYMENT_ROUTES.active.route}?open=true&release=${encodeURIComponent(releaseName)}`);
53✔
373

374
  const onToggleReleaseDeletion = () => setConfirmReleaseDeletion(toggle);
53✔
375

376
  const onDeleteRelease = () => dispatch(removeRelease(releaseName)).then(() => setConfirmReleaseDeletion(false));
53✔
377

378
  const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo(releaseName, { notes })), [dispatch, releaseName]);
53✔
379

380
  const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags(releaseName, tags)), [dispatch, releaseName]);
53✔
381

382
  return (
53✔
383
    <Drawer anchor="right" open={!!releaseName} onClose={onCloseClick} PaperProps={{ style: { minWidth: '60vw' }, ref: drawerRef }}>
384
      <div className="flexbox center-aligned space-between">
385
        <div className="flexbox center-aligned">
386
          <b>
387
            Release information for <i>{releaseName}</i>
388
          </b>
389
          <IconButton onClick={copyLinkToClipboard} size="large">
390
            <LinkIcon />
391
          </IconButton>
392
        </div>
393
        <div className="flexbox center-aligned">
394
          <div className="muted margin-right flexbox">
395
            <div className="margin-right-small">Last modified:</div>
396
            <RelativeTime updateTime={release.modified} />
397
          </div>
398
          <IconButton onClick={onCloseClick} aria-label="close" size="large">
399
            <CloseIcon />
400
          </IconButton>
401
        </div>
402
      </div>
403
      <Divider className="margin-bottom" />
404
      <ReleaseNotes onChange={onReleaseNotesChanged} release={release} />
405
      <ReleaseTags existingTags={existingTags} onChange={onTagSelectionChanged} release={release} />
406
      <ArtifactsList
407
        artifacts={artifacts}
408
        selectedArtifact={selectedArtifact}
409
        setSelectedArtifact={setSelectedArtifact}
410
        setShowRemoveArtifactDialog={setShowRemoveArtifactDialog}
411
      />
412
      <RemoveArtifactDialog
413
        artifact={selectedArtifact}
414
        open={!!showRemoveDialog}
415
        onCancel={() => setShowRemoveArtifactDialog(false)}
2✔
416
        onRemove={() => onRemoveArtifact(selectedArtifact)}
×
417
      />
418
      <RemoveArtifactDialog open={!!confirmReleaseDeletion} onRemove={onDeleteRelease} onCancel={onToggleReleaseDeletion} release={release} />
419
      <ReleaseQuickActions
420
        actionCallbacks={{ onCreateDeployment, onDeleteRelease: onToggleReleaseDeletion }}
421
        innerRef={creationRef}
422
        selectedRelease={release}
423
        userCapabilities={userCapabilities}
424
      />
425
    </Drawer>
426
  );
427
};
428

429
export default ReleaseDetails;
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