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

mendersoftware / gui / 1301920191

23 May 2024 07:13AM UTC coverage: 83.42% (-16.5%) from 99.964%
1301920191

Pull #4421

gitlab-ci

mzedel
fix: fixed an issue that sometimes prevented reopening paginated auditlog links

Ticket: None
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4421: MEN-7034 - device information in auditlog entries

4456 of 6367 branches covered (69.99%)

34 of 35 new or added lines in 7 files covered. (97.14%)

1668 existing lines in 162 files now uncovered.

8473 of 10157 relevant lines covered (83.42%)

140.52 hits per line

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

77.78
/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
  LabelOutlined as LabelOutlinedIcon,
24
  Link as LinkIcon,
25
  Replay as ReplayIcon,
26
  Sort as SortIcon
27
} from '@mui/icons-material';
28
import {
29
  Button,
30
  ClickAwayListener,
31
  Dialog,
32
  DialogActions,
33
  DialogContent,
34
  DialogTitle,
35
  Divider,
36
  Drawer,
37
  IconButton,
38
  SpeedDial,
39
  SpeedDialAction,
40
  SpeedDialIcon,
41
  TextField,
42
  Tooltip
43
} from '@mui/material';
44
import { speedDialActionClasses } from '@mui/material/SpeedDialAction';
45
import { makeStyles } from 'tss-react/mui';
46

47
import copy from 'copy-to-clipboard';
48
import pluralize from 'pluralize';
49

50
import { setSnackbar } from '../../actions/appActions';
51
import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '../../actions/releaseActions';
52
import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants';
53
import { FileSize, customSort, formatTime, toggle } from '../../helpers';
54
import { getReleaseListState, getReleaseTags, getSelectedRelease, getUserCapabilities } from '../../selectors';
55
import useWindowSize from '../../utils/resizehook';
56
import ChipSelect from '../common/chipselect';
57
import { ConfirmationButtons, EditButton } from '../common/confirm';
58
import ExpandableAttribute from '../common/expandable-attribute';
59
import { RelativeTime } from '../common/time';
60
import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips';
61
import Artifact from './artifact';
62
import RemoveArtifactDialog from './dialogs/removeartifact';
63

64
const DeviceTypeCompatibility = ({ artifact }) => {
7✔
65
  const compatible = artifact.artifact_depends ? artifact.artifact_depends.device_type.join(', ') : artifact.device_types_compatible.join(', ');
9✔
66
  return (
9✔
67
    <Tooltip title={compatible} placement="top-start">
68
      <div className="text-overflow">{compatible}</div>
69
    </Tooltip>
70
  );
71
};
72

73
export const columns = [
7✔
74
  {
75
    title: 'Device type compatibility',
76
    name: 'device_types',
77
    sortable: false,
78
    render: DeviceTypeCompatibility,
79
    tooltip: <MenderHelpTooltip id={HELPTOOLTIPS.expandArtifact.id} className="margin-left-small" />
80
  },
81
  {
82
    title: 'Type',
83
    name: 'type',
84
    sortable: false,
85
    render: ({ artifact }) => <div style={{ maxWidth: '100vw' }}>{artifact.updates.reduce((accu, item) => (accu ? accu : item.type_info.type), '')}</div>
9!
86
  },
87
  { title: 'Size', name: 'size', sortable: true, render: ({ artifact }) => <FileSize fileSize={artifact.size} /> },
9✔
88
  { title: 'Last modified', name: 'modified', sortable: true, render: ({ artifact }) => <RelativeTime updateTime={formatTime(artifact.modified)} /> }
9✔
89
];
90

