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

Adds Tokens List

This commit is contained in:
Alex Corey 2020-06-24 13:03:02 -04:00
parent 6e99b1cf85
commit 73bb539b16
11 changed files with 513 additions and 28 deletions

View File

@ -44,6 +44,12 @@ class Users extends Base {
readTeamsOptions(userId) {
return this.http.options(`${this.baseUrl}${userId}/teams/`);
}
readTokens(userId, params) {
return this.http.get(`${this.baseUrl}${userId}/tokens/`, {
params,
});
}
}
export default Users;

View File

@ -20,10 +20,10 @@ import UserDetail from './UserDetail';
import UserEdit from './UserEdit';
import UserOrganizations from './UserOrganizations';
import UserTeams from './UserTeams';
import UserTokens from './UserTokens';
import UserTokenList from './UserTokenList';
import UserAccessList from './UserAccess/UserAccessList';
function User({ i18n, setBreadcrumb }) {
function User({ i18n, setBreadcrumb, me }) {
const location = useLocation();
const match = useRouteMatch('/users/:id');
const userListUrl = `/users`;
@ -69,9 +69,16 @@ function User({ i18n, setBreadcrumb }) {
},
{ name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 3 },
{ name: i18n._(t`Tokens`), link: `${match.url}/tokens`, id: 4 },
];
if (me?.id === Number(match.params.id)) {
tabsArray.push({
name: i18n._(t`Tokens`),
link: `${match.url}/tokens`,
id: 4,
});
}
let showCardHeader = true;
if (['edit'].some(name => location.pathname.includes(name))) {
showCardHeader = false;
@ -93,6 +100,7 @@ function User({ i18n, setBreadcrumb }) {
</PageSection>
);
}
return (
<PageSection>
<Card>
@ -123,7 +131,7 @@ function User({ i18n, setBreadcrumb }) {
</Route>
)}
<Route path="/users/:id/tokens">
<UserTokens id={Number(match.params.id)} />
<UserTokenList id={Number(match.params.id)} />
</Route>
<Route key="not-found" path="*">
<ContentError isNotFound>

View File

@ -56,21 +56,24 @@ describe('<User />', () => {
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<User setBreadcrumb={() => {}} />, {
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/users/1',
path: '/users/1',
wrapper = mountWithContexts(
<User me={{ id: 1 }} setBreadcrumb={() => {}} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/users/1',
path: '/users/1',
},
},
},
},
},
});
}
);
});
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 6);
@ -78,6 +81,36 @@ describe('<User />', () => {
expect(wrapper.find('Tabs TabButton').length).toEqual(6);
});
test('should not now Tokens tab', async () => {
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
const history = createMemoryHistory({
initialEntries: ['/users/1'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<User me={{ id: 2 }} setBreadcrumb={() => {}} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/users/1',
path: '/users/1',
},
},
},
},
}
);
});
expect(wrapper.find('button[aria-label="Tokens"]').length).toBe(0);
});
test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/users/1/foobar'],

View File

@ -0,0 +1,146 @@
import React, { useCallback, useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import useSelected from '../../../util/useSelected';
import useRequest from '../../../util/useRequest';
import { UsersAPI } from '../../../api';
import DataListToolbar from '../../../components/DataListToolbar';
import UserTokensListItem from './UserTokenListItem';
const QS_CONFIG = getQSConfig('user', {
page: 1,
page_size: 20,
order_by: 'application__name',
});
function UserTokenList({ i18n }) {
const location = useLocation();
const { id } = useParams();
const {
error,
isLoading,
request: fetchTokens,
result: { tokens, itemCount },
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const {
data: { results, count },
} = await UsersAPI.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.username = 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 canAdd = true;
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={tokens}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Tokens`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'application__name',
isDefault: true,
},
{
name: i18n._(t`Description`),
key: 'description',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'application__name',
},
{
name: i18n._(t`Scope`),
key: 'scope',
},
{
name: i18n._(t`Expires`),
key: 'expires',
},
{
name: i18n._(t`Created`),
key: 'created',
},
{
name: i18n._(t`Modified`),
key: 'modified',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
qsConfig={QS_CONFIG}
onSelectAll={isSelected => setSelected(isSelected ? [...tokens] : [])}
additionalControls={[
...(canAdd
? [
<ToolbarAddButton
key="add"
linkTo={`${location.pathname}/add`}
/>,
]
: []),
<ToolbarDeleteButton
key="delete"
onDelete={() => {}}
itemsToDelete={selected}
pluralizedItemName="Tokens"
/>,
]}
/>
)}
renderItem={token => (
<UserTokensListItem
key={token.id}
token={token}
onSelect={() => {
handleSelect(token);
}}
detailUrl={`${location.pathname}/details`}
isSelected={selected.some(row => row.id === token.id)}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${location.pathname}/add`} />
) : null
}
/>
);
}
export default withI18n()(UserTokenList);

View File

