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

mendersoftware / gui / 1350829378

27 Jun 2024 01:46PM UTC coverage: 83.494% (-16.5%) from 99.965%
1350829378

Pull #4465

gitlab-ci

mzedel
chore: test fixes

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4465: MEN-7169 - feat: added multi sorting capabilities to devices view

4506 of 6430 branches covered (70.08%)

81 of 100 new or added lines in 14 files covered. (81.0%)

1661 existing lines in 163 files now uncovered.

8574 of 10269 relevant lines covered (83.49%)

160.6 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
} from '@mui/icons-material';
27
import {
28
  Button,
29
  ClickAwayListener,
30
  Dialog,
31
  DialogActions,
32
  DialogContent,
33
  DialogTitle,
34
  Divider,
35
  Drawer,
36
  IconButton,
37
  SpeedDial,
38
  SpeedDialAction,
39
  SpeedDialIcon,
40
  TextField,
41
  Tooltip
42
} from '@mui/material';
43
import { speedDialActionClasses } from '@mui/material/SpeedDialAction';
44
import { makeStyles } from 'tss-react/mui';
45

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

49
import { setSnackbar } from '../../actions/appActions';
50
import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '../../actions/releaseActions';
51
import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants';
52
import { FileSize, customSort, formatTime, toggle } from '../../helpers';
53
import { getReleaseListState, getReleaseTags, getSelectedRelease, getUserCapabilities } from '../../selectors';
54
import { generateReleasesPath } from '../../utils/locationutils';
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 SortIcon from '../common/sorticon';
60
import { RelativeTime } from '../common/time';
61
import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips';
62
import Artifact from './artifact';
63
import RemoveArtifactDialog from './dialogs/removeartifact';
64

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

395
  const { name: releaseName, artifacts = [] } = release;
42✔
396

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

399
  const copyLinkToClipboard = () => {
42✔
UNCOV
400
    const location = window.location.href.substring(0, window.location.href.indexOf('/releases'));
×
UNCOV
401
    copy(`${location}${generateReleasesPath({ pageState: { selectedRelease: releaseName } })}`);
×
UNCOV
402
    dispatch(setSnackbar('Link copied to clipboard'));
×
403
  };
404

405
  const onCloseClick = () => dispatch(selectRelease());
42✔
406

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

409
  const onToggleReleaseDeletion = () => setConfirmReleaseDeletion(toggle);
42✔
410

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

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

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

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

464
export default ReleaseDetails;
465

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