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

mendersoftware / mender-server / 1616115226

08 Jan 2025 12:29PM UTC coverage: 73.566% (+0.7%) from 72.851%
1616115226

Pull #324

gitlab-ci

alfrunes
ci: Run enterprise merge on same repository

Creates new branches called mender-server/<branch>.
Creates a pull request to <branch>.

Signed-off-by: Alf-Rune Siqveland <alf.rune@northern.tech>
Pull Request #324: ci: Run enterprise merge on same repository

4280 of 6206 branches covered (68.97%)

Branch coverage included in aggregate %.

40154 of 54194 relevant lines covered (74.09%)

23.11 hits per line

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

77.03
/frontend/src/js/components/releases/ReleaseDetails.tsx
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
  HighlightOffOutlined as HighlightOffOutlinedIcon,
22
  LabelOutlined as LabelOutlinedIcon,
23
  Replay as ReplayIcon,
24
  Sort as SortIcon
25
} from '@mui/icons-material';
26
import {
27
  Button,
28
  ClickAwayListener,
29
  Dialog,
30
  DialogActions,
31
  DialogContent,
32
  DialogTitle,
33
  Divider,
34
  Drawer,
35
  SpeedDial,
36
  SpeedDialAction,
37
  SpeedDialIcon,
38
  Tooltip
39
} from '@mui/material';
40
import { speedDialActionClasses } from '@mui/material/SpeedDialAction';
41
import { makeStyles } from 'tss-react/mui';
42

43
import ChipSelect from '@northern.tech/common-ui/ChipSelect';
44
import { ConfirmationButtons, EditButton } from '@northern.tech/common-ui/Confirm';
45
import { DrawerTitle } from '@northern.tech/common-ui/DrawerTitle';
46
import { EditableLongText } from '@northern.tech/common-ui/EditableLongText';
47
import FileSize from '@northern.tech/common-ui/FileSize';
48
import { RelativeTime } from '@northern.tech/common-ui/Time';
49
import { HELPTOOLTIPS, MenderHelpTooltip } from '@northern.tech/helptips/HelpTooltips';
50
import storeActions from '@northern.tech/store/actions';
51
import { DEPLOYMENT_ROUTES } from '@northern.tech/store/constants';
52
import { getReleaseListState, getReleaseTags, getSelectedRelease, getUserCapabilities } from '@northern.tech/store/selectors';
53
import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '@northern.tech/store/thunks';
54
import { customSort, formatTime, toggle } from '@northern.tech/utils/helpers';
55
import { generateReleasesPath } from '@northern.tech/utils/locationutils';
56
import useWindowSize from '@northern.tech/utils/resizehook';
57
import copy from 'copy-to-clipboard';
58
import pluralize from 'pluralize';
59

60
import Artifact from './Artifact';
61
import RemoveArtifactDialog from './dialogs/RemoveArtifact';
62

63
const { setSnackbar } = storeActions;
6✔
64

