From a070d5708099439fc47aa2237e7f2d9ed8004fc0 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 27 Jul 2020 16:33:15 -0400 Subject: [PATCH 1/3] 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' From 8e27e0ce28afe6025e6407b30978ab6c706ce9f2 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 28 Jul 2020 13:16:55 -0400 Subject: [PATCH 2/3] Teams Access List using Resource Access component --- awx/ui_next/src/api/models/Teams.js | 3 +- awx/ui_next/src/screens/Team/Team.jsx | 4 +- .../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, 3 insertions(+), 653 deletions(-) delete mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx delete mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.test.jsx delete mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.jsx delete mode 100644 awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.test.jsx delete 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 e19400ad5b..1a205993d4 100644 --- a/awx/ui_next/src/api/models/Teams.js +++ b/awx/ui_next/src/api/models/Teams.js @@ -29,7 +29,7 @@ class Teams extends Base { return this.http.options(`${this.baseUrl}${teamId}/roles/`); } - readUsersAccess(teamId, params) { + readAccessList(teamId, params) { return this.http.get(`${this.baseUrl}${teamId}/access_list/`, { params, }); @@ -38,7 +38,6 @@ class Teams extends Base { 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 ca04326380..60b71a6a55 100644 --- a/awx/ui_next/src/screens/Team/Team.jsx +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -17,7 +17,7 @@ import TeamDetail from './TeamDetail'; import TeamEdit from './TeamEdit'; import { TeamsAPI } from '../../api'; import TeamAccessList from './TeamAccess'; -import TeamUsersList from './TeamUsers'; +import { ResourceAccessList } from '../../components/ResourceAccessList'; function Team({ i18n, setBreadcrumb }) { const [team, setTeam] = useState(null); @@ -97,7 +97,7 @@ function Team({ i18n, setBreadcrumb }) { )} {team && ( - + )} {team && ( diff --git a/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx b/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx deleted file mode 100644 index 916f926b6d..0000000000 --- a/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.jsx +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 91d770e97e..0000000000 --- a/awx/ui_next/src/screens/Team/TeamUsers/TeamUserListItem.test.jsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index d85b3512d2..0000000000 --- a/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.jsx +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index 2d393f8bf7..0000000000 --- a/awx/ui_next/src/screens/Team/TeamUsers/TeamUsersList.test.jsx +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index 68714e00a2..0000000000 --- a/awx/ui_next/src/screens/Team/TeamUsers/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { - default -} -from './TeamUsersList' From 24acacbcb6bf9d7c59211b0f1f508daaabdfa48b Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 28 Jul 2020 13:25:46 -0400 Subject: [PATCH 3/3] Renames files to match the tabs better. --- awx/ui_next/src/screens/Team/Team.jsx | 2 +- .../src/screens/Team/TeamAccess/index.js | 1 - .../TeamRoleListItem.jsx} | 4 ++-- .../TeamRoleListItem.test.jsx} | 12 ++++++------ .../TeamRolesList.jsx} | 8 ++++---- .../TeamRolesList.test.jsx} | 18 +++++++++--------- .../src/screens/Team/TeamRoles/index.js | 1 + 7 files changed, 23 insertions(+), 23 deletions(-) delete mode 100644 awx/ui_next/src/screens/Team/TeamAccess/index.js rename awx/ui_next/src/screens/Team/{TeamAccess/TeamAccessListItem.jsx => TeamRoles/TeamRoleListItem.jsx} (94%) rename awx/ui_next/src/screens/Team/{TeamAccess/TeamAccessListItem.test.jsx => TeamRoles/TeamRoleListItem.test.jsx} (89%) rename awx/ui_next/src/screens/Team/{TeamAccess/TeamAccessList.jsx => TeamRoles/TeamRolesList.jsx} (97%) rename awx/ui_next/src/screens/Team/{TeamAccess/TeamAccessList.test.jsx => TeamRoles/TeamRolesList.test.jsx} (94%) create mode 100644 awx/ui_next/src/screens/Team/TeamRoles/index.js diff --git a/awx/ui_next/src/screens/Team/Team.jsx b/awx/ui_next/src/screens/Team/Team.jsx index 60b71a6a55..2d46f30360 100644 --- a/awx/ui_next/src/screens/Team/Team.jsx +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -16,7 +16,7 @@ import ContentError from '../../components/ContentError'; import TeamDetail from './TeamDetail'; import TeamEdit from './TeamEdit'; import { TeamsAPI } from '../../api'; -import TeamAccessList from './TeamAccess'; +import TeamAccessList from './TeamRoles'; import { ResourceAccessList } from '../../components/ResourceAccessList'; function Team({ i18n, setBreadcrumb }) { diff --git a/awx/ui_next/src/screens/Team/TeamAccess/index.js b/awx/ui_next/src/screens/Team/TeamAccess/index.js deleted file mode 100644 index d249ad2afa..0000000000 --- a/awx/ui_next/src/screens/Team/TeamAccess/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './TeamAccessList'; diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.jsx similarity index 94% rename from awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx rename to awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.jsx index e32199d19e..7c32f50d60 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.jsx @@ -11,7 +11,7 @@ import { Link } from 'react-router-dom'; import { DetailList, Detail } from '../../../components/DetailList'; import DataListCell from '../../../components/DataListCell'; -function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) { +function TeamRoleListItem({ role, i18n, detailUrl, onSelect }) { const labelId = `teamRole-${role.id}`; return ( @@ -60,4 +60,4 @@ function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) { ); } -export default withI18n()(TeamAccessListItem); +export default withI18n()(TeamRoleListItem); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.test.jsx similarity index 89% rename from awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx rename to awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.test.jsx index 094ba21bf8..3e446a34a8 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRoleListItem.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import TeamAccessListItem from './TeamAccessListItem'; +import TeamRoleListItem from './TeamRoleListItem'; -describe('', () => { +describe('', () => { let wrapper; const role = { id: 1, @@ -20,7 +20,7 @@ describe('', () => { test('should mount properly', () => { wrapper = mountWithContexts( - @@ -31,7 +31,7 @@ describe('', () => { test('should render proper list item data', () => { wrapper = mountWithContexts( - @@ -49,7 +49,7 @@ describe('', () => { }); test('should render deletable chip', () => { wrapper = mountWithContexts( - @@ -59,7 +59,7 @@ describe('', () => { test('should render read only chip', () => { role.summary_fields.user_capabilities.unattach = false; wrapper = mountWithContexts( - diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx similarity index 97% rename from awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx rename to awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx index e29b7d5e5f..25c3a59220 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx @@ -19,7 +19,7 @@ import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import ErrorDetail from '../../../components/ErrorDetail'; import AlertModal from '../../../components/AlertModal'; -import TeamAccessListItem from './TeamAccessListItem'; +import TeamRoleListItem from './TeamRoleListItem'; import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('roles', { @@ -28,7 +28,7 @@ const QS_CONFIG = getQSConfig('roles', { order_by: 'id', }); -function TeamAccessList({ i18n }) { +function TeamRolesList({ i18n }) { const [isWizardOpen, setIsWizardOpen] = useState(false); const { search } = useLocation(); const { id } = useParams(); @@ -165,7 +165,7 @@ function TeamAccessList({ i18n }) { /> )} renderItem={role => ( - ); } -export default withI18n()(TeamAccessList); +export default withI18n()(TeamRolesList); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.test.jsx similarity index 94% rename from awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx rename to awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.test.jsx index 5828d162df..5475470486 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.test.jsx @@ -5,7 +5,7 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import TeamAccessList from './TeamAccessList'; +import TeamRolesList from './TeamRolesList'; jest.mock('../../../api/models/Teams'); jest.mock('../../../api/models/Roles'); @@ -92,7 +92,7 @@ const roles = { const options = { data: { actions: { POST: { id: 1, disassociate: true } } }, }; -describe('', () => { +describe('', () => { let wrapper; afterEach(() => { @@ -104,9 +104,9 @@ describe('', () => { TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); - expect(wrapper.find('TeamAccessList').length).toBe(1); + expect(wrapper.find('TeamRolesList').length).toBe(1); }); test('should create proper detailUrl', async () => { @@ -114,7 +114,7 @@ describe('', () => { TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -161,7 +161,7 @@ describe('', () => { }, }); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -175,7 +175,7 @@ describe('', () => { TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -228,7 +228,7 @@ describe('', () => { TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -285,7 +285,7 @@ describe('', () => { TeamsAPI.readRoleOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); waitForElement( diff --git a/awx/ui_next/src/screens/Team/TeamRoles/index.js b/awx/ui_next/src/screens/Team/TeamRoles/index.js new file mode 100644 index 0000000000..67d96f34fd --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamRoles/index.js @@ -0,0 +1 @@ +export { default } from './TeamRolesList';