diff --git a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx index f53ec6b6d4..bddb53b949 100644 --- a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx +++ b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx @@ -73,8 +73,10 @@ function AssociateModal({ const clearQSParams = () => { const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = QS_CONFIG.namespace; - const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); + const { namespace } = QS_CONFIG(displayKey); + const otherParts = parts.filter( + param => !param.startsWith(`${namespace}.`) + ); history.replace(`${history.location.pathname}?${otherParts.join('&')}`); }; diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index 8b5d10006e..c29948fe67 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -124,9 +124,11 @@ function User({ i18n, setBreadcrumb, me }) { - - - + {user && ( + + + + )} {user && ( diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx index 868cbb9e55..b8085905b6 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx @@ -1,12 +1,24 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { useLocation, useParams } from 'react-router-dom'; import { t } from '@lingui/macro'; -import PaginatedDataList from '../../../components/PaginatedDataList'; -import useRequest from '../../../util/useRequest'; -import { UsersAPI } from '../../../api'; -import { getQSConfig, parseQueryString } from '../../../util/qs'; +import PaginatedDataList, { + ToolbarAddButton, +} from '../../../components/PaginatedDataList'; +import DataListToolbar from '../../../components/DataListToolbar'; +import DisassociateButton from '../../../components/DisassociateButton'; +import AssociateModal from '../../../components/AssociateModal'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; +import useRequest, { + useDeleteItems, + useDismissableError, +} from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import { TeamsAPI, UsersAPI } from '../../../api'; +import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs'; + import UserTeamListItem from './UserTeamListItem'; const QS_CONFIG = getQSConfig('teams', { @@ -16,14 +28,21 @@ const QS_CONFIG = getQSConfig('teams', { }); function UserTeamList({ i18n }) { + const [isModalOpen, setIsModalOpen] = useState(false); const location = useLocation(); const { id: userId } = useParams(); const { - result: { teams, count, relatedSearchableKeys, searchableKeys }, + result: { + teams, + count, + userOptions, + relatedSearchableKeys, + searchableKeys, + }, error: contentError, isLoading, - request: fetchOrgs, + request: fetchTeams, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); @@ -32,13 +51,17 @@ function UserTeamList({ i18n }) { data: { results, count: teamCount }, }, actionsResponse, + usersResponse, ] = await Promise.all([ UsersAPI.readTeams(userId, params), UsersAPI.readTeamsOptions(userId), + UsersAPI.readOptions(), ]); return { teams: results, count: teamCount, + userOptions: usersResponse.data.actions, + actions: actionsResponse.data.actions, relatedSearchableKeys: ( actionsResponse?.data?.related_search_fields || [] ).map(val => val.slice(0, -8)), @@ -50,47 +73,197 @@ function UserTeamList({ i18n }) { { teams: [], count: 0, + roles: {}, + userOptions: {}, relatedSearchableKeys: [], searchableKeys: [], } ); useEffect(() => { - fetchOrgs(); - }, [fetchOrgs]); + fetchTeams(); + }, [fetchTeams]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + teams + ); + + const disassociateUserRoles = team => { + return [ + UsersAPI.disassociateRole( + userId, + team.summary_fields.object_roles.admin_role.id + ), + UsersAPI.disassociateRole( + userId, + team.summary_fields.object_roles.member_role.id + ), + UsersAPI.disassociateRole( + userId, + team.summary_fields.object_roles.read_role.id + ), + ]; + }; + + const { + isLoading: isDisassociateLoading, + deleteItems: disassociateTeams, + deletionError: disassociateError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all(selected.flatMap(team => disassociateUserRoles(team))); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTeams, + } + ); + + const { request: handleAssociate, error: associateError } = useRequest( + useCallback( + async teamsToAssociate => { + await Promise.all( + teamsToAssociate.map(team => + UsersAPI.associateRole( + userId, + team.summary_fields.object_roles.member_role.id + ) + ) + ); + fetchTeams(); + }, + [userId, fetchTeams] + ) + ); + + const handleDisassociate = async () => { + await disassociateTeams(); + setSelected([]); + }; + + const { error, dismissError } = useDismissableError( + associateError || disassociateError + ); + + const canAdd = + userOptions && Object.prototype.hasOwnProperty.call(userOptions, 'POST'); + + const fetchTeamsToAssociate = useCallback( + params => { + return TeamsAPI.read( + mergeParams(params, { + not__member_role__members__id: userId, + not__admin_role__members__id: userId, + }) + ); + }, + [userId] + ); + + const readTeamOptions = useCallback(() => UsersAPI.readTeamsOptions(userId), [ + userId, + ]); return ( - ( - {}} - isSelected={false} + <> + ( + handleSelect(team)} + isSelected={selected.some(row => row.id === team.id)} + /> + )} + renderToolbar={props => ( + + setSelected(isSelected ? [...teams] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + setIsModalOpen(true)} + defaultLabel={i18n._(t`Associate`)} + />, + ] + : []), + , + ]} + emptyStateControls={ + canAdd ? ( + setIsModalOpen(true)} + /> + ) : null + } + /> + )} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name__icontains', + isDefault: true, + }, + { + name: i18n._(t`Organization`), + key: 'organization__name__icontains', + }, + ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} + /> + {isModalOpen && ( + setIsModalOpen(false)} + title={i18n._(t`Select Teams`)} + optionsRequest={readTeamOptions} /> )} - toolbarSearchColumns={[ - { - name: i18n._(t`Name`), - key: 'name__icontains', - isDefault: true, - }, - { - name: i18n._(t`Organization`), - key: 'organization__name__icontains', - }, - ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} - /> + {error && ( + + {associateError + ? i18n._(t`Failed to associate.`) + : i18n._(t`Failed to disassociate one or more teams.`)} + + + )} + ); } diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx index b7fd0a9abf..5d17e6e7ee 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx @@ -1,83 +1,244 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { UsersAPI } from '../../../api'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { createMemoryHistory } from 'history'; + +import { UsersAPI, TeamsAPI } from '../../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; import UserTeamList from './UserTeamList'; jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + userId: 2, + }), +})); -const mockAPIUserTeamList = { - data: { - count: 3, - results: [ - { - name: 'Team 0', - id: 1, - url: '/teams/1', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, +const mockAPIUserTeamList = [ + { + name: 'Team 0', + id: 1, + url: '/teams/1', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + object_roles: { + member_role: { + id: 42, + }, + admin_role: { + id: 43, + }, + read_role: { + id: 44, }, }, - { - name: 'Team 1', - id: 2, - url: '/teams/2', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, - }, - }, - { - name: 'Team 2', - id: 3, - url: '/teams/3', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, - }, - }, - ], + }, }, - isModalOpen: false, - warningTitle: 'title', - warningMsg: 'message', -}; + { + name: 'Team 1', + id: 2, + url: '/teams/2', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + object_roles: { + member_role: { + id: 12, + }, + admin_role: { + id: 13, + }, + read_role: { + id: 14, + }, + }, + }, + }, + { + name: 'Team 2', + id: 3, + url: '/teams/3', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + object_roles: { + member_role: { + id: 22, + }, + admin_role: { + id: 23, + }, + read_role: { + id: 24, + }, + }, + }, + }, +]; + +const options = { data: { actions: { POST: true } } }; describe('', () => { - beforeEach(() => { - UsersAPI.readTeams = jest.fn(() => - Promise.resolve({ - data: mockAPIUserTeamList.data, - }) - ); - UsersAPI.readTeamsOptions = jest.fn(() => - Promise.resolve({ - data: { - actions: { - GET: {}, - POST: {}, - }, - related_search_fields: [], + let wrapper; + + beforeEach(async () => { + UsersAPI.readTeams.mockResolvedValue({ + data: { + count: mockAPIUserTeamList.length, + results: mockAPIUserTeamList, + }, + }); + + UsersAPI.readTeamsOptions.mockResolvedValue(options); + UsersAPI.readOptions.mockResolvedValue(options); + const history = createMemoryHistory({ + initialEntries: ['/users/1/teams'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { history, route: { location: history.location } }, }, - }) - ); + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); }); test('should load and render teams', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); - expect(wrapper.find('UserTeamListItem')).toHaveLength(3); }); + + test('should fetch teams from the api and render them in the list', () => { + expect(UsersAPI.readTeams).toHaveBeenCalled(); + expect(UsersAPI.readTeamsOptions).toHaveBeenCalled(); + expect(wrapper.find('UserTeamListItem').length).toBe(3); + }); + + test('should show associate team modal when adding an existing team', () => { + wrapper.find('ToolbarAddButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(1); + wrapper.find('ModalBoxCloseButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(0); + }); + + test('should show error modal for failed disassociation', async () => { + UsersAPI.disassociateRole.mockRejectedValue(new Error()); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('button[aria-label="Disassociate"]').invoke('onClick')(); + expect(wrapper.find('AlertModal Title').text()).toEqual( + 'Disassociate related team(s)?' + ); + await act(async () => { + wrapper + .find('button[aria-label="confirm disassociate"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1); + expect(wrapper.find('AlertModal ModalBoxBody').text()).toEqual( + expect.stringContaining('Failed to disassociate one or more teams.') + ); + }); + + test('expected api calls are made for multi-delete', async () => { + expect(UsersAPI.disassociateRole).toHaveBeenCalledTimes(0); + expect(UsersAPI.readTeams).toHaveBeenCalledTimes(1); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('button[aria-label="Disassociate"]').invoke('onClick')(); + expect(wrapper.find('AlertModal Title').text()).toEqual( + 'Disassociate related team(s)?' + ); + await act(async () => { + wrapper + .find('button[aria-label="confirm disassociate"]') + .invoke('onClick')(); + }); + expect(UsersAPI.disassociateRole).toHaveBeenCalledTimes(9); + expect(UsersAPI.readTeams).toHaveBeenCalledTimes(2); + }); + + test('should make expected api request when associating teams', async () => { + UsersAPI.associateRole.mockResolvedValue({ id: 2 }); + UsersAPI.readTeamsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); + TeamsAPI.read.mockResolvedValue({ + data: { + count: 1, + results: [ + { + name: 'Baz', + id: 12, + url: '/teams/42', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + object_roles: { + admin_role: { + id: 78, + }, + member_role: { + id: 79, + }, + read_role: { + id: 80, + }, + }, + }, + }, + ], + }, + }); + await act(async () => { + wrapper + .find('ToolbarAddButton button[aria-label="Associate"]') + .prop('onClick')(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + wrapper.update(); + await act(async () => { + wrapper + .find('CheckboxListItem') + .first() + .prop('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').prop('onClick')(); + }); + await waitForElement(wrapper, 'AssociateModal', el => el.length === 0); + expect(UsersAPI.associateRole).toHaveBeenCalledTimes(1); + expect(TeamsAPI.read).toHaveBeenCalledTimes(1); + }); }); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx index 41f4429879..6df28bd4a7 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { bool, func } from 'prop-types'; import { Link } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -6,13 +7,27 @@ import { DataListItemCells, DataListItemRow, DataListItem, + DataListCheck, + Split, + SplitItem, } from '@patternfly/react-core'; import DataListCell from '../../../components/DataListCell'; +import { Team } from '../../../types'; -function UserTeamListItem({ team, i18n }) { +function UserTeamListItem({ team, isSelected, onSelect, i18n }) { return ( - + + @@ -22,14 +37,18 @@ function UserTeamListItem({ team, i18n }) { , {team.summary_fields.organization && ( - <> - {i18n._(t`Organization`)}{' '} - - {team.summary_fields.organization.name} - - + + + {i18n._(t`Organization`)}{' '} + + + + {team.summary_fields.organization.name} + + + )} , {team.description}, @@ -40,4 +59,10 @@ function UserTeamListItem({ team, i18n }) { ); } +UserTeamListItem.prototype = { + team: Team.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + export default withI18n()(UserTeamListItem); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx index 5816622eb2..377b7993f9 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx @@ -22,7 +22,7 @@ describe('', () => { }, }} detailUrl="/team/1" - isSelected + isSelected={false} onSelect={() => {}} />