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
commit 76fd63ba5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 481 additions and 118 deletions

View File

@ -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('&')}`);
};

View File

@ -124,9 +124,11 @@ function User({ i18n, setBreadcrumb, me }) {
<Route path="/users/:id/organizations">
<UserOrganizations id={Number(match.params.id)} />
</Route>
<Route path="/users/:id/teams">
<UserTeams userId={Number(match.params.id)} />
</Route>
{user && (
<Route path="/users/:id/teams">
<UserTeams />
</Route>
)}
{user && (
<Route path="/users/:id/roles">
<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 { 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 (
<PaginatedDataList
items={teams}
contentError={contentError}
hasContentLoading={isLoading}
itemCount={count}
pluralizedItemName={i18n._(t`Teams`)}
qsConfig={QS_CONFIG}
renderItem={team => (
<UserTeamListItem
key={team.id}
value={team.name}
team={team}
detailUrl={`/teams/${team.id}/details`}
onSelect={() => {}}
isSelected={false}
<>
<PaginatedDataList
items={teams}
contentError={contentError}
hasContentLoading={isLoading || isDisassociateLoading}
itemCount={count}
pluralizedItemName={i18n._(t`Teams`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
renderItem={team => (
<UserTeamListItem
key={team.id}
value={team.name}
team={team}
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={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Organization`),
key: 'organization__name__icontains',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
/>
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
variant="error"
>
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more teams.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}

View File

@ -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('<UserTeamList />', () => {
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(<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 () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<UserTeamList />);
});
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);
});
});

View File

@ -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 (
<DataListItem aria-labelledby={`team-${team.id}`}>
<DataListItem
key={team.id}
id={`${team.id}`}
aria-labelledby={`team-${team.id}`}
>
<DataListItemRow>
<DataListCheck
aria-labelledby={`team-${team.id}`}
checked={isSelected}
id={`team-${team.id}`}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
@ -22,14 +37,18 @@ function UserTeamListItem({ team, i18n }) {
</DataListCell>,
<DataListCell key="organization">
{team.summary_fields.organization && (
<>
<b>{i18n._(t`Organization`)}</b>{' '}
<Link
to={`/organizations/${team.summary_fields.organization.id}/details`}
>
<b>{team.summary_fields.organization.name}</b>
</Link>
</>
<Split hasGutter>
<SplitItem>
<b>{i18n._(t`Organization`)}</b>{' '}
</SplitItem>
<SplitItem>
<Link
to={`/organizations/${team.summary_fields.organization.id}/details`}
>
<b>{team.summary_fields.organization.name}</b>
</Link>
</SplitItem>
</Split>
)}
</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);

View File

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