91
const defaultActions = [
7✔
92
  {
UNCOV
93
    action: ({ onCreateDeployment, selection }) => onCreateDeployment(selection),
×
94
    icon: <ReplayIcon />,
95
    isApplicable: ({ userCapabilities: { canDeploy }, selectedSingleRelease }) => canDeploy && selectedSingleRelease,
3✔
96
    key: 'deploy',
97
    title: () => 'Create a deployment for this release'
12✔
98
  },
99
  {
UNCOV
100
    action: ({ onTagRelease, selectedReleases }) => onTagRelease(selectedReleases),
×
101
    icon: <LabelOutlinedIcon />,
102
    isApplicable: ({ userCapabilities: { canManageReleases } }) => canManageReleases,
3✔
103
    key: 'tag',
104
    title: pluralized => `Tag ${pluralized}`
12✔
105
  },
106
  {
UNCOV
107
    action: ({ onDeleteRelease, selection, selectedReleases }) => onDeleteRelease(selection || selectedReleases),
×
108
    icon: <HighlightOffOutlinedIcon className="red" />,
109
    isApplicable: ({ userCapabilities: { canManageReleases } }) => canManageReleases,
3✔
110
    key: 'delete',
111
    title: pluralized => `Delete ${pluralized}`
12✔
112
  }
113
];
114

115
const useStyles = makeStyles()(theme => ({
7✔
116
  container: {
117
    display: 'flex',
118
    position: 'fixed',
119
    bottom: theme.spacing(6.5),
120
    right: theme.spacing(6.5),
121
    zIndex: 10,
122
    minWidth: 'max-content',
123
    alignItems: 'flex-end',
124
    justifyContent: 'flex-end',
125
    pointerEvents: 'none',
126
    [`.${speedDialActionClasses.staticTooltipLabel}`]: {
127
      minWidth: 'max-content'
128
    }
129
  },
130
  fab: { margin: theme.spacing(2) },
131
  tagSelect: { marginRight: theme.spacing(2), maxWidth: 350 },
132
  label: {
133
    marginRight: theme.spacing(2),
134
    marginBottom: theme.spacing(4)
135
  },
136
  notes: { display: 'block', whiteSpace: 'pre-wrap' },
137
  notesWrapper: { minWidth: theme.components?.MuiFormControl?.styleOverrides?.root?.minWidth }
138
}));
139

140
export const ReleaseQuickActions = ({ actionCallbacks, innerRef, selectedRelease, userCapabilities, releases }) => {
7✔
141
  const [showActions, setShowActions] = useState(false);
12✔
142
  const [selectedReleases, setSelectedReleases] = useState([]);
12✔
143
  const { classes } = useStyles();
12✔
144
  const { selection: selectedRows } = useSelector(getReleaseListState);
12✔
145

146
  useEffect(() => {
12✔
147
    if (releases) {
1!
UNCOV
148
      setSelectedReleases(selectedRows.map(row => releases[row]));
×
149
    }
150
  }, [releases, selectedRows, setSelectedReleases]);
151

152
  const actions = useMemo(() => {
12✔
153
    return Object.values(defaultActions).reduce((accu, action) => {
3✔
154
      if (action.isApplicable({ userCapabilities, selectedSingleRelease: !!selectedRelease })) {
9!
155
        accu.push(action);
9✔
156
      }
157
      return accu;
9✔
158
    }, []);
159
    // eslint-disable-next-line react-hooks/exhaustive-deps
160
  }, [JSON.stringify(userCapabilities), selectedRelease]);
161

162
  const handleShowActions = () => {
12✔
UNCOV
163
    setShowActions(!showActions);
×
164
  };
165

166
  const handleClickAway = () => {
12✔
167
    setShowActions(false);
5✔
168
  };
169

170
  const pluralized = pluralize('releases', selectedRows.length);
12✔
171

172
  return (
12✔
173
    <div className={classes.container} ref={innerRef}>
174
      <div className={classes.label}>{selectedRelease ? 'Release actions' : `${selectedRows.length} ${pluralized} selected`}</div>
12!
175
      <ClickAwayListener onClickAway={handleClickAway}>
176
        <SpeedDial className={classes.fab} ariaLabel="device-actions" icon={<SpeedDialIcon />} onClick={handleShowActions} open={Boolean(showActions)}>
177
          {actions.map(action => (
178
            <SpeedDialAction
36✔
179
              key={action.key}
180
              aria-label={action.key}
181
              icon={action.icon}
182
              tooltipTitle={action.title(pluralized)}
183
              tooltipOpen
UNCOV
184
              onClick={() => action.action({ ...actionCallbacks, selection: selectedRelease, selectedReleases })}
×
185
            />
186
          ))}
187
        </SpeedDial>
188
      </ClickAwayListener>
189
    </div>
190
  );
191
};
192

