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={() => {}}
/>