Skip to content

Commit

Permalink
fix(gui): fixed an issue that would prevent deleting & tagging releas…
Browse files Browse the repository at this point in the history
…es in the release overview

Ticket: MEN-7960
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
  • Loading branch information
mzedel committed Jan 23, 2025
1 parent 5e20bff commit 16b2628
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 86 deletions.
40 changes: 13 additions & 27 deletions frontend/src/js/components/releases/ReleaseDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import storeActions from '@northern.tech/store/actions';
import { DEPLOYMENT_ROUTES } from '@northern.tech/store/constants';
import { getReleaseListState, getReleaseTags, getSelectedRelease, getUserCapabilities } from '@northern.tech/store/selectors';
import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '@northern.tech/store/thunks';
import { customSort, formatTime, toggle } from '@northern.tech/utils/helpers';
import { customSort, formatTime, isEmpty, toggle } from '@northern.tech/utils/helpers';
import { generateReleasesPath } from '@northern.tech/utils/locationutils';
import useWindowSize from '@northern.tech/utils/resizehook';
import copy from 'copy-to-clipboard';
Expand Down Expand Up @@ -93,19 +93,20 @@ const defaultActions = [
{
action: ({ onCreateDeployment, selection }) => onCreateDeployment(selection),
icon: <ReplayIcon />,
isApplicable: ({ userCapabilities: { canDeploy }, selectedSingleRelease }) => canDeploy && selectedSingleRelease,
isApplicable: ({ userCapabilities: { canDeploy }, selectedSingleRelease, selectedRows }) =>
canDeploy && (selectedSingleRelease || selectedRows.length === 1),
key: 'deploy',
title: () => 'Create a deployment for this release'
},
{
action: ({ onTagRelease, selectedReleases }) => onTagRelease(selectedReleases),
action: ({ onTagRelease, selection }) => onTagRelease(selection),
icon: <LabelOutlinedIcon />,
isApplicable: ({ userCapabilities: { canManageReleases }, selectedSingleRelease }) => canManageReleases && !selectedSingleRelease,
key: 'tag',
title: pluralized => `Tag ${pluralized}`
},
{
action: ({ onDeleteRelease, selection, selectedReleases }) => onDeleteRelease(selection || selectedReleases),
action: ({ onDeleteRelease, selection }) => onDeleteRelease(selection),
icon: <HighlightOffOutlinedIcon className="red" />,
isApplicable: ({ userCapabilities: { canManageReleases } }) => canManageReleases,
key: 'delete',
Expand Down Expand Up @@ -136,36 +137,26 @@ const useStyles = makeStyles()(theme => ({
}
}));

export const ReleaseQuickActions = ({ actionCallbacks, innerRef, userCapabilities, releases }) => {
export const ReleaseQuickActions = ({ actionCallbacks, innerRef }) => {
const [showActions, setShowActions] = useState(false);
const [selectedReleases, setSelectedReleases] = useState([]);
const { classes } = useStyles();
const { selection: selectedRows } = useSelector(getReleaseListState);
const selectedRelease = useSelector(getSelectedRelease);

useEffect(() => {
if (releases) {
setSelectedReleases(selectedRows.map(row => releases[row]));
}
}, [releases, selectedRows, setSelectedReleases]);
const userCapabilities = useSelector(getUserCapabilities);

const actions = useMemo(() => {
return Object.values(defaultActions).reduce((accu, action) => {
if (action.isApplicable({ userCapabilities, selectedSingleRelease: !!selectedRelease })) {
if (action.isApplicable({ userCapabilities, selectedSingleRelease: !isEmpty(selectedRelease), selectedRows })) {
accu.push(action);
}
return accu;
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(userCapabilities), selectedRelease]);
}, [JSON.stringify(userCapabilities), selectedRelease, selectedRows]);

const handleShowActions = () => {
setShowActions(!showActions);
};
const handleShowActions = () => setShowActions(toggle);

const handleClickAway = () => {
setShowActions(false);
};
const handleClickAway = () => setShowActions(false);

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

Expand All @@ -181,7 +172,7 @@ export const ReleaseQuickActions = ({ actionCallbacks, innerRef, userCapabilitie
icon={action.icon}
tooltipTitle={action.title(pluralized)}
tooltipOpen
onClick={() => action.action({ ...actionCallbacks, selection: selectedRelease, selectedReleases })}
onClick={() => action.action({ ...actionCallbacks, selection: selectedRows })}
/>
))}
</SpeedDial>
Expand Down Expand Up @@ -326,7 +317,6 @@ export const ReleaseDetails = () => {
const dispatch = useDispatch();
const release = useSelector(getSelectedRelease);
const existingTags = useSelector(getReleaseTags);
const userCapabilities = useSelector(getUserCapabilities);

const { name: releaseName, artifacts = [] } = release;

Expand Down Expand Up @@ -383,11 +373,7 @@ export const ReleaseDetails = () => {
onRemove={() => onRemoveArtifact(selectedArtifact)}
/>
<RemoveArtifactDialog open={!!confirmReleaseDeletion} onRemove={onDeleteRelease} onCancel={onToggleReleaseDeletion} release={release} />
<ReleaseQuickActions
actionCallbacks={{ onCreateDeployment, onDeleteRelease: onToggleReleaseDeletion }}
innerRef={creationRef}
userCapabilities={userCapabilities}
/>
<ReleaseQuickActions actionCallbacks={{ onCreateDeployment, onDeleteRelease: onToggleReleaseDeletion }} innerRef={creationRef} />
</Drawer>
);
};
Expand Down
85 changes: 57 additions & 28 deletions frontend/src/js/components/releases/Releases.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
// limitations under the License.
import React from 'react';

import GeneralApi from '@northern.tech/store/api/general-api';
import { TIMEOUTS } from '@northern.tech/store/commonConstants';
import { apiUrl } from '@northern.tech/store/constants';
import * as ReleaseActions from '@northern.tech/store/releasesSlice/thunks';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Expand All @@ -33,34 +37,38 @@ describe('Releases Component', () => {
});
});

it('works as expected', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const preloadedState = {
...defaultState,
releases: {
...defaultState.releases,
selectedArtifact: defaultState.releases.byId.r1.artifacts[0],
selectedRelease: defaultState.releases.byId.r1.name
}
};
const ui = <Releases />;
const { rerender } = render(ui, { preloadedState });
await waitFor(() => expect(screen.queryAllByText(defaultState.releases.byId.r1.name)[0]).toBeInTheDocument());
await user.click(screen.getAllByText(defaultState.releases.byId.r1.name)[0]);
await user.click(screen.getByText(/qemux/i));
expect(screen.queryByText(defaultState.releases.byId.r1.artifacts[0].description)).toBeVisible();
await user.click(screen.getByRole('button', { name: /Remove this/i }));
await waitFor(() => expect(screen.queryByRole('button', { name: /Cancel/i })).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Close/i }));
await waitFor(() => rerender(ui));
await act(async () => {
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
expect(screen.queryByText(/release information/i)).toBeFalsy();
}, 20000);
it(
'works as expected',
async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const preloadedState = {
...defaultState,
releases: {
...defaultState.releases,
selectedArtifact: defaultState.releases.byId.r1.artifacts[0],
selectedRelease: defaultState.releases.byId.r1.name
}
};
const ui = <Releases />;
const { rerender } = render(ui, { preloadedState });
await waitFor(() => expect(screen.queryAllByText(defaultState.releases.byId.r1.name)[0]).toBeInTheDocument());
await user.click(screen.getAllByText(defaultState.releases.byId.r1.name)[0]);
await user.click(screen.getByText(/qemux/i));
expect(screen.queryByText(defaultState.releases.byId.r1.artifacts[0].description)).toBeVisible();
await user.click(screen.getByRole('button', { name: /Remove this/i }));
await waitFor(() => expect(screen.queryByRole('button', { name: /Cancel/i })).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Close/i }));
await waitFor(() => rerender(ui));
await act(async () => {
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
expect(screen.queryByText(/release information/i)).toBeFalsy();
},
TIMEOUTS.refreshDefault * 2
);
it('has working search handling as expected', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<Releases />);
Expand All @@ -73,4 +81,25 @@ describe('Releases Component', () => {
jest.runAllTicks();
});
});
it('can delete releases from the list', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const deleteReleasesSpy = jest.spyOn(ReleaseActions, 'removeReleases');
const deletionSpy = jest.spyOn(GeneralApi, 'delete');
const ui = <Releases />;
const { rerender, container } = render(ui);
await waitFor(() => expect(screen.queryAllByText(defaultState.releases.byId.r1.name)[0]).toBeInTheDocument());
await user.click(screen.getAllByRole('checkbox')[0]);
await waitFor(() => rerender(ui));
await user.click(container.querySelector('.MuiSpeedDial-fab'));
await user.click(screen.getByLabelText(/delete release/i));
await waitFor(() => screen.queryByText(/will be deleted/i));
await expect(screen.getByText(/will be deleted/i)).toBeVisible();
await user.click(screen.getByRole('button', { name: /delete/i }));
await act(async () => {
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
expect(deleteReleasesSpy).toHaveBeenCalledWith([defaultState.releases.byId.r1.name]);
expect(deletionSpy).toHaveBeenCalledWith(`${apiUrl.v1}/deployments/artifacts/${defaultState.releases.byId.r1.artifacts[0].id}`);
});
});
49 changes: 26 additions & 23 deletions frontend/src/js/components/releases/ReleasesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import Pagination from '@northern.tech/common-ui/Pagination';
import { RelativeTime } from '@northern.tech/common-ui/Time';
import storeActions from '@northern.tech/store/actions';
import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, canAccess as canShow } from '@northern.tech/store/constants';
import { getFeatures, getHasReleases, getReleaseListState, getReleasesList, getUserCapabilities } from '@northern.tech/store/selectors';
import { removeRelease, selectRelease, setReleasesListState } from '@northern.tech/store/thunks';
import { getFeatures, getHasReleases, getReleaseListState, getReleasesList, getSelectedReleases, getUserCapabilities } from '@northern.tech/store/selectors';
import { removeReleases, selectRelease, setReleasesListState } from '@northern.tech/store/thunks';

