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

mendersoftware / gui / 1081664682

22 Nov 2023 02:11PM UTC coverage: 82.798% (-17.2%) from 99.964%
1081664682

Pull #4214

gitlab-ci

tranchitella
fix: Fixed the infinite page redirects when the back button is pressed

Remove the location and navigate from the useLocationParams.setValue callback
dependencies as they change the set function that is presented in other
useEffect dependencies. This happens when the back button is clicked, which
leads to the location changing infinitely.

Changelog: Title
Ticket: MEN-6847
Ticket: MEN-6796

Signed-off-by: Ihor Aleksandrychiev <ihor.aleksandrychiev@northern.tech>
Signed-off-by: Fabio Tranchitella <fabio.tranchitella@northern.tech>
Pull Request #4214: fix: Fixed the infinite page redirects when the back button is pressed

4319 of 6292 branches covered (0.0%)

8332 of 10063 relevant lines covered (82.8%)

191.0 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