Merge pull request #8463 from nixocio/ui_issue_7130

Add feature to associate teams to users

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-11-12 18:21:25 +00:00
committed by GitHub
6 changed files with 481 additions and 118 deletions

View File

@@ -73,8 +73,10 @@ function AssociateModal({
const clearQSParams = () => { const clearQSParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&'); const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = QS_CONFIG.namespace; const { namespace } = QS_CONFIG(displayKey);
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); const otherParts = parts.filter(
param => !param.startsWith(`${namespace}.`)
);
history.replace(`${history.location.pathname}?${otherParts.join('&')}`); history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
}; };

View File

@@ -124,9 +124,11 @@ function User({ i18n, setBreadcrumb, me }) {
<Route path="/users/:id/organizations"> <Route path="/users/:id/organizations">
<UserOrganizations id={Number(match.params.id)} /> <UserOrganizations id={Number(match.params.id)} />
</Route> </Route>
<Route path="/users/:id/teams"> {user && (
<UserTeams userId={Number(match.params.id)} /> <Route path="/users/:id/teams">
</Route> <UserTeams />
</Route>
)}
{user && ( {user && (
<Route path="/users/:id/roles"> <Route path="/users/:id/roles">
<UserRolesList user={user} /> <UserRolesList user={user} />

View File

@@ -1,12 +1,24 @@
import React, { useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList, {
import useRequest from '../../../util/useRequest'; ToolbarAddButton,
import { UsersAPI } from '../../../api'; } from '../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../util/qs'; 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'; import UserTeamListItem from './UserTeamListItem';
const QS_CONFIG = getQSConfig('teams', { const QS_CONFIG = getQSConfig('teams', {
@@ -16,14 +28,21 @@ const QS_CONFIG = getQSConfig('teams', {
}); });
function UserTeamList({ i18n }) { function UserTeamList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const { id: userId } = useParams(); const { id: userId } = useParams();
const { const {
result: { teams, count, relatedSearchableKeys, searchableKeys }, result: {
teams,
count,
userOptions,
relatedSearchableKeys,
searchableKeys,
},
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchOrgs, request: fetchTeams,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
@@ -32,13 +51,17 @@ function UserTeamList({ i18n }) {
data: { results, count: teamCount }, data: { results, count: teamCount },
}, },
actionsResponse, actionsResponse,
usersResponse,
] = await Promise.all([ ] = await Promise.all([
UsersAPI.readTeams(userId, params), UsersAPI.readTeams(userId, params),
UsersAPI.readTeamsOptions(userId), UsersAPI.readTeamsOptions(userId),
UsersAPI.readOptions(),
]); ]);
return { return {
teams: results, teams: results,
count: teamCount, count: teamCount,
userOptions: usersResponse.data.actions,
actions: actionsResponse.data.actions,
relatedSearchableKeys: ( relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || [] actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
@@ -50,47 +73,197 @@ function UserTeamList({ i18n }) {
{ {
teams: [], teams: [],
count: 0, count: 0,
roles: {},
userOptions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
} }
); );
useEffect(() => { useEffect(() => {
fetchOrgs(); fetchTeams();
}, [fetchOrgs]); }, [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 ( return (
<PaginatedDataList <>
items={teams} <PaginatedDataList
contentError={contentError} items={teams}
hasContentLoading={isLoading} contentError={contentError}
itemCount={count} hasContentLoading={isLoading || isDisassociateLoading}
pluralizedItemName={i18n._(t`Teams`)} itemCount={count}
qsConfig={QS_CONFIG} pluralizedItemName={i18n._(t`Teams`)}
renderItem={team => ( qsConfig={QS_CONFIG}
<UserTeamListItem onRowClick={handleSelect}
key={team.id} renderItem={team => (
value={team.name} <UserTeamListItem
team={team} key={team.id}
detailUrl={`/teams/${team.id}/details`} value={team.name}
onSelect={() => {}} team={team}
isSelected={false} detailUrl={`/teams/${team.id}/details`}
onSelect={() => handleSelect(team)}
isSelected={selected.some(row => row.id === team.id)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...teams] : [])
}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [
<ToolbarAddButton
key="associate"
onClick={() => setIsModalOpen(true)}
defaultLabel={i18n._(t`Associate`)}
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={i18n._(t`Disassociate related team(s)?`)}
modalNote={i18n._(
t`This action will disassociate all roles for this user from the selected teams.`
)}
/>,
]}
emptyStateControls={
canAdd ? (
<ToolbarAddButton
key="add"
onClick={() => 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 && (
<AssociateModal
header={i18n._(t`Teams`)}
fetchRequest={fetchTeamsToAssociate}
isModalOpen={isModalOpen}
onAssociate={handleAssociate}
onClose={() => setIsModalOpen(false)}
title={i18n._(t`Select Teams`)}
optionsRequest={readTeamOptions}
/> />
)} )}
toolbarSearchColumns={[ {error && (
{ <AlertModal
name: i18n._(t`Name`), isOpen={error}
key: 'name__icontains', onClose={dismissError}
isDefault: true, title={i18n._(t`Error!`)}
}, variant="error"
{ >
name: i18n._(t`Organization`), {associateError
key: 'organization__name__icontains', ? i18n._(t`Failed to associate.`)
}, : i18n._(t`Failed to disassociate one or more teams.`)}
]} <ErrorDetail error={error} />
toolbarSearchableKeys={searchableKeys} </AlertModal>
toolbarRelatedSearchableKeys={relatedSearchableKeys} )}
/> </>
); );
} }

View File

@@ -1,83 +1,244 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { UsersAPI } from '../../../api'; import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { UsersAPI, TeamsAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import UserTeamList from './UserTeamList'; import UserTeamList from './UserTeamList';
jest.mock('../../../api'); jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
userId: 2,
}),
}));
const mockAPIUserTeamList = { const mockAPIUserTeamList = [
data: { {
count: 3, name: 'Team 0',
results: [ id: 1,
{ url: '/teams/1',
name: 'Team 0', summary_fields: {
id: 1, user_capabilities: {
url: '/teams/1', delete: true,
summary_fields: { edit: true,
user_capabilities: { },
delete: true, object_roles: {
edit: true, 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', name: 'Team 1',
warningMsg: 'message', 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('<UserTeamList />', () => { describe('<UserTeamList />', () => {
beforeEach(() => { let wrapper;
UsersAPI.readTeams = jest.fn(() =>
Promise.resolve({ beforeEach(async () => {
data: mockAPIUserTeamList.data, UsersAPI.readTeams.mockResolvedValue({
}) data: {
); count: mockAPIUserTeamList.length,
UsersAPI.readTeamsOptions = jest.fn(() => results: mockAPIUserTeamList,
Promise.resolve({ },
data: { });
actions: {
GET: {}, UsersAPI.readTeamsOptions.mockResolvedValue(options);
POST: {}, UsersAPI.readOptions.mockResolvedValue(options);
}, const history = createMemoryHistory({
related_search_fields: [], initialEntries: ['/users/1/teams'],
});
await act(async () => {
wrapper = mountWithContexts(<UserTeamList />, {
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 () => { test('should load and render teams', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<UserTeamList />);
});
wrapper.update();
expect(wrapper.find('UserTeamListItem')).toHaveLength(3); 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);
});
}); });

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { bool, func } from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -6,13 +7,27 @@ import {
DataListItemCells, DataListItemCells,
DataListItemRow, DataListItemRow,
DataListItem, DataListItem,
DataListCheck,
Split,
SplitItem,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import DataListCell from '../../../components/DataListCell'; import DataListCell from '../../../components/DataListCell';
import { Team } from '../../../types';
function UserTeamListItem({ team, i18n }) { function UserTeamListItem({ team, isSelected, onSelect, i18n }) {
return ( return (
<DataListItem aria-labelledby={`team-${team.id}`}> <DataListItem
key={team.id}
id={`${team.id}`}
aria-labelledby={`team-${team.id}`}
>
<DataListItemRow> <DataListItemRow>
<DataListCheck
aria-labelledby={`team-${team.id}`}
checked={isSelected}
id={`team-${team.id}`}
onChange={onSelect}
/>
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell key="name"> <DataListCell key="name">
@@ -22,14 +37,18 @@ function UserTeamListItem({ team, i18n }) {
</DataListCell>, </DataListCell>,
<DataListCell key="organization"> <DataListCell key="organization">
{team.summary_fields.organization && ( {team.summary_fields.organization && (
<> <Split hasGutter>
<b>{i18n._(t`Organization`)}</b>{' '} <SplitItem>
<Link <b>{i18n._(t`Organization`)}</b>{' '}
to={`/organizations/${team.summary_fields.organization.id}/details`} </SplitItem>
> <SplitItem>
<b>{team.summary_fields.organization.name}</b> <Link
</Link> to={`/organizations/${team.summary_fields.organization.id}/details`}
</> >
<b>{team.summary_fields.organization.name}</b>
</Link>
</SplitItem>
</Split>
)} )}
</DataListCell>, </DataListCell>,
<DataListCell key="description">{team.description}</DataListCell>, <DataListCell key="description">{team.description}</DataListCell>,
@@ -40,4 +59,10 @@ function UserTeamListItem({ team, i18n }) {
); );
} }
UserTeamListItem.prototype = {
team: Team.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(UserTeamListItem); export default withI18n()(UserTeamListItem);

View File

@@ -22,7 +22,7 @@ describe('<UserTeamListItem />', () => {
}, },
}} }}
detailUrl="/team/1" detailUrl="/team/1"
isSelected isSelected={false}
onSelect={() => {}} onSelect={() => {}}
/> />
</MemoryRouter> </MemoryRouter>