1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-30 22:21:13 +03:00

Adds Application Token List with delete functionality

This commit is contained in:
Alex Corey 2020-06-30 17:55:14 -04:00
parent d1f9f4dc86
commit a9d4046ec5
10 changed files with 547 additions and 5 deletions

View File

@ -24,6 +24,7 @@ import Roles from './models/Roles';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
import Tokens from './models/Tokens';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
@ -58,6 +59,7 @@ const RolesAPI = new Roles();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
const TokensAPI = new Tokens();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
@ -93,6 +95,7 @@ export {
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,
TokensAPI,
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,

View File

@ -5,6 +5,12 @@ class Applications extends Base {
super(http);
this.baseUrl = '/api/v2/applications/';
}
readTokens(appId, params) {
return this.http.get(`${this.baseUrl}${appId}/tokens/`, {
params,
});
}
}
export default Applications;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Tokens extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/tokens/';
}
}
export default Tokens;

View File

@ -15,9 +15,9 @@ import { Card, PageSection } from '@patternfly/react-core';
import useRequest from '../../../util/useRequest';
import { ApplicationsAPI } from '../../../api';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import ApplicationEdit from '../ApplicationEdit';
import ApplicationDetails from '../ApplicationDetails';
import ApplicationTokens from '../ApplicationTokens';
import RoutedTabs from '../../../components/RoutedTabs';
function Application({ setBreadcrumb, i18n }) {
@ -82,6 +82,7 @@ function Application({ setBreadcrumb, i18n }) {
if (pathname.endsWith('edit')) {
cardHeader = null;
}
if (!isLoading && error) {
return (
<PageSection>
@ -101,10 +102,6 @@ function Application({ setBreadcrumb, i18n }) {
);
}
if (isLoading) {
return <ContentLoading />;
}
return (
<PageSection>
<Card>
@ -131,6 +128,9 @@ function Application({ setBreadcrumb, i18n }) {
clientTypeOptions={clientTypeOptions}
/>
</Route>
<Route path="/applications/:id/tokens">
<ApplicationTokens application={application} />
</Route>
</>
)}
</Switch>

View File

@ -0,0 +1,163 @@
import React, { useCallback, useEffect } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import PaginatedDataList, {
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { TokensAPI, ApplicationsAPI } from '../../../api';
import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import ApplicationTokenListItem from './ApplicationTokenListItem';
import DatalistToolbar from '../../../components/DataListToolbar';
const QS_CONFIG = getQSConfig('applications', {
page: 1,
page_size: 20,
order_by: 'user__username',
});
function ApplicationTokenList({ i18n }) {
const { id } = useParams();
const location = useLocation();
const {
error,
isLoading,
result: { tokens, itemCount },
request: fetchTokens,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const {
data: { results, count },
} = await ApplicationsAPI.readTokens(id, params);
const modifiedResults = results.map(result => {
result.summary_fields = {
user: result.summary_fields.user,
application: result.summary_fields.application,
user_capabilities: { delete: true },
};
result.name = result.summary_fields.user?.username;
return result;
});
return { tokens: modifiedResults, itemCount: count };
}, [id, location.search]),
{ tokens: [], itemCount: 0 }
);
useEffect(() => {
fetchTokens();
}, [fetchTokens]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
tokens
);
const {
isLoading: deleteLoading,
deletionError,
deleteItems: handleDeleteApplications,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
await Promise.all(
selected.map(({ id: tokenId }) => TokensAPI.destroy(tokenId))
);
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchTokens,
}
);
const handleDelete = async () => {
await handleDeleteApplications();
setSelected([]);
};
return (
<>
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading || deleteLoading}
items={tokens}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Tokens`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'user__username',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'user__username',
},
{
name: i18n._(t`Scope`),
key: 'scope',
},
{
name: i18n._(t`Expiration`),
key: 'expires',
},
{
name: i18n._(t`Created`),
key: 'created',
},
{
name: i18n._(t`Modified`),
key: 'modified',
},
]}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
showExpandCollapse
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...tokens] : [])
}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Tokens`)}
/>,
]}
/>
)}
renderItem={token => (
<ApplicationTokenListItem
key={token.id}
value={token.name}
token={token}
detailUrl={`/users/${token.summary_fields.user.id}/details`}
onSelect={() => handleSelect(token)}
isSelected={selected.some(row => row.id === token.id)}
/>
)}
/>
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more tokens.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</>
);
}
export default withI18n()(ApplicationTokenList);

View File

