Merge pull request #7103 from AlexSCorey/6921-UserAndTeamsAccessAdd

Adds support to user and team access add

Reviewed-by: Alex Corey <Alex.swansboro@gmail.com>
             https://github.com/AlexSCorey
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-05-29 13:54:28 +00:00 committed by GitHub
commit d205685541
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 998 additions and 255 deletions

View File

@ -258,7 +258,7 @@ class AddResourceRole extends React.Component {
sortColumns={userSortColumns}
displayKey="username"
onRowClick={this.handleResourceCheckboxClick}
onSearch={readUsers}
fetchItems={readUsers}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
@ -269,7 +269,7 @@ class AddResourceRole extends React.Component {
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={this.handleResourceCheckboxClick}
onSearch={readTeams}
fetchItems={readTeams}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>

View File

@ -1,19 +1,27 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from '@patternfly/react-core';
import { Checkbox as PFCheckbox } from '@patternfly/react-core';
import styled from 'styled-components';
const CheckboxWrapper = styled.div`
display: flex;
border: 1px solid var(--pf-global--BorderColor--200);
border-radius: var(--pf-global--BorderRadius--sm);
padding: 10px;
`;
const Checkbox = styled(PFCheckbox)`
width: 100%;
& label {
width: 100%;
}
`;
class CheckboxCard extends Component {
render() {
const { name, description, isSelected, onSelect, itemId } = this.props;
return (
<div
style={{
display: 'flex',
border: '1px solid var(--pf-global--BorderColor--200)',
borderRadius: 'var(--pf-global--BorderRadius--sm)',
padding: '10px',
}}
>
<CheckboxWrapper>
<Checkbox
isChecked={isSelected}
onChange={onSelect}
@ -27,7 +35,7 @@ class CheckboxCard extends Component {
}
value={itemId}
/>
</div>
</CheckboxWrapper>
);
}
}

View File

@ -1,8 +1,10 @@
import React, { Fragment } from 'react';
import React, { Fragment, useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withRouter, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import useRequest from '../../util/useRequest';
import { SearchColumns, SortColumns } from '../../types';
import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar';
@ -10,124 +12,94 @@ import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { getQSConfig, parseQueryString } from '../../util/qs';
class SelectResourceStep extends React.Component {
constructor(props) {
super(props);
const QS_Config = sortColumns => {
return getQSConfig('resource', {
page: 1,
page_size: 5,
order_by: `${
sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username'
}`,
});
};
function SelectResourceStep({
searchColumns,
sortColumns,
displayKey,
onRowClick,
selectedLabel,
selectedResourceRows,
fetchItems,
i18n,
}) {
const location = useLocation();
this.state = {
isInitialized: false,
count: null,
error: false,
const {
isLoading,
error,
request: readResourceList,
result: { resources, itemCount },
} = useRequest(
useCallback(async () => {
const queryParams = parseQueryString(
QS_Config(sortColumns),
location.search
);
const {
data: { count, results },
} = await fetchItems(queryParams);
return { resources: results, itemCount: count };
}, [location, fetchItems, sortColumns]),
{
resources: [],
};
this.qsConfig = getQSConfig('resource', {
page: 1,
page_size: 5,
order_by: `${
props.sortColumns.filter(col => col.key === 'name').length
? 'name'
: 'username'
}`,
});
}
componentDidMount() {
this.readResourceList();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readResourceList();
itemCount: 0,
}
}
);
async readResourceList() {
const { onSearch, location } = this.props;
const queryParams = parseQueryString(this.qsConfig, location.search);
useEffect(() => {
readResourceList();
}, [readResourceList]);
this.setState({
isLoading: true,
error: false,
});
try {
const { data } = await onSearch(queryParams);
const { count, results } = data;
this.setState({
resources: results,
count,
isInitialized: true,
isLoading: false,
error: false,
});
} catch (err) {
this.setState({
isLoading: false,
error: true,
});
}
}
render() {
const { isInitialized, isLoading, count, error, resources } = this.state;
const {
searchColumns,
sortColumns,
displayKey,
onRowClick,
selectedLabel,
selectedResourceRows,
i18n,
} = this.props;
return (
<Fragment>
{isInitialized && (
<Fragment>
<div>
{i18n._(
t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.`
)}
</div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
/>
)}
<PaginatedDataList
hasContentLoading={isLoading}
items={resources}
itemCount={count}
qsConfig={this.qsConfig}
onRowClick={onRowClick}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
label={item[displayKey]}
onSelect={() => onRowClick(item)}
onDeselect={() => onRowClick(item)}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</Fragment>
return (
<Fragment>
<div>
{i18n._(
t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.`
)}
{error ? <div>error</div> : ''}
</Fragment>
);
}
</div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
/>
)}
<PaginatedDataList
hasContentLoading={isLoading}
contentError={error}
items={resources}
itemCount={itemCount}
qsConfig={QS_Config(sortColumns)}
onRowClick={onRowClick}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
label={item[displayKey]}
onSelect={() => onRowClick(item)}
onDeselect={() => onRowClick(item)}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</Fragment>
);
}
SelectResourceStep.propTypes = {
@ -135,7 +107,7 @@ SelectResourceStep.propTypes = {
sortColumns: SortColumns,
displayKey: PropTypes.string,
onRowClick: PropTypes.func,
onSearch: PropTypes.func.isRequired,
fetchItems: PropTypes.func.isRequired,
selectedLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
};

View File

@ -1,7 +1,11 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import SelectResourceStep from './SelectResourceStep';
@ -30,12 +34,12 @@ describe('<SelectResourceStep />', () => {
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
fetchItems={() => {}}
/>
);
});
test('fetches resources on mount', async () => {
test('fetches resources on mount and adds items to list', async () => {
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
@ -45,61 +49,24 @@ describe('<SelectResourceStep />', () => {
],
},
});
mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
/>
);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
fetchItems={handleSearch}
/>
);
});
expect(handleSearch).toHaveBeenCalledWith({
order_by: 'username',
page: 1,
page_size: 5,
});
});
test('readResourceList properly adds rows to state', async () => {
const selectedResourceRows = [{ id: 1, username: 'foo', url: 'item/1' }];
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
],
},
});
const history = createMemoryHistory({
initialEntries: [
'/organizations/1/access?resource.page=1&resource.order_by=-username',
],
});
const wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
selectedResourceRows={selectedResourceRows}
/>,
{
context: { router: { history, route: { location: history.location } } },
}
).find('SelectResourceStep');
await wrapper.instance().readResourceList();
expect(handleSearch).toHaveBeenCalledWith({
order_by: '-username',
page: 1,
page_size: 5,
});
expect(wrapper.state('resources')).toEqual([
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
]);
waitForElement(wrapper, 'CheckBoxListItem', el => el.length === 2);
});
test('clicking on row fires callback with correct params', async () => {
@ -111,20 +78,24 @@ describe('<SelectResourceStep />', () => {
{ id: 2, username: 'bar', url: 'item/2' },
],
};
const wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={handleRowClick}
onSearch={() => ({ data })}
selectedResourceRows={[]}
/>
);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<SelectResourceStep
searchColumns={searchColumns}
sortColumns={sortColumns}
displayKey="username"
onRowClick={handleRowClick}
fetchItems={() => ({ data })}
selectedResourceRows={[]}
/>
);
});
await sleep(0);
wrapper.update();
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
expect(checkboxListItemWrapper.length).toBe(2);
checkboxListItemWrapper
.first()
.find('input[type="checkbox"]')

