Merge pull request #6968 from AlexSCorey/6919-UserAccessList

Adds User Access List

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-05-12 20:47:47 +00:00 committed by GitHub
commit 4378dc62bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 367 additions and 4 deletions

View File

@ -24,6 +24,16 @@ class Users extends Base {
params,
});
}
readRoles(userId, params) {
return this.http.get(`${this.baseUrl}${userId}/roles/`, {
params,
});
}
readRoleOptions(userId) {
return this.http.options(`${this.baseUrl}${userId}/roles/`);
}
}
export default Users;

View File

@ -22,6 +22,7 @@ import UserEdit from './UserEdit';
import UserOrganizations from './UserOrganizations';
import UserTeams from './UserTeams';
import UserTokens from './UserTokens';
import UserAccessList from './UserAccess/UserAccessList';
function User({ i18n, setBreadcrumb }) {
const location = useLocation();
@ -111,10 +112,7 @@ function User({ i18n, setBreadcrumb }) {
</Route>
{user && (
<Route path="/users/:id/access">
<span>
this needs a different access list from regular resources like
proj, inv, jt
</span>
<UserAccessList />
</Route>
)}
<Route path="/users/:id/tokens">

View File

@ -0,0 +1,119 @@
import React, { useCallback, useEffect } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { UsersAPI } from '@api';
import useRequest from '@util/useRequest';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import DatalistToolbar from '@components/DataListToolbar';
import UserAccessListItem from './UserAccessListItem';
const QS_CONFIG = getQSConfig('roles', {
page: 1,
page_size: 20,
order_by: 'id',
});
// TODO Figure out how to best conduct a search of this list.
// Since we only have a role ID in the top level of each role object
// we can't really search using the normal search parameters.
function UserAccessList({ i18n }) {
const { id } = useParams();
const { search } = useLocation();
const {
isLoading,
request: fetchRoles,
error,
result: { roleCount, roles, options },
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search);
const [
{
data: { results, count },
},
{
data: { actions },
},
] = await Promise.all([
UsersAPI.readRoles(id, params),
UsersAPI.readRoleOptions(id),
]);
return { roleCount: count, roles: results, options: actions };
}, [id, search]),
{
roles: [],
roleCount: 0,
}
);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
const canAdd =
options && Object.prototype.hasOwnProperty.call(options, 'POST');
const detailUrl = role => {
const { resource_id, resource_type } = role.summary_fields;
if (!role || !resource_type) {
return null;
}
if (resource_type?.includes('template')) {
return `/templates/${resource_type}/${resource_id}/details`;
}
if (resource_type?.includes('inventory')) {
return `/inventories/${resource_type}/${resource_id}/details`;
}
return `/${resource_type}s/${resource_id}/details`;
};
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={roles}
itemCount={roleCount}
pluralizedItemName={i18n._(t`User Roles`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Role`),
key: 'role_field',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'id',
},
]}
renderItem={role => {
return (
<UserAccessListItem
key={role.id}
value={role.name}
role={role}
detailUrl={detailUrl(role)}
onSelect={() => {}}
isSelected={false}
/>
);
}}
renderToolbar={props => (
<DatalistToolbar
{...props}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [<ToolbarAddButton key="add" linkTo="/" />] : []),
]}
/>
)}
/>
);
}
export default withI18n()(UserAccessList);

View File

@ -0,0 +1,141 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { UsersAPI } from '@api';
import { Route } from 'react-router-dom';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import UserAccessList from './UserAccessList';
jest.mock('@api/models/Users');
describe('<UserAccessList />', () => {
let wrapper;
let history;
beforeEach(async () => {
UsersAPI.readRoles.mockResolvedValue({
data: {
results: [
{
id: 2,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 3,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 16,
resource_type: 'workflow_job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
},
{
id: 4,
name: 'Execute',
type: 'role',
url: '/api/v2/roles/258/',
summary_fields: {
resource_name: 'Credential Bar',
resource_id: 75,
resource_type: 'credential',
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
},
{
id: 5,
name: 'Update',
type: 'role',
url: '/api/v2/roles/259/',
summary_fields: {
resource_name: 'Inventory Foo',
resource_id: 76,
resource_type: 'inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
{
id: 6,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/260/',
summary_fields: {
resource_name: 'Smart Inventory Foo',
resource_id: 77,
resource_type: 'smart_inventory',
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
},
],
count: 4,
},
});
UsersAPI.readRoleOptions.mockResolvedValue({
data: { actions: { POST: { id: 1, disassociate: true } } },
});
history = createMemoryHistory({
initialEntries: ['/users/18/access'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/users/:id/access">
<UserAccessList />
</Route>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 18 } },
},
},
},
}
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render properly', async () => {
expect(wrapper.find('UserAccessList').length).toBe(1);
});
test('should create proper detailUrl', async () => {
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe(
'/templates/job_template/15/details'
);
expect(wrapper.find(`Link#userRole-3`).prop('to')).toBe(
'/templates/workflow_job_template/16/details'
);
expect(wrapper.find('Link#userRole-4').prop('to')).toBe(
'/credentials/75/details'
);
expect(wrapper.find('Link#userRole-5').prop('to')).toBe(
'/inventories/inventory/76/details'
);
expect(wrapper.find('Link#userRole-6').prop('to')).toBe(
'/inventories/smart_inventory/77/details'
);
});
});

View File

@ -0,0 +1,48 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListItemCells,
DataListItemRow,
} from '@patternfly/react-core';
import DataListCell from '@components/DataListCell';
import { Link } from 'react-router-dom';
function UserAccessListItem({ role, i18n, detailUrl }) {
const labelId = `userRole-${role.id}`;
return (
<DataListItem key={role.id} aria-labelledby={labelId} id={`${role.id}`}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key="name" aria-label={i18n._(t`resource name`)}>
<Link to={`${detailUrl}`} id={labelId}>
<b>{role.summary_fields.resource_name}</b>
</Link>
</DataListCell>,
<DataListCell key="type" aria-label={i18n._(t`resource type`)}>
{role.summary_fields && (
<>
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
{role.summary_fields.resource_type_display_name}
</>
)}
</DataListCell>,
<DataListCell key="role" aria-label={i18n._(t`resource role`)}>
{role.name && (
<>
<b css="margin-right: 24px">{i18n._(t`Role`)}</b>
{role.name}
</>
)}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
export default withI18n()(UserAccessListItem);

View File

@ -0,0 +1,45 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import UserAccessListItem from './UserAccessListItem';
describe('<UserAccessListItem/>', () => {
let wrapper;
const role = {
id: 1,
name: 'Admin',
type: 'role',
url: '/api/v2/roles/257/',
summary_fields: {
resource_name: 'template delete project',
resource_id: 15,
resource_type: 'job_template',
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
};
beforeEach(() => {
wrapper = mountWithContexts(
<UserAccessListItem
role={role}
detailUrl="/templates/job_template/15/details"
/>
);
});
test('should mount properly', () => {
expect(wrapper.length).toBe(1);
});
test('should render proper list item data', () => {
expect(
wrapper.find('PFDataListCell[aria-label="resource name"]').text()
).toBe('template delete project');
expect(
wrapper.find('PFDataListCell[aria-label="resource type"]').text()
).toContain('Job Template');
expect(
wrapper.find('PFDataListCell[aria-label="resource role"]').text()
).toContain('Admin');
});
});

View File

@ -0,0 +1,2 @@
export { default as UserAccessListList } from './UserAccessList';
export { default as UserAccessListItem } from './UserAccessListItem';