import { DeleteReleasesConfirmationDialog, ReleaseQuickActions } from './ReleaseDetails';
import AddTagsDialog from './dialogs/AddTags';
Expand Down Expand Up @@ -95,17 +95,27 @@ export const ReleasesList = ({ className = '', onFileUploadClick }) => {
const dropzoneRef = useRef();
const uploading = useSelector(state => state.app.uploading);
const releasesListState = useSelector(getReleaseListState);
const { selection: selectedRows } = releasesListState;
const { isLoading, page = defaultPage, perPage = defaultPerPage, searchTerm, sort = {}, searchTotal, selectedTags = [], total, type } = releasesListState;
const {
isLoading,
page = defaultPage,
perPage = defaultPerPage,
searchTerm,
sort = {},
searchTotal,
selection: selectedRows,
selectedTags = [],
total,
type
} = releasesListState;
const hasReleases = useSelector(getHasReleases);
const features = useSelector(getFeatures);
const releases = useSelector(getReleasesList);
const userCapabilities = useSelector(getUserCapabilities);
const selectedReleases = useSelector(getSelectedReleases);
const dispatch = useDispatch();
const { classes } = useStyles();
const [addTagsDialog, setAddTagsDialog] = useState(false);
const [deleteDialogConfirmation, setDeleteDialogConfirmation] = useState(false);
const [selectedReleases, setSelectedReleases] = useState([]);

const { canUploadReleases } = userCapabilities;
const { key: attribute, direction } = sort;
Expand Down Expand Up @@ -144,25 +154,21 @@ export const ReleasesList = ({ className = '', onFileUploadClick }) => {
);

const onDeleteRelease = releases => {
setSelectedReleases(releases);
onSelectionChange(releases);
setDeleteDialogConfirmation(true);
};

const deleteReleases = () => {
setDeleteDialogConfirmation(false);
dispatch(setReleasesListState({ loading: true }))
.then(() => {
const deleteRequests = selectedReleases.reduce((accu, release) => {
accu.push(dispatch(removeRelease(release.name)));
return accu;
}, []);
return Promise.all(deleteRequests);
})
.then(() => onSelectionChange([]));
};
const onSelectionChange = useCallback((selection: number[] = []) => dispatch(setReleasesListState({ selection })), [dispatch]);

const deleteReleases = useCallback(() => {
dispatch(removeReleases(selectedReleases.map(({ name }) => name))).then(() => {
setDeleteDialogConfirmation(false);
onSelectionChange([]);
});
}, [dispatch, onSelectionChange, selectedReleases]);

const onTagRelease = releases => {
setSelectedReleases(releases);
onSelectionChange(releases);
setAddTagsDialog(true);
};

Expand All @@ -186,9 +192,6 @@ export const ReleasesList = ({ className = '', onFileUploadClick }) => {
);
}