View File

@ -0,0 +1,156 @@
import React, { useState, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import useRequest, { useDismissableError } from '../../util/useRequest';
import SelectableCard from '../SelectableCard';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import Wizard from '../Wizard/Wizard';
import useSelected from '../../util/useSelected';
import SelectResourceStep from '../AddRole/SelectResourceStep';
import SelectRoleStep from '../AddRole/SelectRoleStep';
import getResourceAccessConfig from './getResourceAccessConfig';
const Grid = styled.div`
display: grid;
grid-gap: 20px;
grid-template-columns: 33% 33% 33%;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
`;
function UserAndTeamAccessAdd({
i18n,
isOpen,
title,
onSave,
apiModel,
onClose,
}) {
const [selectedResourceType, setSelectedResourceType] = useState(null);
const [stepIdReached, setStepIdReached] = useState(1);
const { id: userId } = useParams();
const {
selected: resourcesSelected,
handleSelect: handleResourceSelect,
} = useSelected([]);
const {
selected: rolesSelected,
handleSelect: handleRoleSelect,
} = useSelected([]);
const { request: handleWizardSave, error: saveError } = useRequest(
useCallback(async () => {
const roleRequests = [];
const resourceRolesTypes = resourcesSelected.flatMap(resource =>
Object.values(resource.summary_fields.object_roles)
);
rolesSelected.map(role =>
resourceRolesTypes.forEach(rolename => {
if (rolename.name === role.name) {
roleRequests.push(apiModel.associateRole(userId, rolename.id));
}
})
);
await Promise.all(roleRequests);
onSave();
}, [onSave, rolesSelected, apiModel, userId, resourcesSelected]),
{}
);
const { error, dismissError } = useDismissableError(saveError);
const steps = [
{
id: 1,
name: i18n._(t`Add resource type`),
component: (
<Grid>
{getResourceAccessConfig(i18n).map(resource => (
<SelectableCard
key={resource.selectedResource}
isSelected={
resource.selectedResource ===
selectedResourceType?.selectedResource
}
label={resource.label}
dataCy={`add-role-${resource.selectedResource}`}
onClick={() => setSelectedResourceType(resource)}
/>
))}
</Grid>
),
enableNext: selectedResourceType !== null,
},
{
id: 2,
name: i18n._(t`Select items from list`),
component: selectedResourceType && (
<SelectResourceStep
searchColumns={selectedResourceType.searchColumns}
sortColumns={selectedResourceType.sortColumns}
displayKey="name"
onRowClick={handleResourceSelect}
fetchItems={selectedResourceType.fetchItems}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={resourcesSelected}
sortedColumnKey="username"
/>
),
enableNext: resourcesSelected.length > 0,
canJumpTo: stepIdReached >= 2,
},
{
id: 3,
name: i18n._(t`Select roles to apply`),
component: resourcesSelected?.length > 0 && (
<SelectRoleStep
onRolesClick={handleRoleSelect}
roles={resourcesSelected[0].summary_fields.object_roles}
selectedListKey={
selectedResourceType === 'users' ? 'username' : 'name'
}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={resourcesSelected}
selectedRoleRows={rolesSelected}
/>
),
nextButtonText: i18n._(t`Save`),
canJumpTo: stepIdReached >= 3,
},
];
if (error) {
return (
<AlertModal
aria-label={i18n._(t`Associate role error`)}
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
>
{i18n._(t`Failed to associate role`)}
<ErrorDetail error={error} />
</AlertModal>
);
}
return (
<Wizard
isOpen={isOpen}
title={title}
steps={steps}
onClose={onClose}
onNext={({ id }) =>
setStepIdReached(stepIdReached < id ? id : stepIdReached)
}
onSave={handleWizardSave}
/>
);
}
export default withI18n()(UserAndTeamAccessAdd);

View File

@ -0,0 +1,232 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { UsersAPI, JobTemplatesAPI } from '../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import UserAndTeamAccessAdd from './UserAndTeamAccessAdd';
jest.mock('../../api/models/Teams');
jest.mock('../../api/models/Users');
jest.mock('../../api/models/JobTemplates');
describe('<UserAndTeamAccessAdd/>', () => {
const resources = {
data: {
results: [
{
id: 1,
name: 'Job Template Foo Bar',
url: '/api/v2/job_template/1/',
summary_fields: {
object_roles: {
admin_role: {
description: 'Can manage all aspects of the job template',
name: 'Admin',
id: 164,
},
execute_role: {
description: 'May run the job template',
name: 'Execute',
id: 165,
},
read_role: {
description: 'May view settings for the job template',
name: 'Read',
id: 166,
},
},
},
},
],
count: 1,
},
};
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserAndTeamAccessAdd
apiModel={UsersAPI}
isOpen
onSave={() => {}}
onClose={() => {}}
title="Add user permissions"
/>
);
});
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should mount properly', async () => {
expect(wrapper.find('PFWizard').length).toBe(1);
});
test('should disable steps', async () => {
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
expect(
wrapper
.find('WizardNavItem[text="Select items from list"]')
.prop('isDisabled')
).toBe(true);
expect(
wrapper
.find('WizardNavItem[text="Select roles to apply"]')
.prop('isDisabled')
).toBe(true);
await act(async () =>
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
fetchItems: JobTemplatesAPI.read,
label: 'Job template',
selectedResource: 'jobTemplate',
searchColumns: [{ name: 'Name', key: 'name', isDefault: true }],
sortColumns: [{ name: 'Name', key: 'name' }],
})
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
expect(
wrapper.find('WizardNavItem[text="Add resource type"]').prop('isDisabled')
).toBe(false);
expect(
wrapper
.find('WizardNavItem[text="Select items from list"]')
.prop('isDisabled')
).toBe(false);
expect(
wrapper
.find('WizardNavItem[text="Select roles to apply"]')
.prop('isDisabled')
).toBe(true);
});
test('should call api to associate role', async () => {
JobTemplatesAPI.read.mockResolvedValue(resources);
UsersAPI.associateRole.mockResolvedValue({});
await act(async () =>
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
fetchItems: JobTemplatesAPI.read,
label: 'Job template',
selectedResource: 'jobTemplate',
searchColumns: [{ name: 'Name', key: 'name', isDefault: true }],
sortColumns: [{ name: 'Name', key: 'name' }],
})
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
expect(JobTemplatesAPI.read).toHaveBeenCalledWith({
order_by: 'name',
page: 1,
page_size: 5,
});
await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0);
expect(JobTemplatesAPI.read).toHaveBeenCalled();
await act(async () =>
wrapper
.find('CheckboxListItem')
.first()
.find('input[type="checkbox"]')
.simulate('change', { target: { checked: true } })
);
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('RolesStep').length).toBe(1);
await act(async () =>
wrapper
.find('CheckboxCard')
.first()
.prop('onSelect')()
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
await expect(UsersAPI.associateRole).toHaveBeenCalled();
});
test('should throw error', async () => {
JobTemplatesAPI.read.mockResolvedValue(resources);
UsersAPI.associateRole.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/users/a/roles',
},
data: 'An error occurred',
status: 403,
},
})
);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 'a',
}),
}));
await act(async () =>
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
fetchItems: JobTemplatesAPI.read,
label: 'Job template',
selectedResource: 'jobTemplate',
searchColumns: [{ name: 'Name', key: 'name', isDefault: true }],
sortColumns: [{ name: 'Name', key: 'name' }],
})
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0);
expect(JobTemplatesAPI.read).toHaveBeenCalled();
await act(async () =>
wrapper
.find('CheckboxListItem')
.first()
.find('input[type="checkbox"]')
.simulate('change', { target: { checked: true } })
);
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('RolesStep').length).toBe(1);
await act(async () =>
wrapper
.find('CheckboxCard')
.first()
.prop('onSelect')()
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
await expect(UsersAPI.associateRole).toHaveBeenCalled();
wrapper.update();
expect(wrapper.find('AlertModal').length).toBe(1);
});
});