193
export const EditableLongText = ({ contentFallback = '', fullWidth, original, onChange, placeholder = '-' }) => {
7✔
194
  const [isEditing, setIsEditing] = useState(false);
22✔
195
  const [value, setValue] = useState(original);
22✔
196
  const { classes } = useStyles();
22✔
197

198
  useEffect(() => {
22✔
199
    setValue(original);
4✔
200
  }, [original]);
201

202
  const onCancelClick = () => {
22✔
UNCOV
203
    setValue(original);
×
UNCOV
204
    setIsEditing(false);
×
205
  };
206

207
  const onEdit = ({ target: { value } }) => setValue(value);
22✔
208

209
  const onEditClick = () => setIsEditing(true);
22✔
210

211
  const onToggleEditing = useCallback(
22✔
212
    event => {
UNCOV
213
      event.stopPropagation();
×
UNCOV
214
      if (event.key && (event.key !== 'Enter' || event.shiftKey)) {
×
UNCOV
215
        return;
×
216
      }
UNCOV
217
      if (isEditing) {
×
218
        // save change
UNCOV
219
        onChange(value);
×
220
      }
UNCOV
221
      setIsEditing(toggle);
×
222
    },
223
    [isEditing, onChange, value]
224
  );
225

226
  const fullWidthClass = fullWidth ? 'full-width' : '';
22✔
227

228
  return (
22✔
229
    <div className="flexbox" style={{ alignItems: 'end' }}>
230
      {isEditing ? (
22!
231
        <>
232
          <TextField
233
            className={`margin-right ${fullWidthClass}`}
234
            multiline
235
            onChange={onEdit}
236
            onKeyDown={onToggleEditing}
237
            placeholder={placeholder}
238
            value={value}
239
          />
240
          <ConfirmationButtons onCancel={onCancelClick} onConfirm={onToggleEditing} />
241
        </>
242
      ) : (
243
        <>
244
          <ExpandableAttribute
245
            className={`${fullWidthClass} margin-right ${classes.notesWrapper}`}
246
            component="div"
247
            dense
248
            disableGutters
249
            primary=""
250
            secondary={original || value || contentFallback}
48✔
251
            textClasses={{ secondary: classes.notes }}
252
          />
253
          <EditButton onClick={onEditClick} />
254
        </>
255
      )}
256
    </div>
257
  );
258
};
259

260
const ReleaseNotes = ({ onChange, release: { notes = '' } }) => (
7✔
261
  <>
12✔
262
    <h4>Release notes</h4>
263
    <EditableLongText contentFallback="Add release notes here" original={notes} onChange={onChange} placeholder="Release notes" />
264
  </>
265
);
266

267
const ReleaseTags = ({ existingTags = [], release: { tags = [] }, onChange }) => {
7!
268
  const [isEditing, setIsEditing] = useState(false);
12✔
269
  const [initialValues] = useState({ tags });
12✔
270
  const { classes } = useStyles();
12✔
271

272
  const methods = useForm({ mode: 'onChange', defaultValues: initialValues });
12✔
273
  const { setValue, getValues } = methods;
12✔
274

275
  useEffect(() => {
12✔
276
    if (!initialValues.tags.length) {
12!
277
      setValue('tags', tags);
12✔
278
    }
279
  }, [initialValues.tags, setValue, tags]);
280

281
  const onToggleEdit = useCallback(() => {
12✔
UNCOV
282
    setValue('tags', tags);
×
UNCOV
283
    setIsEditing(toggle);
×
284
  }, [setValue, tags]);
285

286
  const onSave = () => {
12✔
UNCOV
287
    onChange(getValues('tags'));
×
UNCOV
288
    setIsEditing(false);
×
289
  };
290

291
  return (
12✔
292
    <div className="margin-bottom margin-top" style={{ maxWidth: 500 }}>
293
      <div className="flexbox center-aligned">
294
        <h4 className="margin-right">Tags</h4>
295
        {!isEditing && <EditButton onClick={onToggleEdit} />}
24✔
296
      </div>
297
      <div className="flexbox" style={{ alignItems: 'end' }}>
298
        <FormProvider {...methods}>
299
          <form noValidate>
300
            <ChipSelect
301
              className={classes.tagSelect}
302
              disabled={!isEditing}
303
              label=""
304
              name="tags"
305
              options={existingTags}
306
              placeholder={isEditing ? 'Enter release tags' : 'Click edit to add release tags'}
12!
307
            />
308
          </form>
309
        </FormProvider>
310
        {isEditing && <ConfirmationButtons onConfirm={onSave} onCancel={onToggleEdit} />}
12!
311
      </div>
312
    </div>
313
  );
314
};
315