const onSelectionChange = (selection = []) => {
dispatch(setReleasesListState({ selection }));
};
return (
<div className={className}>
{isLoading === undefined ? (
Expand Down Expand Up @@ -218,7 +221,7 @@ export const ReleasesList = ({ className = '', onFileUploadClick }) => {
/>
<Loader show={isLoading} small />
</div>
{selectedRows?.length > 0 && <ReleaseQuickActions actionCallbacks={actionCallbacks} userCapabilities={userCapabilities} releases={releases} />}
{selectedReleases?.length > 0 && <ReleaseQuickActions actionCallbacks={actionCallbacks} />}
{addTagsDialog && <AddTagsDialog selectedReleases={selectedReleases} onClose={() => setAddTagsDialog(false)} />}
{deleteDialogConfirmation && <DeleteReleasesConfirmationDialog onClose={() => setDeleteDialogConfirmation(false)} onSubmit={deleteReleases} />}
</>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/js/store/releasesSlice/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ export const getHasReleases = createSelector(
);

export const getSelectedRelease = createSelector([getReleasesById, getSelectedReleaseId], (byId, id) => byId[id] ?? {});

export const getSelectedReleases = createSelector([getReleaseListState, getReleasesList], ({ selection }, releases) => selection.map(index => releases[index]));
3 changes: 3 additions & 0 deletions frontend/src/js/store/releasesSlice/thunks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
selectRelease,
setReleaseTags,
setReleasesListState,
setSingleReleaseTags,
updateReleaseInfo,
uploadArtifact
} from './thunks';
Expand Down Expand Up @@ -358,10 +359,12 @@ describe('release actions', () => {
const store = mockStore({ ...defaultState });
const expectedActions = [
{ type: setReleaseTags.pending.type },
{ type: setSingleReleaseTags.pending.type },
{
type: actions.receiveRelease.type,
payload: { ...defaultState.releases.byId.r1, tags: ['foo', 'bar'] }
},
{ type: setSingleReleaseTags.fulfilled.type },
{ type: appActions.setSnackbar.type, payload: 'Release tags were set successfully.' },
{ type: setReleaseTags.fulfilled.type }
];
Expand Down
36 changes: 28 additions & 8 deletions frontend/src/js/store/releasesSlice/thunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ export const removeRelease = createAsyncThunk(`${sliceName}/removeRelease`, (rel
Promise.all(getReleasesById(getState())[releaseId].artifacts.map(({ id }) => dispatch(removeArtifact(id)))).then(() => dispatch(selectRelease()))
);

export const removeReleases = createAsyncThunk(`${sliceName}/removeReleases`, (releaseIds, { dispatch, getState }) => {
const deleteRequests = releaseIds.reduce((accu, releaseId) => {
const releaseArtifacts = getReleasesById(getState())[releaseId].artifacts;
accu.push(releaseArtifacts.map(({ id }) => dispatch(removeArtifact(id))));
return accu;
}, []);
return Promise.all(deleteRequests);
});

export const selectRelease = createAsyncThunk(`${sliceName}/selectRelease`, (release, { dispatch }) => {
const name = release ? release.name || release : null;
let tasks = [dispatch(actions.selectedRelease(name))];
Expand Down Expand Up @@ -358,17 +367,28 @@ export const updateReleaseInfo = createAsyncThunk(`${sliceName}/updateReleaseInf
})
);

export const setReleaseTags = createAsyncThunk(`${sliceName}/setReleaseTags`, ({ name, tags = [] }, { dispatch, getState }) =>
GeneralApi.put(`${deploymentsApiUrlV2}/deployments/releases/${name}/tags`, tags)
export const setSingleReleaseTags = createAsyncThunk(`${sliceName}/setSingleReleaseTags`, ({ name, tags }, { dispatch, getState }) =>
GeneralApi.put(`${deploymentsApiUrlV2}/deployments/releases/${name}/tags`, tags).then(() =>
Promise.resolve(dispatch(actions.receiveRelease({ ...getReleasesById(getState())[name], name, tags })))
)
);

export const setReleaseTags = createAsyncThunk(`${sliceName}/setReleaseTags`, ({ name, tags = [] }, { dispatch }) =>
dispatch(setSingleReleaseTags({ name, tags }))
.catch(err => commonErrorHandler(err, `Release tags couldn't be set.`, dispatch))
.then(() => {
return Promise.all([
dispatch(actions.receiveRelease({ ...getReleasesById(getState())[name], name, tags })),
dispatch(setSnackbar('Release tags were set successfully.', TIMEOUTS.fiveSeconds, ''))
]);
})
.then(() => Promise.resolve(dispatch(setSnackbar('Release tags were set successfully.', TIMEOUTS.fiveSeconds, ''))))
);

export const setReleasesTags = createAsyncThunk(`${sliceName}/setReleasesTags`, ({ releases, tags = [] }, { dispatch }) => {
const addRequests = releases.reduce((accu, release) => {
accu.push(dispatch(setSingleReleaseTags({ name: release.name, tags: [...new Set([...release.tags, ...tags])] })));
return accu;
}, []);
return Promise.all(addRequests)
.catch(err => commonErrorHandler(err, `Releases couldn't be tagged.`, dispatch))
.then(() => Promise.resolve(dispatch(setSnackbar('Releases were tagged successfully.', TIMEOUTS.fiveSeconds, ''))));
});

export const getExistingReleaseTags = createAsyncThunk(`${sliceName}/getReleaseTags`, (_, { dispatch }) =>
GeneralApi.get(`${deploymentsApiUrlV2}/releases/all/tags`)
.catch(err => commonErrorHandler(err, `Existing release tags couldn't be retrieved.`, dispatch))
Expand Down
Loading

0 comments on commit 16b2628

Please sign in to comment.