65
const DeviceTypeCompatibility = ({ artifact }) => {
6✔
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 = [
6✔
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 = [
6✔
93
  {
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'
13✔
99
  },
100
  {
101
    action: ({ onTagRelease, selectedReleases }) => onTagRelease(selectedReleases),
×
102
    icon: <LabelOutlinedIcon />,
103
    isApplicable: ({ userCapabilities: { canManageReleases }, selectedSingleRelease }) => canManageReleases && !selectedSingleRelease,
3✔
104
    key: 'tag',
105
    title: pluralized => `Tag ${pluralized}`
×
106
  },
107
  {
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}`
13✔
113
  }
114
];
115

116
const useStyles = makeStyles()(theme => ({
6✔
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
}));
138

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

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

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

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

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

170
  const pluralized = pluralize('releases', selectedRelease ? 1 : selectedRows.length);
13!
171

172
  return (
13✔
173
    <div className={classes.container} ref={innerRef}>
174
      <div className={classes.label}>{selectedRelease ? 'Release actions' : `${selectedRows.length} ${pluralized} selected`}</div>
13!
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
26✔
179
              key={action.key}
180
              aria-label={action.key}
181
              icon={action.icon}
182
              tooltipTitle={action.title(pluralized)}
183
              tooltipOpen
184
              onClick={() => action.action({ ...actionCallbacks, selection: selectedRelease, selectedReleases })}
×
185
            />
186
          ))}
187
        </SpeedDial>
188
      </ClickAwayListener>
189
    </div>
190
  );
191
};
192

193
const ReleaseNotes = ({ onChange, release: { notes = '' } }) => (
6✔
194
  <>
12✔
195
    <h4>Release notes</h4>
196
    <EditableLongText contentFallback="Add release notes here" original={notes} onChange={onChange} placeholder="Release notes" />
197
  </>
198
);
199

200
const ReleaseTags = ({ existingTags = [], release: { tags = [] }, onChange }) => {
6!
201
  const [isEditing, setIsEditing] = useState(false);
12✔
202
  const [initialValues] = useState({ tags });
12✔
203
  const { classes } = useStyles();
12✔
204

205
  const methods = useForm({ mode: 'onChange', defaultValues: initialValues });
12✔
206
  const { setValue, getValues } = methods;
12✔
207

208
  useEffect(() => {
12✔
209
    if (!initialValues.tags.length) {
12!
210
      setValue('tags', tags);
12✔
211
    }
212
  }, [initialValues.tags, setValue, tags]);
213

214
  const onToggleEdit = useCallback(() => {
12✔
215
    setValue('tags', tags);
×
216
    setIsEditing(toggle);
×
217
  }, [setValue, tags]);
218

219
  const onSave = () => {
12✔
220
    onChange(getValues('tags'));
×
221
    setIsEditing(false);
×
222
  };
223

224
  return (
12✔
225
    <div className="margin-bottom margin-top" style={{ maxWidth: 500 }}>
226
      <div className="flexbox center-aligned">
227
        <h4 className="margin-right">Tags</h4>
228
        {!isEditing && <EditButton onClick={onToggleEdit} />}
24✔
229
      </div>
230
      <div className="flexbox" style={{ alignItems: 'end' }}>
231
        <FormProvider {...methods}>
232
          <form noValidate>
233
            <ChipSelect
234
              className={classes.tagSelect}
235
              disabled={!isEditing}
236
              label=""
237
              name="tags"
238
              options={existingTags}
239
              placeholder={isEditing ? 'Enter release tags' : 'Click edit to add release tags'}
12!
240
            />
241
          </form>
242
        </FormProvider>
243
        {isEditing && <ConfirmationButtons onConfirm={onSave} onCancel={onToggleEdit} />}
12!
244
      </div>
245
    </div>
246
  );
247
};
248

249
const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setShowRemoveArtifactDialog }) => {
6✔
250
  const [sortCol, setSortCol] = useState('modified');
12✔
251
  const [sortDown, setSortDown] = useState(true);
12✔
252

253
  const onRowSelection = artifact => {
12✔
254
    if (artifact?.id === selectedArtifact?.id) {
1!
255
      return setSelectedArtifact();
×
256
    }
257
    setSelectedArtifact(artifact);
1✔
258
  };
259

260
  const sortColumn = col => {
12✔
261
    if (!col.sortable) {
×
262
      return;
×
263
    }
264
    // sort table
265
    setSortDown(toggle);
×
266
    setSortCol(col);
×
267
  };
268

269
  if (!artifacts.length) {
12✔
270
    return null;
4✔
271
  }
272

273
  const items = artifacts.sort(customSort(sortDown, sortCol));
8✔
274

275
  return (
8✔
276
    <>
277
      <h4>Artifacts in this Release:</h4>
278
      <div>
279
        <div className="release-repo-item repo-item repo-header">
280
          {columns.map(item => (
281
            <div className="columnHeader" key={item.name} onClick={() => sortColumn(item)}>
32✔
282
              <Tooltip title={item.title} placement="top-start">
283
                <>{item.title}</>
284
              </Tooltip>
285
              {item.sortable ? <SortIcon className={`sortIcon ${sortCol === item.name ? 'selected' : ''} ${sortDown.toString()}`} /> : null}
48✔
286
              {item.tooltip}
287
            </div>
288
          ))}
289
          <div style={{ width: 48 }} />
290
        </div>
291
        {items.map((artifact, index) => {
292
          const expanded = selectedArtifact?.id === artifact.id;
8✔
293
          return (
8✔
294
            <Artifact
295
              key={`repository-item-${index}`}
296
              artifact={artifact}
297
              columns={columns}
298
              expanded={expanded}
299
              index={index}
300
              onRowSelection={() => onRowSelection(artifact)}
1✔
301
              // this will be run after expansion + collapse and both need some time to fully settle
302
              // otherwise the measurements are off
303
              showRemoveArtifactDialog={setShowRemoveArtifactDialog}
304
            />
305
          );
306
        })}
307
      </div>
308
    </>
309
  );
310
};
311

312
export const ReleaseDetails = () => {
6✔
313
  const [showRemoveDialog, setShowRemoveArtifactDialog] = useState(false);
42✔
314
  const [confirmReleaseDeletion, setConfirmReleaseDeletion] = useState(false);
42✔
315
  const [selectedArtifact, setSelectedArtifact] = useState();
42✔
316

317
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
318
  const windowSize = useWindowSize();
42✔
319
  const creationRef = useRef();
42✔
320
  const drawerRef = useRef();
42✔
321
  const navigate = useNavigate();
42✔
322
  const dispatch = useDispatch();
42✔
323
  const release = useSelector(getSelectedRelease);
42✔
324
  const existingTags = useSelector(getReleaseTags);
42✔
325
  const userCapabilities = useSelector(getUserCapabilities);
42✔
326

327
  const { name: releaseName, artifacts = [] } = release;
42✔
328

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

331
  const copyLinkToClipboard = () => {
42✔
332
    const location = window.location.href.substring(0, window.location.href.indexOf('/releases'));
×
333
    copy(`${location}${generateReleasesPath({ pageState: { selectedRelease: releaseName } })}`);
×
334
    dispatch(setSnackbar('Link copied to clipboard'));
×
335
  };
336

337
  const onCloseClick = () => dispatch(selectRelease());
42✔
338

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

341
  const onToggleReleaseDeletion = () => setConfirmReleaseDeletion(toggle);
42✔
342

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

345
  const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo({ name: releaseName, info: { notes } })), [dispatch, releaseName]);
42✔
346

347
  const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags({ name: releaseName, tags })), [dispatch, releaseName]);
42✔
348

349
  return (
42✔
350
    <Drawer anchor="right" open={!!releaseName} onClose={onCloseClick} PaperProps={{ style: { minWidth: '60vw' }, ref: drawerRef }}>
351
      <DrawerTitle
352
        title={
353
          <>
354
            Release information for <i className="margin-left-small margin-right-small">{releaseName}</i>
355
          </>
356
        }
357
        onLinkCopy={copyLinkToClipboard}
358
        preCloser={
359
          <div className="muted margin-right flexbox">
360
            <div className="margin-right-small">Last modified:</div>
361
            <RelativeTime updateTime={release.modified} />
362
          </div>
363
        }
364
        onClose={onCloseClick}
365
      />
366
      <Divider className="margin-bottom" />
367
      <ReleaseNotes onChange={onReleaseNotesChanged} release={release} />
368
      <ReleaseTags existingTags={existingTags} onChange={onTagSelectionChanged} release={release} />
369
      <ArtifactsList
370
        artifacts={artifacts}
371
        selectedArtifact={selectedArtifact}
372
        setSelectedArtifact={setSelectedArtifact}
373
        setShowRemoveArtifactDialog={setShowRemoveArtifactDialog}
374
      />
375
      <RemoveArtifactDialog
376
        artifact={selectedArtifact}
377
        open={!!showRemoveDialog}
378
        onCancel={() => setShowRemoveArtifactDialog(false)}
1✔
379
        onRemove={() => onRemoveArtifact(selectedArtifact)}
×
380
      />
381
      <RemoveArtifactDialog open={!!confirmReleaseDeletion} onRemove={onDeleteRelease} onCancel={onToggleReleaseDeletion} release={release} />
382
      <ReleaseQuickActions
383
        actionCallbacks={{ onCreateDeployment, onDeleteRelease: onToggleReleaseDeletion }}
384
        innerRef={creationRef}
385
        userCapabilities={userCapabilities}
386
      />
387
    </Drawer>
388
  );
389
};
390

391
export default ReleaseDetails;
392

393
export const DeleteReleasesConfirmationDialog = ({ onClose, onSubmit }) => (
6✔
394
  <Dialog open={true}>
×
395
    <DialogTitle>Delete releases?</DialogTitle>
396
    <DialogContent style={{ overflow: 'hidden' }}>All releases artifacts will be deleted. Are you sure you want to delete these releases ?</DialogContent>
397
    <DialogActions>
398
      <Button style={{ marginRight: 10 }} onClick={onClose}>
399
        Cancel
400
      </Button>
401
      <Button variant="contained" color="primary" onClick={onSubmit}>
402
        Delete
403
      </Button>
404
    </DialogActions>
405
  </Dialog>
406
);
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