316
const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setShowRemoveArtifactDialog }) => {
7✔
317
  const [sortCol, setSortCol] = useState('modified');
12✔
318
  const [sortDown, setSortDown] = useState(true);
12✔
319

320
  const onRowSelection = artifact => {
12✔
321
    if (artifact?.id === selectedArtifact?.id) {
1!
UNCOV
322
      return setSelectedArtifact();
×
323
    }
324
    setSelectedArtifact(artifact);
1✔
325
  };
326

327
  const sortColumn = col => {
12✔
UNCOV
328
    if (!col.sortable) {
×
UNCOV
329
      return;
×
330
    }
331
    // sort table
UNCOV
332
    setSortDown(toggle);
×
UNCOV
333
    setSortCol(col);
×
334
  };
335

336
  if (!artifacts.length) {
12✔
337
    return null;
4✔
338
  }
339

340
  const items = artifacts.sort(customSort(sortDown, sortCol));
8✔
341

342
  return (
8✔
343
    <>
344
      <h4>Artifacts in this Release:</h4>
345
      <div>
346
        <div className="release-repo-item repo-item repo-header">
347
          {columns.map(item => (
348
            <div className="columnHeader" key={item.name} onClick={() => sortColumn(item)}>
32✔
349
              <Tooltip title={item.title} placement="top-start">
350
                <>{item.title}</>
351
              </Tooltip>
352
              {item.sortable ? <SortIcon className={`sortIcon ${sortCol === item.name ? 'selected' : ''} ${sortDown.toString()}`} /> : null}
48✔
353
              {item.tooltip}
354
            </div>
355
          ))}
356
          <div style={{ width: 48 }} />
357
        </div>
358
        {items.map((artifact, index) => {
359
          const expanded = selectedArtifact?.id === artifact.id;
8✔
360
          return (
8✔
361
            <Artifact
362
              key={`repository-item-${index}`}
363
              artifact={artifact}
364
              columns={columns}
365
              expanded={expanded}
366
              index={index}
367
              onRowSelection={() => onRowSelection(artifact)}
1✔
368
              // this will be run after expansion + collapse and both need some time to fully settle
369
              // otherwise the measurements are off
370
              showRemoveArtifactDialog={setShowRemoveArtifactDialog}
371
            />
372
          );
373
        })}
374
      </div>
375
    </>
376
  );
377
};
378