@ -0,0 +1,172 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { UsersAPI } from '../../../api';
import UserTokenList from './UserTokenList';
jest.mock('../../../api/models/Users');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
search: '',
}),
useParams: () => ({
id: 1,
}),
}));
const tokens = {
data: {
results: [
{
id: 1,
type: 'o_auth2_access_token',
url: '/api/v2/tokens/1/',
related: {
user: '/api/v2/users/1/',
application: '/api/v2/applications/1/',
activity_stream: '/api/v2/tokens/1/activity_stream/',
},
summary_fields: {
user: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
application: {
id: 1,
name: 'app',
},
},
created: '2020-06-23T15:06:43.188634Z',
modified: '2020-06-23T15:06:43.224151Z',
description: '',
user: 1,
token: '************',
refresh_token: '************',
application: 1,
expires: '3019-10-25T15:06:43.182788Z',
scope: 'read',
},
{
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: 3,
},
};
describe('<UserTokenList />', () => {
let wrapper;
test('should mount properly, and fetch tokens', async () => {
UsersAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<UserTokenList />);
});
expect(UsersAPI.readTokens).toHaveBeenCalledWith(1, {
order_by: 'application__name',
page: 1,
page_size: 20,
});
expect(wrapper.find('UserTokenList').length).toBe(1);
});
test('edit 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 enable edit button', async () => {
UsersAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<UserTokenList />);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(
wrapper.find('DataListCheck[id="select-token-3"]').props().checked
).toBe(false);
await act(async () => {
wrapper.find('DataListCheck[id="select-token-3"]').invoke('onChange')(
true
);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-token-3"]').props().checked
).toBe(true);
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
false
);
});
});

View File

@ -0,0 +1,52 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
DataListItemCells,
DataListCheck,
DataListItemRow,
DataListItem,
} from '@patternfly/react-core';
import styled from 'styled-components';
import { formatDateStringUTC } from '../../../util/dates';
import DataListCell from '../../../components/DataListCell';
const Label = styled.b`
margin-right: 20px;
`;
function UserTokenListItem({ i18n, token, isSelected, detailUrl, onSelect }) {
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 aria-label={i18n._(t`name`)} key={token.id}>
<Link to={`${detailUrl}`}>
{token.summary_fields.application.name}
</Link>
</DataListCell>,
<DataListCell aria-label={i18n._(t`scope`)} key={token.scope}>
<Label>{i18n._(t`Scope`)}</Label>
{token.scope}
</DataListCell>,
<DataListCell aria-label={i18n._(t`expiration`)} key="expiration">
<Label>{i18n._(t`Expires`)}</Label>
{formatDateStringUTC(token.expires)}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
export default withI18n()(UserTokenListItem);

View File

@ -0,0 +1,73 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import UserTokenListItem from './UserTokenListItem';
const token = {
id: 1,
type: 'o_auth2_access_token',
url: '/api/v2/tokens/1/',
related: {
user: '/api/v2/users/1/',
application: '/api/v2/applications/1/',
activity_stream: '/api/v2/tokens/1/activity_stream/',
},
summary_fields: {
user: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
application: {
id: 1,
name: 'app',
},
},
created: '2020-06-23T15:06:43.188634Z',
modified: '2020-06-23T15:06:43.224151Z',
description: '',
user: 1,
token: '************',
refresh_token: '************',
application: 1,
expires: '3019-10-25T15:06:43.182788Z',
scope: 'read',
};
describe('<UserTokenListItem />', () => {
let wrapper;
test('should mount properly', async () => {
await act(async () => {
wrapper = mountWithContexts(<UserTokenListItem token={token} />);
});
expect(wrapper.find('UserTokenListItem').length).toBe(1);
});
test('should render proper data', async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserTokenListItem isSelected={false} token={token} />
);
});
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
expect(wrapper.find('PFDataListCell[aria-label="name"]').text()).toBe(
'app'
);
expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe(
'Scoperead'
);
expect(wrapper.find('PFDataListCell[aria-label="expiration"]').text()).toBe(
'Expires10/25/3019, 3:06:43 PM'
);
});
test('should be checked', async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserTokenListItem isSelected token={token} />
);
});
expect(wrapper.find('DataListCheck').prop('checked')).toBe(true);
});
});

View File

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

View File

@ -1,10 +0,0 @@
import React, { Component } from 'react';
import { CardBody } from '../../../components/Card';
class UserAdd extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default UserAdd;

View File

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

View File

@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import { Config } from '../../contexts/Config';
import UsersList from './UserList/UserList';
import UserAdd from './UserAdd/UserAdd';
@ -44,7 +45,11 @@ function Users({ i18n }) {
<UserAdd />
</Route>
<Route path={`${match.path}/:id`}>
<User setBreadcrumb={addUserBreadcrumb} />
<Config>
{({ me }) => (
<User setBreadcrumb={addUserBreadcrumb} me={me || {}} />
)}
</Config>
</Route>
<Route path={`${match.path}`}>
<UsersList />