View File

@ -0,0 +1,208 @@
import { t } from '@lingui/macro';
import {
JobTemplatesAPI,
WorkflowJobTemplatesAPI,
CredentialsAPI,
InventoriesAPI,
ProjectsAPI,
OrganizationsAPI,
} from '../../api';
export default function getResourceAccessConfig(i18n) {
return [
{
selectedResource: 'jobTemplate',
label: i18n._(t`Job templates`),
searchColumns: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Playbook name`),
key: 'playbook',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
],
sortColumns: [
{
name: i18n._(t`Name`),
key: 'name',
},
],
fetchItems: queryParams => JobTemplatesAPI.read(queryParams),
},
{
selectedResource: 'workflowJobTemplate',
label: i18n._(t`Workflow job templates`),
searchColumns: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Playbook name`),
key: 'playbook',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
],
sortColumns: [
{
name: i18n._(t`Name`),
key: 'name',
},
],
fetchItems: queryParams => WorkflowJobTemplatesAPI.read(queryParams),
},
{
selectedResource: 'credential',
label: i18n._(t`Credentials`),
searchColumns: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'scm_type',
options: [
[``, i18n._(t`Manual`)],
[`git`, i18n._(t`Git`)],
[`hg`, i18n._(t`Mercurial`)],
[`svn`, i18n._(t`Subversion`)],
[`insights`, i18n._(t`Red Hat Insights`)],
],
},
{
name: i18n._(t`Source Control URL`),
key: 'scm_url',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
],
sortColumns: [
{
name: i18n._(t`Name`),
key: 'name',
},
],
fetchItems: queryParams => CredentialsAPI.read(queryParams),
},
{
selectedResource: 'inventory',
label: i18n._(t`Inventories`),
searchColumns: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
],
sortColumns: [
{
name: i18n._(t`Name`),
key: 'name',
},
],
fetchItems: queryParams => InventoriesAPI.read(queryParams),
},
{
selectedResource: 'project',
label: i18n._(t`Projects`),
searchColumns: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'scm_type',
options: [
[``, i18n._(t`Manual`)],
[`git`, i18n._(t`Git`)],
[`hg`, i18n._(t`Mercurial`)],
[`svn`, i18n._(t`Subversion`)],
[`insights`, i18n._(t`Red Hat Insights`)],
],
},
{
name: i18n._(t`Source Control URL`),
key: 'scm_url',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
],
sortColumns: [
{
name: i18n._(t`Name`),
key: 'name',
},
],
fetchItems: queryParams => ProjectsAPI.read(queryParams),
},
{
selectedResource: 'organization',
label: i18n._(t`Organizations`),
searchColumns: [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
],
sortColumns: [
{
name: i18n._(t`Name`),
key: 'name',
},
],
fetchItems: queryParams => OrganizationsAPI.read(queryParams),
},
];
}

