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

Adds Teams Roles List and Disassociate functionality

This commit is contained in:
Alex Corey 2020-07-27 16:33:15 -04:00
parent 196368d89b
commit a070d57080
7 changed files with 666 additions and 5 deletions

View File

@ -28,6 +28,17 @@ class Teams extends Base {
readRoleOptions(teamId) {
return this.http.options(`${this.baseUrl}${teamId}/roles/`);
}
readUsersAccess(teamId, params) {
return this.http.get(`${this.baseUrl}${teamId}/access_list/`, {
params,
});
}
readUsersAccessOptions(teamId) {
return this.http.options(`${this.baseUrl}${teamId}/users/`);
}
}
export default Teams;

View File

@ -17,6 +17,7 @@ import TeamDetail from './TeamDetail';
import TeamEdit from './TeamEdit';
import { TeamsAPI } from '../../api';
import TeamAccessList from './TeamAccess';
import TeamUsersList from './TeamUsers';
function Team({ i18n, setBreadcrumb }) {
const [team, setTeam] = useState(null);
@ -51,8 +52,8 @@ function Team({ i18n, setBreadcrumb }) {
id: 99,
},
{ name: i18n._(t`Details`), link: `/teams/${id}/details`, id: 0 },
{ name: i18n._(t`Users`), link: `/teams/${id}/users`, id: 1 },
{ name: i18n._(t`Access`), link: `/teams/${id}/access`, id: 2 },
{ name: i18n._(t`Access`), link: `/teams/${id}/access`, id: 1 },
{ name: i18n._(t`Roles`), link: `/teams/${id}/roles`, id: 2 },
];
let showCardHeader = true;
@ -95,12 +96,12 @@ function Team({ i18n, setBreadcrumb }) {
</Route>
)}
{team && (
<Route path="/teams/:id/users">
<span>Coming soon :)</span>
<Route path="/teams/:id/access">
<TeamUsersList />
</Route>
)}
{team && (
<Route path="/teams/:id/access">
<Route path="/teams/:id/roles">
<TeamAccessList />
</Route>
)}

View File