@ -0,0 +1,193 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { ApplicationsAPI, TokensAPI } from '../../../api';
import ApplicationTokenList from './ApplicationTokenList';
jest.mock('../../../api/models/Applications');
jest.mock('../../../api/models/Tokens');
const tokens = {
data: {
results: [
{
id: 2,
type: 'o_auth2_access_token',
url: '/api/v2/tokens/2/',
related: {
user: '/api/v2/users/1/',
application: '/api/v2/applications/3/',
activity_stream: '/api/v2/tokens/2/activity_stream/',
},
summary_fields: {
user: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
application: {
id: 3,
name: 'hg',
},
},
created: '2020-06-23T19:56:38.422053Z',
modified: '2020-06-23T19:56:38.441353Z',
description: 'cdfsg',
user: 1,
token: '************',
refresh_token: '************',
application: 3,
expires: '3019-10-25T19:56:38.395635Z',
scope: 'read',
},
{
id: 3,
type: 'o_auth2_access_token',
url: '/api/v2/tokens/3/',
related: {
user: '/api/v2/users/1/',
application: '/api/v2/applications/3/',
activity_stream: '/api/v2/tokens/3/activity_stream/',
},
summary_fields: {
user: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
application: {
id: 3,
name: 'hg',
},
},
created: '2020-06-23T19:56:50.536169Z',
modified: '2020-06-23T19:56:50.549521Z',
description: 'fgds',
user: 1,
token: '************',
refresh_token: '************',
application: 3,
expires: '3019-10-25T19:56:50.529306Z',
scope: 'write',
},
],
count: 2,
},
};
describe('<ApplicationTokenList/>', () => {
let wrapper;
test('should mount properly', async () => {
ApplicationsAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<ApplicationTokenList />);
});
await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0);
});
test('should have data fetched and render 2 rows', async () => {
ApplicationsAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<ApplicationTokenList />);
});
await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0);
expect(wrapper.find('ApplicationTokenListItem').length).toBe(2);
expect(ApplicationsAPI.readTokens).toBeCalled();
});
test('should delete item successfully', async () => {
ApplicationsAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<ApplicationTokenList />);
});
waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0);
wrapper
.find('input#select-token-2')
.simulate('change', tokens.data.results[0]);
wrapper.update();
expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true);
await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
);
wrapper.update();
await act(async () =>
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
);
expect(TokensAPI.destroy).toBeCalledWith(tokens.data.results[0].id);
});
test('should throw content error', async () => {
ApplicationsAPI.readTokens.mockRejectedValue(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/applications/',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper = mountWithContexts(<ApplicationTokenList />);
});
await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
test('should render deletion error modal', async () => {
TokensAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/tokens/',
},
data: 'An error occurred',
},
})
);
ApplicationsAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<ApplicationTokenList />);
});
waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0);
wrapper.find('input#select-token-2').simulate('change', 'a');
wrapper.update();
expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true);
await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
);
wrapper.update();
await act(async () =>
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
test('should not render add button', async () => {
ApplicationsAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<ApplicationTokenList />);
});
waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
});

View File

@ -0,0 +1,69 @@
import React from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
} from '@patternfly/react-core';
import styled from 'styled-components';
import { Token } from '../../../types';
import { formatDateString } from '../../../util/dates';
import { toTitleCase } from '../../../util/strings';
import DataListCell from '../../../components/DataListCell';
const Label = styled.b`
margin-right: 20px;
`;
function ApplicationTokenListItem({
token,
isSelected,
onSelect,
detailUrl,
i18n,
}) {
const labelId = `check-action-${token.id}`;
return (
<DataListItem key={token.id} aria-labelledby={labelId} id={`${token.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-token-${token.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider" aria-label={i18n._(t`token name`)}>
<Link to={`${detailUrl}`}>
<b>{token.summary_fields.user.username}</b>
</Link>
</DataListCell>,
<DataListCell key="scope" aria-label={i18n._(t`scope`)}>
<Label>{i18n._(t`Scope`)}</Label>
<span>{toTitleCase(token.scope)}</span>
</DataListCell>,
<DataListCell key="expiration" aria-label={i18n._(t`expiration`)}>
<Label>{i18n._(t`Expiration`)}</Label>
<span>{formatDateString(token.expires)}</span>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
ApplicationTokenListItem.propTypes = {
token: Token.isRequired,
detailUrl: string.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(ApplicationTokenListItem);

View File

@ -0,0 +1,90 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ApplicationTokenListItem from './ApplicationTokenListItem';
describe('<ApplicationTokenListItem/>', () => {
let wrapper;
const token = {
id: 2,
type: 'o_auth2_access_token',
url: '/api/v2/tokens/2/',
related: {
user: '/api/v2/users/1/',
application: '/api/v2/applications/3/',
activity_stream: '/api/v2/tokens/2/activity_stream/',
},
summary_fields: {
user: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
application: {
id: 3,
name: 'hg',
},
},
created: '2020-06-23T19:56:38.422053Z',
modified: '2020-06-23T19:56:38.441353Z',
description: 'cdfsg',
user: 1,
token: '************',
refresh_token: '************',
application: 3,
expires: '3019-10-25T19:56:38.395635Z',
scope: 'read',
};
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ApplicationTokenListItem
token={token}
detailUrl="/users/2/details"
isSelected={false}
onSelect={() => {}}
/>
);
});
expect(wrapper.find('ApplicationTokenListItem').length).toBe(1);
});
test('should render the proper data', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ApplicationTokenListItem
token={token}
detailUrl="/users/2/details"
isSelected={false}
onSelect={() => {}}
/>
);
});
expect(wrapper.find('DataListCell[aria-label="token name"]').text()).toBe(
'admin'
);
expect(wrapper.find('DataListCell[aria-label="scope"]').text()).toBe(
'ScopeRead'
);
expect(wrapper.find('DataListCell[aria-label="expiration"]').text()).toBe(
'Expiration10/25/3019, 7:56:38 PM'
);
expect(wrapper.find('input#select-token-2').prop('checked')).toBe(false);
});
test('should be checked', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ApplicationTokenListItem
token={token}
detailUrl="/users/2/details"
isSelected
onSelect={() => {}}
/>
);
});
expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true);
});
});

View File

@ -0,0 +1 @@
export { default } from './ApplicationTokenList';

View File

@ -234,6 +234,13 @@ export const Team = shape({
organization: number,
});
export const Token = shape({
id: number.isRequired,
expires: string.isRequired,
summary_fields: shape({}),
scope: string.isRequired,
});
export const User = shape({
id: number.isRequired,
type: oneOf(['user']),