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

mendersoftware / gui / 1015445845

25 Sep 2023 09:43AM UTC coverage: 82.537% (-17.4%) from 99.964%
1015445845

Pull #4028

gitlab-ci

mzedel
chore: aligned release retrieval with v2 api models

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4028: MEN-6455

4355 of 6315 branches covered (0.0%)

184 of 206 new or added lines in 19 files covered. (89.32%)

1724 existing lines in 164 files now uncovered.

8323 of 10084 relevant lines covered (82.54%)

208.49 hits per line

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

77.27
/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 { 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 }) => {
7✔
48
  const compatible = artifact.artifact_depends ? artifact.artifact_depends.device_type.join(', ') : artifact.device_types_compatible.join(', ');
29✔
49
  return (
29✔
50
    <Tooltip title={compatible} placement="top-start">
51
      <div className="text-overflow">{compatible}</div>
52
    </Tooltip>
53
  );
54
};
55

56
export const columns = [
7✔
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>
29!
69
  },
70
  { title: 'Size', name: 'size', sortable: true, render: ({ artifact }) => <FileSize fileSize={artifact.size} /> },
29✔
71
  { title: 'Last modified', name: 'modified', sortable: true, render: ({ artifact }) => <RelativeTime updateTime={formatTime(artifact.modified)} /> }
29✔
72
];
73

74
const defaultActions = [
7✔
75
  {
UNCOV
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
  {
UNCOV
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 }) => {
7✔
117
  const [showActions, setShowActions] = useState(false);
31✔
118
  const { classes } = useStyles();
31✔
119

120
  const actions = useMemo(() => {
31✔
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
  return (
31✔
131
    <div className={classes.container} ref={innerRef}>
132
      <div className={classes.label}>Release actions</div>
133
      <SpeedDial
134
        className={classes.fab}
135
        ariaLabel="device-actions"
136
        icon={<SpeedDialIcon />}
UNCOV
137
        onClose={() => setShowActions(false)}
×
138
        onOpen={setShowActions}
139
        open={Boolean(showActions)}
140
      >
141
        {actions.map(action => (
142
          <SpeedDialAction
62✔
143
            key={action.key}
144
            aria-label={action.key}
145
            icon={action.icon}
146
            tooltipTitle={action.title}
147
            tooltipOpen
UNCOV
148
            onClick={() => action.action({ ...actionCallbacks, selection: selectedRelease })}
×
149
          />
150
        ))}
151
      </SpeedDial>
152
    </div>
153
  );
154
};
155

156
export const EditableLongText = ({ contentFallback = '', fullWidth, original, onChange, placeholder = '-' }) => {
7✔
157
  const [isEditing, setIsEditing] = useState(false);
61✔
158
  const [value, setValue] = useState(original);
61✔
159
  const { classes } = useStyles();
61✔
160

161
  useEffect(() => {
61✔
162
    setValue(original);
6✔
163
  }, [original]);
164

165
  const onCancelClick = () => {
61✔
NEW
166
    setValue(original);
×
NEW
167
    setIsEditing(false);
×
168
  };
169

170
  const onEdit = ({ target: { value } }) => setValue(value);
61✔
171

172
  const onEditClick = () => setIsEditing(true);
61✔
173

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

189
  const fullWidthClass = fullWidth ? 'full-width' : '';
61✔
190

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

223
const ReleaseNotes = ({ onChange, release: { notes = '' } }) => (
7✔
224
  <>
31✔
225
    <h4>Release notes</h4>
226
    <EditableLongText contentFallback="Add release notes here" original={notes} onChange={onChange} placeholder="Release notes" />
227
  </>
228
);
229

230
const ReleaseTags = ({ existingTags = [], release: { tags = [] }, onChange }) => {
7!
231
  const [isEditing, setIsEditing] = useState(false);
31✔
232
  const [initialValues] = useState({ tags });
31✔
233
  const { classes } = useStyles();
31✔
234

235
  const methods = useForm({ mode: 'onChange', defaultValues: initialValues });
31✔
236
  const { setValue, getValues } = methods;
31✔
237

238
  useEffect(() => {
31✔
239
    if (!initialValues.tags.length) {
31!
240
      setValue('tags', tags);
31✔
241
    }
242
  }, [initialValues.tags, setValue, tags]);
243

244
  const onToggleEdit = useCallback(() => {
31✔
NEW
245
    setValue('tags', tags);
×
NEW
246
    setIsEditing(toggle);
×
247
  }, [setValue, tags]);
248

249
  const onSave = () => {
31✔
NEW
250
    onChange(getValues('tags'));
×
NEW
251
    setIsEditing(false);
×
252
  };
253

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

279
const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setShowRemoveArtifactDialog }) => {
7✔
280
  const [sortCol, setSortCol] = useState('modified');
31✔
281
  const [sortDown, setSortDown] = useState(true);
31✔
282

283
  const onRowSelection = artifact => {
31✔
284
    if (artifact?.id === selectedArtifact?.id) {
2!
NEW
285
      return setSelectedArtifact();
×
286
    }
287
    setSelectedArtifact(artifact);
2✔
288
  };
289

290
  const sortColumn = col => {
31✔
UNCOV
291
    if (!col.sortable) {
×
UNCOV
292
      return;
×
293
    }
294
    // sort table
UNCOV
295
    setSortDown(toggle);
×
UNCOV
296
    setSortCol(col);
×
297
  };
298

299
  if (!artifacts.length) {
31✔
300
    return null;
3✔
301
  }
302

303
  const items = artifacts.sort(customSort(sortDown, sortCol));
28✔
304

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

342
export const ReleaseDetails = () => {
7✔
343
  const [showRemoveDialog, setShowRemoveArtifactDialog] = useState(false);
52✔
344
  const [confirmReleaseDeletion, setConfirmReleaseDeletion] = useState(false);
52✔
345
  const [selectedArtifact, setSelectedArtifact] = useState();
52✔
346

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

357
  const { name: releaseName, artifacts = [] } = release;
52✔
358

359
  const onRemoveArtifact = artifact => dispatch(removeArtifact(artifact.id)).finally(() => setShowRemoveArtifactDialog(false));
52✔
360

361
  const copyLinkToClipboard = () => {
52✔
UNCOV
362
    const location = window.location.href.substring(0, window.location.href.indexOf('/releases') + '/releases'.length);
×
NEW
363
    copy(`${location}/${releaseName}`);
×
UNCOV
364
    dispatch(setSnackbar('Link copied to clipboard'));
×
365
  };
366

367
  const onCloseClick = () => dispatch(selectRelease());
52✔
368

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

371
  const onToggleReleaseDeletion = () => setConfirmReleaseDeletion(toggle);
52✔
372

373
  const onDeleteRelease = () => dispatch(removeRelease(releaseName)).then(() => setConfirmReleaseDeletion(false));
52✔
374

375
  const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo(releaseName, { notes })), [dispatch, releaseName]);
52✔
376

377
  const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags(releaseName, tags)), [dispatch, releaseName]);
52✔
378

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

426
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