379
export const ReleaseDetails = () => {
7✔
380
  const [showRemoveDialog, setShowRemoveArtifactDialog] = useState(false);
41✔
381
  const [confirmReleaseDeletion, setConfirmReleaseDeletion] = useState(false);
41✔
382
  const [selectedArtifact, setSelectedArtifact] = useState();
41✔
383

384
  // eslint-disable-next-line no-unused-vars
385
  const windowSize = useWindowSize();
41✔
386
  const creationRef = useRef();
41✔
387
  const drawerRef = useRef();
41✔
388
  const navigate = useNavigate();
41✔
389
  const dispatch = useDispatch();
41✔
390
  const release = useSelector(getSelectedRelease);
41✔
391
  const existingTags = useSelector(getReleaseTags);
41✔
392
  const userCapabilities = useSelector(getUserCapabilities);
41✔
393

394
  const { name: releaseName, artifacts = [] } = release;
41✔
395

396
  const onRemoveArtifact = artifact => dispatch(removeArtifact(artifact.id)).finally(() => setShowRemoveArtifactDialog(false));
41✔
397

398
  const copyLinkToClipboard = () => {
41✔
UNCOV
399
    const location = window.location.href.substring(0, window.location.href.indexOf('/releases') + '/releases'.length);
×
UNCOV
400
    copy(`${location}/${releaseName}`);
×
UNCOV
401
    dispatch(setSnackbar('Link copied to clipboard'));
×
402
  };
403

404
  const onCloseClick = () => dispatch(selectRelease());
41✔
405

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

408
  const onToggleReleaseDeletion = () => setConfirmReleaseDeletion(toggle);
41✔
409

410
  const onDeleteRelease = () => dispatch(removeRelease(releaseName)).then(() => setConfirmReleaseDeletion(false));
41✔
411

412
  const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo(releaseName, { notes })), [dispatch, releaseName]);
41✔
413

414
  const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags(releaseName, tags)), [dispatch, releaseName]);
41✔
415

416
  return (
41✔
417
    <Drawer anchor="right" open={!!releaseName} onClose={onCloseClick} PaperProps={{ style: { minWidth: '60vw' }, ref: drawerRef }}>
418
      <div className="flexbox center-aligned space-between">
419
        <div className="flexbox center-aligned">
420
          <b>
421
            Release information for <i>{releaseName}</i>
422
          </b>
423
          <IconButton onClick={copyLinkToClipboard} size="large">
424
            <LinkIcon />
425
          </IconButton>
426
        </div>
427
        <div className="flexbox center-aligned">
428
          <div className="muted margin-right flexbox">
429
            <div className="margin-right-small">Last modified:</div>
430
            <RelativeTime updateTime={release.modified} />
431
          </div>
432
          <IconButton onClick={onCloseClick} aria-label="close" size="large">
433
            <CloseIcon />
434
          </IconButton>
435
        </div>
436
      </div>
437
      <Divider className="margin-bottom" />
438
      <ReleaseNotes onChange={onReleaseNotesChanged} release={release} />
439
      <ReleaseTags existingTags={existingTags} onChange={onTagSelectionChanged} release={release} />
440
      <ArtifactsList
441
        artifacts={artifacts}
442
        selectedArtifact={selectedArtifact}
443
        setSelectedArtifact={setSelectedArtifact}
444
        setShowRemoveArtifactDialog={setShowRemoveArtifactDialog}
445
      />
446
      <RemoveArtifactDialog
447
        artifact={selectedArtifact}
448
        open={!!showRemoveDialog}
449
        onCancel={() => setShowRemoveArtifactDialog(false)}
1✔
UNCOV
450
        onRemove={() => onRemoveArtifact(selectedArtifact)}
×
451
      />
452
      <RemoveArtifactDialog open={!!confirmReleaseDeletion} onRemove={onDeleteRelease} onCancel={onToggleReleaseDeletion} release={release} />
453
      <ReleaseQuickActions
454
        actionCallbacks={{ onCreateDeployment, onDeleteRelease: onToggleReleaseDeletion }}
455
        innerRef={creationRef}
456
        selectedRelease={release}
457
        userCapabilities={userCapabilities}
458
      />
459
    </Drawer>
460
  );
461
};
462

463
export default ReleaseDetails;
464

465
export const DeleteReleasesConfirmationDialog = ({ onClose, onSubmit }) => (
7✔
UNCOV
466
  <Dialog open={true}>
×
467
    <DialogTitle>Delete releases?</DialogTitle>
468
    <DialogContent style={{ overflow: 'hidden' }}>All releases artifacts will be deleted. Are you sure you want to delete these releases ?</DialogContent>
469
    <DialogActions>
470
      <Button style={{ marginRight: 10 }} onClick={onClose}>
471
        Cancel
472
      </Button>
473
      <Button variant="contained" color="primary" onClick={onSubmit}>
474
        Delete
475
      </Button>
476
    </DialogActions>
477
  </Dialog>
478
);
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