View File

@ -0,0 +1 @@
export { default } from './UserAndTeamAccessAdd';

View File

@ -1,19 +1,18 @@
import React, { useCallback, useEffect } from 'react';
import { useLocation, useRouteMatch, useParams } from 'react-router-dom';
import React, { useCallback, useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card } from '@patternfly/react-core';
import { Button } from '@patternfly/react-core';
import { TeamsAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import PaginatedDataList from '../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import TeamAccessListItem from './TeamAccessListItem';
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
const QS_CONFIG = getQSConfig('team', {
page: 1,
@ -22,8 +21,8 @@ const QS_CONFIG = getQSConfig('team', {
});
function TeamAccessList({ i18n }) {
const [isWizardOpen, setIsWizardOpen] = useState(false);
const { search } = useLocation();
const match = useRouteMatch();
const { id } = useParams();
const {
@ -57,6 +56,11 @@ function TeamAccessList({ i18n }) {
fetchRoles();
}, [fetchRoles]);
const saveRoles = () => {
setIsWizardOpen(false);
fetchRoles();
};
const canAdd =
options && Object.prototype.hasOwnProperty.call(options, 'POST');
@ -77,7 +81,7 @@ function TeamAccessList({ i18n }) {
};
return (
<Card>
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
@ -104,7 +108,17 @@ function TeamAccessList({ i18n }) {
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />]
? [
<Button
key="add"
aria-label={i18n._(t`Add resource roles`)}
onClick={() => {
setIsWizardOpen(true);
}}
>
Add
</Button>,
]
: []),
]}
/>
@ -117,13 +131,17 @@ function TeamAccessList({ i18n }) {
onSelect={() => {}}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null
}
/>
</Card>
{isWizardOpen && (
<UserAndTeamAccessAdd
apiModel={TeamsAPI}
isOpen={isWizardOpen}
onSave={saveRoles}
onClose={() => setIsWizardOpen(false)}
title={i18n._(t`Add team permissions`)}
/>
)}
</>
);
}
export default withI18n()(TeamAccessList);

