Merge pull request #7724 from AlexSCorey/7434-UserTokensDelete

Adds delete functionality to user tokens list

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-08-03 18:14:28 +00:00
committed by GitHub
2 changed files with 217 additions and 78 deletions

View File

@@ -5,11 +5,14 @@ import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import useRequest from '../../../util/useRequest'; import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { UsersAPI } from '../../../api'; import { UsersAPI, TokensAPI } from '../../../api';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import UserTokensListItem from './UserTokenListItem'; import UserTokensListItem from './UserTokenListItem';
const QS_CONFIG = getQSConfig('user', { const QS_CONFIG = getQSConfig('user', {
@@ -54,84 +57,127 @@ function UserTokenList({ i18n }) {
tokens tokens
); );
const {
isLoading: isDeleteLoading,
deleteItems: deleteTokens,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(({ id: tokenId }) => TokensAPI.destroy(tokenId))
);
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchTokens,
}
);
const handleDelete = async () => {
await deleteTokens();
setSelected([]);
};
const canAdd = true; const canAdd = true;
return ( return (
<PaginatedDataList <>
contentError={error} <PaginatedDataList
hasContentLoading={isLoading} contentError={error}
items={tokens} hasContentLoading={isLoading || isDeleteLoading}
itemCount={itemCount} items={tokens}
pluralizedItemName={i18n._(t`Tokens`)} itemCount={itemCount}
qsConfig={QS_CONFIG} pluralizedItemName={i18n._(t`Tokens`)}
onRowClick={handleSelect} qsConfig={QS_CONFIG}
toolbarSearchColumns={[ onRowClick={handleSelect}
{ toolbarSearchColumns={[
name: i18n._(t`Name`), {
key: 'application__name', name: i18n._(t`Name`),
isDefault: true, key: 'application__name',
}, isDefault: true,
{ },
name: i18n._(t`Description`), {
key: 'description', name: i18n._(t`Description`),
}, key: 'description',
]} },
toolbarSortColumns={[ ]}
{ toolbarSortColumns={[
name: i18n._(t`Name`), {
key: 'application__name', name: i18n._(t`Name`),
}, key: 'application__name',
{ },
name: i18n._(t`Scope`), {
key: 'scope', name: i18n._(t`Scope`),
}, key: 'scope',
{ },
name: i18n._(t`Expires`), {
key: 'expires', name: i18n._(t`Expires`),
}, key: 'expires',
{ },
name: i18n._(t`Created`), {
key: 'created', name: i18n._(t`Created`),
}, key: 'created',
{ },
name: i18n._(t`Modified`), {
key: 'modified', name: i18n._(t`Modified`),
}, key: 'modified',
]} },
renderToolbar={props => ( ]}
<DataListToolbar renderToolbar={props => (
{...props} <DataListToolbar
showSelectAll {...props}
isAllSelected={isAllSelected} showSelectAll
qsConfig={QS_CONFIG} isAllSelected={isAllSelected}
onSelectAll={isSelected => setSelected(isSelected ? [...tokens] : [])} qsConfig={QS_CONFIG}
additionalControls={[ onSelectAll={isSelected =>
...(canAdd setSelected(isSelected ? [...tokens] : [])
? [ }
<ToolbarAddButton additionalControls={[
key="add" ...(canAdd
linkTo={`${location.pathname}/add`} ? [
/>, <ToolbarAddButton
] key="add"
: []), linkTo={`${location.pathname}/add`}
]} />,
/> ]
: []),
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`User tokens`)}
/>,
]}
/>
)}
renderItem={token => (
<UserTokensListItem
key={token.id}
token={token}
onSelect={() => {
handleSelect(token);
}}
isSelected={selected.some(row => row.id === token.id)}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${location.pathname}/add`} />
) : null
}
/>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more user tokens.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)} )}
renderItem={token => ( </>
<UserTokensListItem
key={token.id}
token={token}
onSelect={() => {
handleSelect(token);
}}
isSelected={selected.some(row => row.id === token.id)}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${location.pathname}/add`} />
) : null
}
/>
); );
} }

View File

@@ -4,10 +4,11 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import { UsersAPI } from '../../../api'; import { UsersAPI, TokensAPI } from '../../../api';
import UserTokenList from './UserTokenList'; import UserTokenList from './UserTokenList';
jest.mock('../../../api/models/Users'); jest.mock('../../../api/models/Users');
jest.mock('../../../api/models/Tokens');
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@@ -162,4 +163,96 @@ describe('<UserTokenList />', () => {
wrapper.find('DataListCheck[id="select-token-3"]').props().checked wrapper.find('DataListCheck[id="select-token-3"]').props().checked
).toBe(true); ).toBe(true);
}); });
test('delete button should be disabled', async () => {
UsersAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<UserTokenList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
});
test('should select and then delete item properly', async () => {
UsersAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<UserTokenList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
await act(async () => {
wrapper
.find('DataListCheck[aria-labelledby="check-action-3"]')
.prop('onChange')(tokens.data.results[0]);
});
wrapper.update();
expect(
wrapper
.find('DataListCheck[aria-labelledby="check-action-3"]')
.prop('checked')
).toBe(true);
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
false
);
await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
);
wrapper.update();
await act(async () => expect(wrapper.find('AlertModal').length).toBe(1));
await act(async () =>
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
);
wrapper.update();
expect(TokensAPI.destroy).toHaveBeenCalledWith(3);
});
test('should select and then delete item properly', async () => {
UsersAPI.readTokens.mockResolvedValue(tokens);
TokensAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/tokens',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(async () => {
wrapper = mountWithContexts(<UserTokenList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
await act(async () => {
wrapper
.find('DataListCheck[aria-labelledby="check-action-3"]')
.prop('onChange')(tokens.data.results[0]);
});
wrapper.update();
expect(
wrapper
.find('DataListCheck[aria-labelledby="check-action-3"]')
.prop('checked')
).toBe(true);
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
false
);
await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
);
wrapper.update();
await act(async () => expect(wrapper.find('AlertModal').length).toBe(1));
await act(async () =>
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
);
wrapper.update();
expect(TokensAPI.destroy).toHaveBeenCalledWith(3);
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
}); });