From a070d5708099439fc47aa2237e7f2d9ed8004fc0 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 27 Jul 2020 16:33:15 -0400 Subject: [PATCH] Adds Teams Roles List and Disassociate functionality --- awx/ui_next/src/api/models/Teams.js | 11 + awx/ui_next/src/screens/Team/Team.jsx | 11 +- .../Team/TeamUsers/TeamUserListItem.jsx | 102 +++++++ .../Team/TeamUsers/TeamUserListItem.test.jsx | 84 ++++++ .../screens/Team/TeamUsers/TeamUsersList.jsx | 198 +++++++++++++ .../Team/TeamUsers/TeamUsersList.test.jsx | 261 ++++++++++++++++++ .../src/screens/Team/TeamUsers/index.js | 4 + 7 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamUsers/index.js diff --git a/awx/ui_next/src/api/models/Teams.js b/awx/ui_next/src/api/models/Teams.js index de2d3db077..e19400ad5b 100644 --- a/awx/ui_next/src/api/models/Teams.js +++ b/awx/ui_next/src/api/models/Teams.js @@ -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; diff --git a/awx/ui_next/src/screens/Team/Team.jsx b/awx/ui_next/src/screens/Team/Team.jsx index 52203d3748..ca04326380 100644 --- a/awx/ui_next/src/screens/Team/Team.jsx +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -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 }) { )} {team && ( - - Coming soon :) + + )} {team && ( - + )} diff --git a/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx b/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx new file mode 100644 index 0000000000..916f926b6d --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx @@ -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 ( + + + + + {user.username} + + , + + {user.first_name && ( + <> + + {user.first_name} + + )} + , + + {user.last_name && ( + <> + + {user.last} + + )} + , + + {hasDirectRoles && ( + <> + + + {user.summary_fields.direct_access.map(role => + role.role.name !== 'Read' ? ( + disassociateRole(role.role)} + > + {role.role.name} + + ) : null + )} + + + )} + , + + {hasIndirectRoles && ( + <> + + + {user.summary_fields.indirect_access.map(role => ( + + {role.role.name} + + ))} + + + )} + , + ]} + /> + + + ); +} + +TeamUserListItem.propTypes = { + user: User.isRequired, + detailUrl: string.isRequired, + disassociateRole: func.isRequired, +}; + +export default withI18n()(TeamUserListItem); diff --git a/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.test.jsx new file mode 100644 index 0000000000..91d770e97e --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.test.jsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import TeamUserListItem from './TeamUserListItem'; + +describe('', () => { + 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( + {}} + /> + ); + }); + test('initially render prop items', () => { + const wrapper = mountWithContexts( + {}} + /> + ); + 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); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.jsx b/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.jsx new file mode 100644 index 0000000000..d85b3512d2 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.jsx @@ -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 ( + <> + ( + ] + : []), + ]} + /> + )} + renderItem={user => ( + setRoleToDisassociate([user, role])} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + {roleToDisassociate?.length > 0 && ( + setRoleToDisassociate(null)} + actions={[ + , + , + ]} + > +
{i18n._(t`This action will disassociate the following:`)}
+ {roleToDisassociate.name} +
+ )} + + {i18n._(t`Failed to disassociate one or more roles.`)} + + + + ); +} + +export default withI18n()(TeamUsersList); diff --git a/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.test.jsx b/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.test.jsx new file mode 100644 index 0000000000..2d393f8bf7 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.test.jsx @@ -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('', () => { + 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(); + }); + wrapper.update(); + + expect(wrapper.find('TeamUserListItem')).toHaveLength(4); + }); + + test('should disassociate role', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + 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(); + }); + 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!'); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamUsers/index.js b/awx/ui_next/src/screens/Team/TeamUsers/index.js new file mode 100644 index 0000000000..68714e00a2 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamUsers/index.js @@ -0,0 +1,4 @@ +export { + default +} +from './TeamUsersList'