View File

@ -141,4 +141,67 @@ describe('<TeamAccessList />', () => {
'/inventories/smart_inventory/77/details'
);
});
test('should not render add button', async () => {
TeamsAPI.readRoleOptions.mockResolvedValueOnce({
data: {},
});
TeamsAPI.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 },
},
description: 'Can manage all aspects of the job template',
},
],
count: 1,
},
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/teams/:id/access">
<TeamAccessList />
</Route>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 18 } },
},
},
},
}
);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
0
);
});
test('should open and close wizard', async () => {
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(1);
await act(async () =>
wrapper.find("Button[aria-label='Close']").prop('onClick')()
);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(0);
});
});

View File

@ -1,15 +1,16 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { UsersAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import PaginatedDataList from '../../../components/PaginatedDataList';
import DatalistToolbar from '../../../components/DataListToolbar';
import UserAccessListItem from './UserAccessListItem';
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
const QS_CONFIG = getQSConfig('roles', {
page: 1,
@ -22,6 +23,7 @@ const QS_CONFIG = getQSConfig('roles', {
function UserAccessList({ i18n }) {
const { id } = useParams();
const { search } = useLocation();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const {
isLoading,
@ -55,6 +57,11 @@ function UserAccessList({ i18n }) {
const canAdd =
options && Object.prototype.hasOwnProperty.call(options, 'POST');
const saveRoles = () => {
setIsWizardOpen(false);
fetchRoles();
};
const detailUrl = role => {
const { resource_id, resource_type } = role.summary_fields;
@ -72,48 +79,71 @@ function UserAccessList({ i18n }) {
};
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}
<>
<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
? [
<Button
key="add"
aria-label={i18n._(t`Add resource roles`)}
onClick={() => {
setIsWizardOpen(true);
}}
>
Add
</Button>,
]
: []),
]}
/>
);
}}
renderToolbar={props => (
<DatalistToolbar
{...props}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [<ToolbarAddButton key="add" linkTo="/" />] : []),
]}
)}
/>
{isWizardOpen && (
<UserAndTeamAccessAdd
apiModel={UsersAPI}
isOpen={isWizardOpen}
onSave={saveRoles}
onClose={() => setIsWizardOpen(false)}
title={i18n._(t`Add user permissions`)}
/>
)}
/>
</>
);
}
export default withI18n()(UserAccessList);