@ -0,0 +1,102 @@
import 'styled-components/macro';
import React from 'react';
import { string, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListItemCells,
DataListItemRow,
Label as PFLabel,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import DataListCell from '../../../components/DataListCell';
import { User } from '../../../types';
function TeamUserListItem({ user, disassociateRole, detailUrl, i18n }) {
const labelId = `check-action-${user.id}`;
const Label = styled.b`
margin-right: 20px;
`;
const hasDirectRoles = user.summary_fields.direct_access.length > 0;
const hasIndirectRoles = user.summary_fields.indirect_access.length > 0;
return (
<DataListItem key={user.id} aria-labelledby={labelId} id={`${user.id}`}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell aria-label={i18n._(t`username`)} key="username">
<Link id={labelId} to={`${detailUrl}`}>
<b>{user.username}</b>
</Link>
</DataListCell>,
<DataListCell aria-label={i18n._(t`first name`)} key="first name">
{user.first_name && (
<>
<Label>{i18n._(t`First`)}</Label>
<span>{user.first_name}</span>
</>
)}
</DataListCell>,
<DataListCell aria-label={i18n._(t`last name`)} key="last name">
{user.last_name && (
<>
<Label>{i18n._(t`Last`)}</Label>
<span>{user.last}</span>
</>
)}
</DataListCell>,
<DataListCell aria-label={i18n._(t`roles`)} key="role">
{hasDirectRoles && (
<>
<Label>{i18n._(t`Roles`)}</Label>
<span>
{user.summary_fields.direct_access.map(role =>
role.role.name !== 'Read' ? (
<PFLabel
aria-label={role.role.name}
key={role.role.id}
role={role.role}
onClose={() => disassociateRole(role.role)}
>
{role.role.name}
</PFLabel>
) : null
)}
</span>
</>
)}
</DataListCell>,
<DataListCell
aria-label={i18n._(t`indirect role`)}
key="indirectRole"
>
{hasIndirectRoles && (
<>
<Label>{i18n._(t`Indirect Roles`)}</Label>
<span>
{user.summary_fields.indirect_access.map(role => (
<PFLabel key={role.role.id} credential={role.role}>
{role.role.name}
</PFLabel>
))}
</span>
</>
)}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
TeamUserListItem.propTypes = {
user: User.isRequired,
detailUrl: string.isRequired,
disassociateRole: func.isRequired,
};
export default withI18n()(TeamUserListItem);

View File

@ -0,0 +1,84 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import TeamUserListItem from './TeamUserListItem';
describe('<TeamUserListItem />', () => {
const user = {
id: 1,
name: 'Team 1',
summary_fields: {
direct_access: [
{
role: {
id: 40,
name: 'Member',
description: 'User is a member of the team',
resource_name: ' Team 1 Org 0',
resource_type: 'team',
related: {
team: '/api/v2/teams/1/',
},
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
],
indirect_access: [
{
role: {
id: 2,
name: 'Admin',
description: 'Can manage all aspects of the organization',
resource_name: ' Organization 0',
resource_type: 'organization',
related: {
organization: '/api/v2/organizations/1/',
},
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['admin_role', 'member_role', 'read_role'],
},
],
user_capabilities: {
edit: true,
},
},
username: 'Casey',
firstname: 'The',
lastname: 'Cat',
email: '',
};
test('initially renders succesfully', () => {
mountWithContexts(
<TeamUserListItem
user={user}
detailUrl="/users/1/details"
disassociateRole={() => {}}
/>
);
});
test('initially render prop items', () => {
const wrapper = mountWithContexts(
<TeamUserListItem
user={user}
detailUrl="/users/1/details"
disassociateRole={() => {}}
/>
);
expect(wrapper.find('DataListCell[aria-label="username"]').length).toBe(1);
expect(wrapper.find('DataListCell[aria-label="first name"]').length).toBe(
1
);
expect(wrapper.find('DataListCell[aria-label="last name"]').length).toBe(1);
expect(wrapper.find('DataListCell[aria-label="roles"]').length).toBe(1);
expect(
wrapper.find('DataListCell[aria-label="indirect role"]').length
).toBe(1);
});
});

View File

@ -0,0 +1,198 @@
import React, { useEffect, useCallback, useState } from 'react';
import { useLocation, useRouteMatch, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { TeamsAPI, UsersAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import TeamUserListItem from './TeamUserListItem';
const QS_CONFIG = getQSConfig('user', {
page: 1,
page_size: 20,
order_by: 'username',
});
function TeamUsersList({ i18n }) {
const location = useLocation();
const match = useRouteMatch();
const { id: teamId } = useParams();
const [roleToDisassociate, setRoleToDisassociate] = useState([]);
const {
result: { users, itemCount, actions },
error: contentError,
isLoading,
request: fetchRoles,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([
TeamsAPI.readUsersAccess(teamId, params),
TeamsAPI.readUsersAccessOptions(teamId),
]);
return {
users: response.data.results,
itemCount: response.data.count,
actions: actionsResponse.data.actions,
};
}, [location, teamId]),
{
users: [],
itemCount: 0,
actions: {},
}
);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
const {
isLoading: isDeleteLoading,
deleteItems: disassociateRole,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
UsersAPI.disassociateRole(
roleToDisassociate[0].id,
roleToDisassociate[1].id
);
}, [roleToDisassociate]),
{
qsConfig: QS_CONFIG,
fetchItems: fetchRoles,
}
);
const handleRoleDisassociation = async () => {
await disassociateRole();
setRoleToDisassociate(null);
};
const hasContentLoading = isDeleteLoading || isLoading;
const canAdd = actions && actions.POST;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
items={users}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Users`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`User Name`),
key: 'username',
isDefault: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
{
name: i18n._(t`Email`),
key: 'email',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`User Name`),
key: 'username',
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
{
name: i18n._(t`Email`),
key: 'email',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [<ToolbarAddButton key="add" linkTo="/users/add" />]
: []),
]}
/>
)}
renderItem={user => (
<TeamUserListItem
key={user.id}
user={user}
detailUrl={`/users/${user.id}/details`}
disassociateRole={role => setRoleToDisassociate([user, role])}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null
}
/>
{roleToDisassociate?.length > 0 && (
<AlertModal
variant="danger"
title={i18n._(t`Disassociate roles`)}
isOpen={roleToDisassociate}
onClose={() => setRoleToDisassociate(null)}
actions={[
<Button
key="disassociate"
variant="danger"
aria-label={i18n._(t`confirm disassociation`)}
onClick={() => handleRoleDisassociation()}
>
{i18n._(t`Disassociate`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`cancel disassociation`)}
onClick={() => setRoleToDisassociate(null)}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<div>{i18n._(t`This action will disassociate the following:`)}</div>
<span>{roleToDisassociate.name}</span>
</AlertModal>
)}
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to disassociate one or more roles.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</>
);
}
export default withI18n()(TeamUsersList);

View File

@ -0,0 +1,261 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { TeamsAPI, UsersAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import TeamUsersList from './TeamUsersList';
jest.mock('../../../api/models/Teams');
jest.mock('../../../api/models/Users');
const teamUsersList = {
data: {
count: 3,
results: [
{
id: 1,
type: 'user',
url: '',
summary_fields: {
direct_access: [],
indirect_access: [
{
role: {
id: 1,
},
},
],
},
created: '2020-06-19T12:55:13.138692Z',
username: 'admin',
first_name: '',
last_name: '',
email: 'a@g.com',
},
{
id: 5,
type: 'user',
url: '',
summary_fields: {
direct_access: [
{
role: {
id: 40,
name: 'Member',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
{
role: {
id: 41,
name: 'Read',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
],
indirect_access: [],
},
created: '2020-06-19T13:01:44.183577Z',
username: 'jt_admin',
first_name: '',
last_name: '',
email: '',
},
{
id: 2,
type: 'user',
url: '',
summary_fields: {
direct_access: [
{
role: {
id: 40,
name: 'Alex',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
{
role: {
id: 41,
name: 'Read',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
],
indirect_access: [
{
role: {
id: 2,
name: 'Admin',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['admin_role', 'member_role', 'read_role'],
},
],
},
created: '2020-06-19T13:01:43.674349Z',
username: 'org_admin',
first_name: '',
last_name: '',
email: '',
},
{
id: 3,
type: 'user',
url: '',
summary_fields: {
direct_access: [
{
role: {
id: 40,
name: 'Savannah',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
{
role: {
id: 41,
name: 'Read',
user_capabilities: {
unattach: true,
},
},
descendant_roles: ['member_role', 'read_role'],
},
],
indirect_access: [],
},
created: '2020-06-19T13:01:43.868499Z',
username: 'org_member',
first_name: '',
last_name: '',
email: '',
},
],
},
};
describe('<TeamUsersList />', () => {
let wrapper;
beforeEach(() => {
TeamsAPI.readUsersAccess = jest.fn(() =>
Promise.resolve({
data: teamUsersList.data,
})
);
TeamsAPI.readUsersAccessOptions = jest.fn(() =>
Promise.resolve({
data: {
actions: {
GET: {},
POST: {},
},
},
})
);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should load and render users', async () => {
await act(async () => {
wrapper = mountWithContexts(<TeamUsersList />);
});
wrapper.update();
expect(wrapper.find('TeamUserListItem')).toHaveLength(4);
});
test('should disassociate role', async () => {
await act(async () => {
wrapper = mountWithContexts(<TeamUsersList />);
});
wrapper.update();
await act(async () => {
wrapper.find('Label[aria-label="Member"]').prop('onClose')({
id: 1,
name: 'Member',
});
});
wrapper.update();
expect(wrapper.find('AlertModal[title="Disassociate roles"]').length).toBe(
1
);
await act(async () => {
wrapper
.find('Button[aria-label="confirm disassociation"]')
.prop('onClick')();
});
expect(UsersAPI.disassociateRole).toHaveBeenCalledTimes(1);
expect(TeamsAPI.readUsersAccess).toHaveBeenCalledTimes(2);
});
test('should show disassociation error', async () => {
UsersAPI.disassociateRole.mockResolvedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/users/1',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper = mountWithContexts(<TeamUsersList />);
});
waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await act(async () => {
wrapper.find('Label[aria-label="Member"]').prop('onClose')({
id: 1,
name: 'Member',
});
});
wrapper.update();
expect(wrapper.find('AlertModal[title="Disassociate roles"]').length).toBe(
1
);
await act(async () => {
wrapper
.find('Button[aria-label="confirm disassociation"]')
.prop('onClick')();
});
wrapper.update();
expect(UsersAPI.disassociateRole).toHaveBeenCalled();
const modal = wrapper.find('Modal');
expect(modal).toHaveLength(1);
expect(modal.prop('title')).toEqual('Error!');
});
});

View File

@ -0,0 +1,4 @@
export {
default
}
from './TeamUsersList'