View File

@ -29,6 +29,7 @@ describe('<UserAccessList />', () => {
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
description: 'Can manage all aspects of the job template',
},
{
id: 3,
@ -42,6 +43,7 @@ describe('<UserAccessList />', () => {
resource_type_display_name: 'Job Template',
user_capabilities: { unattach: true },
},
description: 'Can manage all aspects of the job template',
},
{
id: 4,
@ -55,10 +57,11 @@ describe('<UserAccessList />', () => {
resource_type_display_name: 'Credential',
user_capabilities: { unattach: true },
},
description: 'May run the job template',
},
{
id: 5,
name: 'Update',
name: 'Read',
type: 'role',
url: '/api/v2/roles/259/',
summary_fields: {
@ -68,6 +71,7 @@ describe('<UserAccessList />', () => {
resource_type_display_name: 'Inventory',
user_capabilities: { unattach: true },
},
description: 'May view settings for the job template',
},
{
id: 6,
@ -75,15 +79,16 @@ describe('<UserAccessList />', () => {
type: 'role',
url: '/api/v2/roles/260/',
summary_fields: {
resource_name: 'Smart Inventory Foo',
resource_name: 'Project Foo',
resource_id: 77,
resource_type: 'smart_inventory',
resource_type_display_name: 'Inventory',
resource_type: 'project',
resource_type_display_name: 'Project',
user_capabilities: { unattach: true },
},
description: 'Can manage all aspects of the job template',
},
],
count: 4,
count: 5,
},
});
@ -138,7 +143,86 @@ describe('<UserAccessList />', () => {
'/inventories/inventory/76/details'
);
expect(wrapper.find('Link#userRole-6').prop('to')).toBe(
'/inventories/smart_inventory/77/details'
'/projects/77/details'
);
});
test('should not render add button', async () => {
UsersAPI.readRoleOptions.mockResolvedValueOnce({
data: {},
});
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 },
object_roles: {
admin_role: {
description: 'Can manage all aspects of the job template',
name: 'Admin',
id: 164,
},
execute_role: {
description: 'May run the job template',
name: 'Execute',
id: 165,
},
read_role: {
description: 'May view settings for the job template',
name: 'Read',
id: 166,
},
},
},
},
],
count: 1,
},
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/users/:id/access">
<UserAccessList />
</Route>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 18 } },
},
},
},
}
);
});
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
0
);
});
test('should open and close wizard', async () => {
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () =>
wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(1);
await act(async () =>
wrapper.find("Button[aria-label='Close']").prop('onClick')()
);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(0);
});
});