1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-01 08:21:15 +03:00

Merge pull request #143 from mabashian/wizard-access-list

Add roles modal to org access list
This commit is contained in:
Michael Abashian 2019-04-23 13:12:31 -04:00 committed by GitHub
commit 1509ef3e80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1299 additions and 26 deletions

View File

@ -0,0 +1,247 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../enzymeHelpers';
import AddResourceRole from '../../src/components/AddRole/AddResourceRole';
describe('<SelectResourceStep />', () => {
const readUsers = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' }
]
}
});
const readTeams = jest.fn();
const createUserRole = jest.fn();
const createTeamRole = jest.fn();
const api = { readUsers, readTeams, createUserRole, createTeamRole };
const roles = {
admin_role: {
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin'
},
execute_role: {
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute'
}
};
test('initially renders without crashing', () => {
shallow(
<AddResourceRole
api={api}
onClose={() => {}}
onSave={() => {}}
roles={roles}
/>
);
});
test('handleRoleCheckboxClick properly updates state', () => {
const wrapper = shallow(
<AddResourceRole
api={api}
onClose={() => {}}
onSave={() => {}}
roles={roles}
/>
);
wrapper.setState({
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1
}
]
});
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1
});
expect(wrapper.state('selectedRoleRows')).toEqual([]);
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1
});
expect(wrapper.state('selectedRoleRows')).toEqual([{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1
}]);
});
test('handleResourceCheckboxClick properly updates state', () => {
const wrapper = shallow(
<AddResourceRole
api={api}
onClose={() => {}}
onSave={() => {}}
roles={roles}
/>
);
wrapper.setState({
selectedResourceRows: [
{
id: 1,
username: 'foobar'
}
]
});
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar'
});
expect(wrapper.state('selectedResourceRows')).toEqual([]);
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar'
});
expect(wrapper.state('selectedResourceRows')).toEqual([{
id: 1,
username: 'foobar'
}]);
});
test('clicking user/team cards updates state', () => {
const spy = jest.spyOn(AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts(
<AddResourceRole
onClose={() => {}}
onSave={() => {}}
api={api}
roles={roles}
/>
).find('AddResourceRole');
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2);
selectableCardWrapper.first().simulate('click');
expect(spy).toHaveBeenCalledWith('users');
expect(wrapper.state('selectedResource')).toBe('users');
selectableCardWrapper.at(1).simulate('click');
expect(spy).toHaveBeenCalledWith('teams');
expect(wrapper.state('selectedResource')).toBe('teams');
});
test('readUsers and readTeams call out to corresponding api functions', () => {
const wrapper = shallow(
<AddResourceRole
api={api}
onClose={() => {}}
onSave={() => {}}
roles={roles}
/>
);
wrapper.instance().readUsers({
foo: 'bar'
});
expect(readUsers).toHaveBeenCalledWith({
foo: 'bar',
is_superuser: false
});
wrapper.instance().readTeams({
foo: 'bar'
});
expect(readTeams).toHaveBeenCalledWith({
foo: 'bar'
});
});
test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
const wrapper = shallow(
<AddResourceRole
api={api}
onClose={() => {}}
onSave={() => {}}
roles={roles}
/>
);
wrapper.setState({
selectedResource: 'teams',
selectedResourceRows: [
{
id: 1,
username: 'foobar'
}
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin'
}
]
});
wrapper.instance().handleResourceSelect('users');
expect(wrapper.state()).toEqual({
selectedResource: 'users',
selectedResourceRows: [],
selectedRoleRows: []
});
wrapper.instance().handleResourceSelect('teams');
expect(wrapper.state()).toEqual({
selectedResource: 'teams',
selectedResourceRows: [],
selectedRoleRows: []
});
});
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
const handleSave = jest.fn();
const wrapper = mountWithContexts(
<AddResourceRole
api={api}
onClose={() => {}}
onSave={handleSave}
roles={roles}
/>
).find('AddResourceRole');
wrapper.setState({
selectedResource: 'users',
selectedResourceRows: [
{
id: 1,
username: 'foobar'
}
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin'
},
{
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute'
}
]
});
await wrapper.instance().handleWizardSave();
expect(createUserRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
wrapper.setState({
selectedResource: 'teams',
selectedResourceRows: [
{
id: 1,
name: 'foobar'
}
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin'
},
{
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute'
}
]
});
await wrapper.instance().handleWizardSave();
expect(createTeamRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,16 @@
import React from 'react';
import { shallow } from 'enzyme';
import CheckboxCard from '../../src/components/AddRole/CheckboxCard';
describe('<CheckboxCard />', () => {
let wrapper;
test('initially renders without crashing', () => {
wrapper = shallow(
<CheckboxCard
name="Foobar"
itemId={5}
/>
);
expect(wrapper.length).toBe(1);
});
});

View File

@ -0,0 +1,147 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../enzymeHelpers';
import SelectResourceStep from '../../src/components/AddRole/SelectResourceStep';
describe('<SelectResourceStep />', () => {
const columns = [
{ name: 'Username', key: 'username', isSortable: true }
];
afterEach(() => {
jest.restoreAllMocks();
});
test('initially renders without crashing', () => {
shallow(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
sortedColumnKey="username"
/>
);
});
test('fetches resources on mount', async () => {
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' }
]
}
});
mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
sortedColumnKey="username"
/>
);
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'
}
];
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' }
]
}
});
const wrapper = await mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>
).find('SelectResourceStep');
await wrapper.instance().readResourceList({
page: 1,
order_by: '-username'
});
expect(handleSearch).toHaveBeenCalledWith({
order_by: '-username',
page: 1
});
expect(wrapper.state('resources')).toEqual([
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' }
]);
});
test('handleSetPage calls readResourceList with correct params', () => {
const spy = jest.spyOn(SelectResourceStep.prototype, 'readResourceList');
const wrapper = mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
selectedResourceRows={[]}
sortedColumnKey="username"
/>
).find('SelectResourceStep');
wrapper.setState({ sortOrder: 'descending' });
wrapper.instance().handleSetPage(2);
expect(spy).toHaveBeenCalledWith({ page: 2, page_size: 5, order_by: '-username' });
wrapper.setState({ sortOrder: 'ascending' });
wrapper.instance().handleSetPage(2);
expect(spy).toHaveBeenCalledWith({ page: 2, page_size: 5, order_by: 'username' });
});
test('handleSort calls readResourceList with correct params', () => {
const spy = jest.spyOn(SelectResourceStep.prototype, 'readResourceList');
const wrapper = mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
selectedResourceRows={[]}
sortedColumnKey="username"
/>
).find('SelectResourceStep');
wrapper.instance().handleSort('username', 'descending');
expect(spy).toHaveBeenCalledWith({ page: 1, page_size: 5, order_by: '-username' });
wrapper.instance().handleSort('username', 'ascending');
expect(spy).toHaveBeenCalledWith({ page: 1, page_size: 5, order_by: 'username' });
});
test('clicking on row fires callback with correct params', () => {
const handleRowClick = jest.fn();
const wrapper = mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={handleRowClick}
onSearch={() => {}}
selectedResourceRows={[]}
sortedColumnKey="username"
/>
);
const selectResourceStepWrapper = wrapper.find('SelectResourceStep');
selectResourceStepWrapper.setState({
resources: [
{ id: 1, username: 'foo' }
]
});
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
expect(checkboxListItemWrapper.length).toBe(1);
checkboxListItemWrapper.first().find('input[type="checkbox"]').simulate('change', { target: { checked: true } });
expect(handleRowClick).toHaveBeenCalledWith({ id: 1, username: 'foo' });
});
});

View File

@ -0,0 +1,63 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import SelectRoleStep from '../../src/components/AddRole/SelectRoleStep';
describe('<SelectRoleStep />', () => {
let wrapper;
const roles = {
project_admin_role: {
id: 1,
name: 'Project Admin',
description: 'Can manage all projects of the organization'
},
execute_role: {
id: 2,
name: 'Execute',
description: 'May run any executable resources in the organization'
}
};
const selectedRoles = [
{
id: 1,
name: 'Project Admin',
description: 'Can manage all projects of the organization'
}
];
const selectedResourceRows = [
{
id: 1,
name: 'foo'
}
];
test('initially renders without crashing', () => {
wrapper = shallow(
<SelectRoleStep
roles={roles}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoles}
/>
);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
test('clicking role fires onRolesClick callback', () => {
const onRolesClick = jest.fn();
wrapper = mount(
<SelectRoleStep
onRolesClick={onRolesClick}
roles={roles}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoles}
/>
);
const CheckboxCards = wrapper.find('CheckboxCard');
expect(CheckboxCards.length).toBe(2);
CheckboxCards.first().prop('onSelect')();
expect(onRolesClick).toBeCalledWith({
id: 1,
name: 'Project Admin',
description: 'Can manage all projects of the organization'
});
wrapper.unmount();
});
});

View File

@ -0,0 +1,27 @@
import React from 'react';
import { shallow } from 'enzyme';
import SelectableCard from '../../src/components/AddRole/SelectableCard';
describe('<SelectableCard />', () => {
let wrapper;
const onClick = jest.fn();
test('initially renders without crashing when not selected', () => {
wrapper = shallow(
<SelectableCard
onClick={onClick}
/>
);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
test('initially renders without crashing when selected', () => {
wrapper = shallow(
<SelectableCard
isSelected
onClick={onClick}
/>
);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
});

12
package-lock.json generated
View File

@ -2219,7 +2219,7 @@
},
"ansi-colors": {
"version": "1.1.0",
"resolved": "http://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
"integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==",
"requires": {
"ansi-wrap": "^0.1.0"
@ -3239,12 +3239,12 @@
},
"babel-plugin-syntax-class-properties": {
"version": "6.13.0",
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz",
"integrity": "sha1-1+sjt5oxf4VDlixQW4J8fWysJ94="
},
"babel-plugin-syntax-flow": {
"version": "6.18.0",
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz",
"integrity": "sha1-TDqyCiryaqIM0lmVw5jE63AxDI0="
},
"babel-plugin-syntax-jsx": {
@ -5421,7 +5421,7 @@
},
"readable-stream": {
"version": "2.3.6",
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
@ -6640,7 +6640,7 @@
},
"readable-stream": {
"version": "2.3.6",
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
@ -10625,7 +10625,7 @@
},
"kind-of": {
"version": "1.1.0",
"resolved": "http://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz",
"integrity": "sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ="
},
"kleur": {

View File

@ -5,6 +5,8 @@ const API_V2 = `${API_ROOT}v2/`;
const API_CONFIG = `${API_V2}config/`;
const API_ORGANIZATIONS = `${API_V2}organizations/`;
const API_INSTANCE_GROUPS = `${API_V2}instance_groups/`;
const API_USERS = `${API_V2}users/`;
const API_TEAMS = `${API_V2}teams/`;
const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
@ -140,6 +142,22 @@ class APIClient {
disassociate (url, id) {
return this.http.post(url, { id, disassociate: true });
}
readUsers (params) {
return this.http.get(API_USERS, { params });
}
readTeams (params) {
return this.http.get(API_TEAMS, { params });
}
createUserRole (userId, roleId) {
return this.http.post(`${API_USERS}${userId}/roles/`, { id: roleId });
}
createTeamRole (teamId, roleId) {
return this.http.post(`${API_TEAMS}${teamId}/roles/`, { id: roleId });
}
}
export default APIClient;

View File

@ -0,0 +1,266 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { I18n, i18nMark } from '@lingui/react';
import { t } from '@lingui/macro';
import {
BackgroundImageSrc,
Wizard
} from '@patternfly/react-core';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import SelectableCard from './SelectableCard';
class AddResourceRole extends React.Component {
constructor (props) {
super(props);
this.state = {
selectedResource: null,
selectedResourceRows: [],
selectedRoleRows: []
};
this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind(this);
this.handleResourceSelect = this.handleResourceSelect.bind(this);
this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this);
this.handleWizardSave = this.handleWizardSave.bind(this);
this.readTeams = this.readTeams.bind(this);
this.readUsers = this.readUsers.bind(this);
}
handleResourceCheckboxClick (user) {
const { selectedResourceRows } = this.state;
const selectedIndex = selectedResourceRows
.findIndex(selectedRow => selectedRow.id === user.id);
if (selectedIndex > -1) {
selectedResourceRows.splice(selectedIndex, 1);
this.setState({ selectedResourceRows });
} else {
this.setState(prevState => ({
selectedResourceRows: [...prevState.selectedResourceRows, user]
}));
}
}
handleRoleCheckboxClick (role) {
const { selectedRoleRows } = this.state;
const selectedIndex = selectedRoleRows
.findIndex(selectedRow => selectedRow.id === role.id);
if (selectedIndex > -1) {
selectedRoleRows.splice(selectedIndex, 1);
this.setState({ selectedRoleRows });
} else {
this.setState(prevState => ({
selectedRoleRows: [...prevState.selectedRoleRows, role]
}));
}
}
handleResourceSelect (resourceType) {
this.setState({
selectedResource: resourceType,
selectedResourceRows: [],
selectedRoleRows: []
});
}
async handleWizardSave () {
const {
onSave,
api
} = this.props;
const {
selectedResourceRows,
selectedRoleRows,
selectedResource
} = this.state;
try {
const roleRequests = [];
for (let i = 0; i < selectedResourceRows.length; i++) {
for (let j = 0; j < selectedRoleRows.length; j++) {
if (selectedResource === 'users') {
roleRequests.push(
api.createUserRole(selectedResourceRows[i].id, selectedRoleRows[j].id)
);
} else if (selectedResource === 'teams') {
roleRequests.push(
api.createTeamRole(selectedResourceRows[i].id, selectedRoleRows[j].id)
);
}
}
}
await Promise.all(roleRequests);
onSave();
} catch (err) {
// TODO: handle this error
}
}
async readUsers (queryParams) {
const { api } = this.props;
return api.readUsers(Object.assign(queryParams, { is_superuser: false }));
}
async readTeams (queryParams) {
const { api } = this.props;
return api.readTeams(queryParams);
}
render () {
const {
selectedResource,
selectedResourceRows,
selectedRoleRows
} = this.state;
const {
onClose,
roles
} = this.props;
const images = {
[BackgroundImageSrc.xs]: '/assets/images/pfbg_576.jpg',
[BackgroundImageSrc.xs2x]: '/assets/images/pfbg_576@2x.jpg',
[BackgroundImageSrc.sm]: '/assets/images/pfbg_768.jpg',
[BackgroundImageSrc.sm2x]: '/assets/images/pfbg_768@2x.jpg',
[BackgroundImageSrc.lg]: '/assets/images/pfbg_2000.jpg',
[BackgroundImageSrc.filter]: '/assets/images/background-filter.svg#image_overlay'
};
const userColumns = [
{ name: i18nMark('Username'), key: 'username', isSortable: true }
];
const teamColumns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true }
];
let wizardTitle = '';
switch (selectedResource) {
case 'users':
wizardTitle = i18nMark('Add User Roles');
break;
case 'teams':
wizardTitle = i18nMark('Add Team Roles');
break;
default:
wizardTitle = i18nMark('Add Roles');
}
const steps = [
{
name: i18nMark('Select Users Or Teams'),
component: (
<I18n>
{({ i18n }) => (
<div style={{ display: 'flex' }}>
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
onClick={() => this.handleResourceSelect('users')}
/>
<SelectableCard
isSelected={selectedResource === 'teams'}
label={i18n._(t`Teams`)}
onClick={() => this.handleResourceSelect('teams')}
/>
</div>
)}
</I18n>
),
enableNext: selectedResource !== null
},
{
name: i18nMark('Select items from list'),
component: (
<I18n>
{({ i18n }) => (
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
columns={userColumns}
displayKey="username"
emptyListBody={i18n._(t`Please add users to populate this list`)}
emptyListTitle={i18n._(t`No Users Found`)}
onRowClick={this.handleResourceCheckboxClick}
onSearch={this.readUsers}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>
)}
{selectedResource === 'teams' && (
<SelectResourceStep
columns={teamColumns}
emptyListBody={i18n._(t`Please add teams to populate this list`)}
emptyListTitle={i18n._(t`No Teams Found`)}
onRowClick={this.handleResourceCheckboxClick}
onSearch={this.readTeams}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>
)}
</Fragment>
)}
</I18n>
),
enableNext: selectedResourceRows.length > 0
},
{
name: i18nMark('Apply roles'),
component: (
<I18n>
{({ i18n }) => (
<SelectRoleStep
onRolesClick={this.handleRoleCheckboxClick}
roles={roles}
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoleRows}
/>
)}
</I18n>
),
enableNext: selectedRoleRows.length > 0
}
];
return (
<I18n>
{({ i18n }) => (
<Wizard
backgroundImgSrc={images}
footerRightAlign
isOpen
lastStepButtonText={i18n._(t`Save`)}
onClose={onClose}
onSave={this.handleWizardSave}
steps={steps}
title={wizardTitle}
/>
)}
</I18n>
);
}
}
AddResourceRole.propTypes = {
onClose: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
roles: PropTypes.shape()
};
AddResourceRole.defaultProps = {
roles: {}
};
export default AddResourceRole;

View File

@ -0,0 +1,50 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import {
Checkbox
} from '@patternfly/react-core';
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)',
borderRadius: 'var(--pf-global--BorderRadius--sm)',
padding: '10px'
}}
>
<Checkbox
checked={isSelected}
onChange={onSelect}
aria-label={name}
id={`checkbox-card-${itemId}`}
label={(
<Fragment>
<div style={{ fontWeight: 'bold' }}>{name}</div>
<div>{description}</div>
</Fragment>
)}
value={itemId}
/>
</div>
);
}
}
CheckboxCard.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
isSelected: PropTypes.bool,
onSelect: PropTypes.func,
itemId: PropTypes.number.isRequired
};
CheckboxCard.defaultProps = {
description: '',
isSelected: false,
onSelect: null
};
export default CheckboxCard;

View File

@ -0,0 +1,217 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { i18nMark } from '@lingui/react';
import {
EmptyState,
EmptyStateBody,
EmptyStateIcon,
Title,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import CheckboxListItem from '../ListItem';
import DataListToolbar from '../DataListToolbar';
import Pagination from '../Pagination';
import SelectedList from '../SelectedList';
const paginationStyling = {
paddingLeft: '0',
justifyContent: 'flex-end',
borderRight: '1px solid #ebebeb',
borderBottom: '1px solid #ebebeb',
borderTop: '0'
};
class SelectResourceStep extends React.Component {
constructor (props) {
super(props);
const { sortedColumnKey } = this.props;
this.state = {
count: null,
error: false,
page: 1,
page_size: 5,
resources: [],
sortOrder: 'ascending',
sortedColumnKey
};
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSort = this.handleSort.bind(this);
this.readResourceList = this.readResourceList.bind(this);
}
componentDidMount () {
const { page_size, page, sortedColumnKey } = this.state;
this.readResourceList({ page_size, page, order_by: sortedColumnKey });
}
handleSetPage (pageNumber) {
const { page_size, sortedColumnKey, sortOrder } = this.state;
const page = parseInt(pageNumber, 10);
let order_by = sortedColumnKey;
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
this.readResourceList({ page_size, page, order_by });
}
handleSort (sortedColumnKey, sortOrder) {
const { page_size } = this.state;
let order_by = sortedColumnKey;
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
this.readResourceList({ page: 1, page_size, order_by });
}
async readResourceList (queryParams) {
const { onSearch } = this.props;
const { page, order_by } = queryParams;
let sortOrder = 'ascending';
let sortedColumnKey = order_by;
if (order_by.startsWith('-')) {
sortOrder = 'descending';
sortedColumnKey = order_by.substring(1);
}
this.setState({ error: false });
try {
const { data } = await onSearch(queryParams);
const { count, results } = data;
const stateToUpdate = {
count,
page,
resources: results,
sortOrder,
sortedColumnKey
};
this.setState(stateToUpdate);
} catch (err) {
this.setState({ error: true });
}
}
render () {
const {
count,
error,
page,
page_size,
resources,
sortOrder,
sortedColumnKey
} = this.state;
const {
columns,
displayKey,
emptyListBody,
emptyListTitle,
onRowClick,
selectedLabel,
selectedResourceRows
} = this.props;
return (
<Fragment>
<Fragment>
{(resources.length === 0) ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
{emptyListTitle}
</Title>
<EmptyStateBody>
{emptyListBody}
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
showOverflowAfter={5}
/>
)}
<DataListToolbar
columns={columns}
noLeftMargin
onSearch={this.onSearch}
handleSort={this.handleSort}
sortOrder={sortOrder}
sortedColumnKey={sortedColumnKey}
/>
<ul className="pf-c-data-list awx-c-list">
{resources.map(i => (
<CheckboxListItem
isSelected={selectedResourceRows.some(item => item.id === i.id)}
itemId={i.id}
key={i.id}
name={i[displayKey]}
onSelect={() => onRowClick(i)}
/>
))}
</ul>
<Pagination
count={count}
onSetPage={this.handleSetPage}
page={page}
pageCount={Math.ceil(count / page_size)}
pageSizeOptions={null}
page_size={page_size}
showPageSizeOptions={false}
style={paginationStyling}
/>
</Fragment>
)}
</Fragment>
{ error ? <div>error</div> : '' }
</Fragment>
);
}
}
SelectResourceStep.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
displayKey: PropTypes.string,
emptyListBody: PropTypes.string,
emptyListTitle: PropTypes.string,
onRowClick: PropTypes.func,
onSearch: PropTypes.func.isRequired,
selectedLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
sortedColumnKey: PropTypes.string
};
SelectResourceStep.defaultProps = {
displayKey: 'name',
emptyListBody: i18nMark('Please add items to populate this list'),
emptyListTitle: i18nMark('No Items Found'),
onRowClick: () => {},
selectedLabel: i18nMark('Selected Items'),
selectedResourceRows: [],
sortedColumnKey: 'name'
};
export default SelectResourceStep;

View File

@ -0,0 +1,69 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { i18nMark } from '@lingui/react';
import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList';
class RolesStep extends React.Component {
render () {
const {
onRolesClick,
roles,
selectedListKey,
selectedListLabel,
selectedResourceRows,
selectedRoleRows
} = this.props;
return (
<Fragment>
<div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={selectedListKey}
isReadOnly
label={selectedListLabel}
selected={selectedResourceRows}
showOverflowAfter={5}
/>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px 20px', marginTop: '20px' }}>
{Object.keys(roles).map(role => (
<CheckboxCard
description={roles[role].description}
itemId={roles[role].id}
isSelected={
selectedRoleRows.some(item => item.id === roles[role].id)
}
key={roles[role].id}
name={roles[role].name}
onSelect={() => onRolesClick(roles[role])}
/>
))}
</div>
</Fragment>
);
}
}
RolesStep.propTypes = {
onRolesClick: PropTypes.func,
roles: PropTypes.objectOf(PropTypes.object).isRequired,
selectedListKey: PropTypes.string,
selectedListLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
selectedRoleRows: PropTypes.arrayOf(PropTypes.object)
};
RolesStep.defaultProps = {
onRolesClick: () => {},
selectedListKey: 'name',
selectedListLabel: i18nMark('Selected'),
selectedResourceRows: [],
selectedRoleRows: []
};
export default RolesStep;

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
class SelectableCard extends Component {
render () {
const {
label,
onClick,
isSelected
} = this.props;
return (
<div
className={isSelected ? 'awx-selectableCard awx-selectableCard__selected' : 'awx-selectableCard'}
onClick={onClick}
onKeyPress={onClick}
role="button"
tabIndex="0"
>
<div
className="awx-selectableCard__indicator"
/>
<div className="awx-selectableCard__label">{label}</div>
</div>
);
}
}
SelectableCard.propTypes = {
label: PropTypes.string,
onClick: PropTypes.func.isRequired,
isSelected: PropTypes.bool
};
SelectableCard.defaultProps = {
label: '',
isSelected: false
};
export default SelectableCard;

View File

@ -0,0 +1,28 @@
.awx-selectableCard {
min-width: 200px;
border: 1px solid var(--pf-global--BorderColor);
border-radius: var(--pf-global--BorderRadius--sm);
margin-right: 20px;
font-weight: bold;
display: flex;
.awx-selectableCard__indicator {
display: flex;
flex: 0 0 5px;
}
.awx-selectableCard__label {
display: flex;
flex: 1;
align-items: center;
padding: 20px;
}
}
.awx-selectableCard.awx-selectableCard__selected {
border-color: var(--pf-global--active-color--100);
.awx-selectableCard__indicator {
background-color: var(--pf-global--active-color--100);
}
}

View File

@ -37,8 +37,8 @@ class DataListToolbar extends React.Component {
showDelete,
showSelectAll,
isAllSelected,
isLookup,
isCompact,
noLeftMargin,
onSort,
onSearch,
onCompact,
@ -54,7 +54,7 @@ class DataListToolbar extends React.Component {
<div className="awx-toolbar">
<Level>
<LevelItem style={{ display: 'flex', flexBasis: '700px' }}>
<Toolbar style={{ marginLeft: isLookup ? '0px' : '20px', flexGrow: '1' }}>
<Toolbar style={{ marginLeft: noLeftMargin ? '0px' : '20px', flexGrow: '1' }}>
{ showSelectAll && (
<ToolbarGroup>
<ToolbarItem>
@ -152,6 +152,7 @@ DataListToolbar.propTypes = {
addUrl: PropTypes.string,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isAllSelected: PropTypes.bool,
noLeftMargin: PropTypes.bool,
onSearch: PropTypes.func,
onSelectAll: PropTypes.func,
onSort: PropTypes.func,
@ -178,7 +179,8 @@ DataListToolbar.defaultProps = {
onCompact: null,
onExpand: null,
isCompact: false,
add: null
add: null,
noLeftMargin: false
};
export default DataListToolbar;

View File

@ -218,7 +218,7 @@ class Lookup extends React.Component {
columns={columns}
onSearch={this.onSearch}
onSort={this.onSort}
isLookup
noLeftMargin
/>
<ul className="pf-c-data-list awx-c-list">
{results.map(i => (

View File

@ -5,6 +5,7 @@
--awx-pagination--disabled-Color: #C2C2CA;
border-top: 1px solid var(--awx-pagination--BorderColor);
border-bottom: 1px solid var(--awx-pagination--BorderColor);
background-color: var(--awx-pagination--BackgroundColor);
height: 55px;
display: flex;

View File

@ -1,9 +1,10 @@
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import {
Chip
} from '@patternfly/react-core';
import BasicChip from '../BasicChip/BasicChip';
import VerticalSeparator from '../VerticalSeparator';
const selectedRowStyling = {
@ -35,8 +36,16 @@ class SelectedList extends Component {
};
render () {
const { label, selected, showOverflowAfter, onRemove } = this.props;
const {
label,
selected,
showOverflowAfter,
onRemove,
displayKey,
isReadOnly
} = this.props;
const { showOverflow } = this.state;
const visibleItems = selected.slice(0, showOverflow ? selected.length : showOverflowAfter);
return (
<div className="awx-selectedList">
<div className="pf-l-split" style={selectedRowStyling}>
@ -46,16 +55,32 @@ class SelectedList extends Component {
<VerticalSeparator />
<div className="pf-l-split__item">
<div className="pf-c-chip-group">
{selected
.slice(0, showOverflow ? selected.length : showOverflowAfter)
.map(selectedItem => (
<Chip
key={selectedItem.id}
onClick={() => onRemove(selectedItem)}
>
{selectedItem.name}
</Chip>
))}
{isReadOnly ? (
<Fragment>
{visibleItems
.map(selectedItem => (
<BasicChip
key={selectedItem.id}
>
{selectedItem[displayKey]}
</BasicChip>
))
}
</Fragment>
) : (
<Fragment>
{visibleItems
.map(selectedItem => (
<Chip
key={selectedItem.id}
onClick={() => onRemove(selectedItem)}
>
{selectedItem[displayKey]}
</Chip>
))
}
</Fragment>
)}
{(
!showOverflow
&& selected.length > showOverflowAfter
@ -76,15 +101,20 @@ class SelectedList extends Component {
}
SelectedList.propTypes = {
displayKey: PropTypes.string,
label: PropTypes.string,
onRemove: PropTypes.func.isRequired,
onRemove: PropTypes.func,
selected: PropTypes.arrayOf(PropTypes.object).isRequired,
showOverflowAfter: PropTypes.number,
isReadOnly: PropTypes.bool
};
SelectedList.defaultProps = {
displayKey: 'name',
label: 'Selected',
onRemove: () => null,
showOverflowAfter: 5,
isReadOnly: false
};
export default SelectedList;

View File

@ -15,6 +15,7 @@ import './app.scss';
import './components/Pagination/styles.scss';
import './components/DataListToolbar/styles.scss';
import './components/SelectedList/styles.scss';
import './components/AddRole/styles.scss';
import { Config } from './contexts/Config';

View File

@ -6,6 +6,10 @@ import {
TextContent, TextVariants, Chip, Button
} from '@patternfly/react-core';
import {
PlusIcon,
} from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
@ -19,6 +23,7 @@ import { withNetwork } from '../../../contexts/Network';
import AlertModal from '../../../components/AlertModal';
import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar';
import AddResourceRole from '../../../components/AddRole/AddResourceRole';
import {
parseQueryString,
@ -109,6 +114,7 @@ class OrganizationAccessList extends React.Component {
deleteRoleId: null,
deleteResourceId: null,
results: [],
isModalOpen: false
};
this.fetchOrgAccessList = this.fetchOrgAccessList.bind(this);
@ -119,6 +125,8 @@ class OrganizationAccessList extends React.Component {
this.handleWarning = this.handleWarning.bind(this);
this.hideWarning = this.hideWarning.bind(this);
this.confirmDelete = this.confirmDelete.bind(this);
this.handleModalToggle = this.handleModalToggle.bind(this);
this.handleSuccessfulRoleAdd = this.handleSuccessfulRoleAdd.bind(this);
}
componentDidMount () {
@ -282,6 +290,22 @@ class OrganizationAccessList extends React.Component {
});
}
handleSuccessfulRoleAdd () {
this.handleModalToggle();
const queryParams = this.getQueryParams();
try {
this.fetchOrgAccessList(queryParams);
} catch (error) {
this.setState({ error });
}
}
handleModalToggle () {
this.setState((prevState) => ({
isModalOpen: !prevState.isModalOpen,
}));
}
hideWarning () {
this.setState({ showWarning: false });
}
@ -303,8 +327,13 @@ class OrganizationAccessList extends React.Component {
sortOrder,
warningMsg,
warningTitle,
showWarning
showWarning,
isModalOpen
} = this.state;
const {
api,
organization
} = this.props;
return (
<I18n>
{({ i18n }) => (
@ -328,6 +357,25 @@ class OrganizationAccessList extends React.Component {
columns={this.columns}
onSearch={() => { }}
onSort={this.onSort}
add={(
<Fragment>
<Button
variant="primary"
aria-label={i18n._(t`Add Access Role`)}
onClick={this.handleModalToggle}
>
<PlusIcon />
</Button>
{isModalOpen && (
<AddResourceRole
onClose={this.handleModalToggle}
onSave={this.handleSuccessfulRoleAdd}
api={api}
roles={organization.summary_fields.object_roles}
/>
)}
</Fragment>
)}
/>
{showWarning && (
<AlertModal
@ -419,7 +467,7 @@ class OrganizationAccessList extends React.Component {
OrganizationAccessList.propTypes = {
getAccessList: PropTypes.func.isRequired,
removeRole: PropTypes.func.isRequired,
removeRole: PropTypes.func.isRequired
};
export { OrganizationAccessList as _OrganizationAccessList };

View File

@ -158,7 +158,9 @@ class Organization extends Component {
<Route
path="/organizations/:id/access"
render={() => (
<OrganizationAccess />
<OrganizationAccess
organization={organization}
/>
)}
/>
<Route

View File

@ -23,10 +23,12 @@ class OrganizationAccess extends React.Component {
}
render () {
const { organization } = this.props;
return (
<OrganizationAccessList
getAccessList={this.getOrgAccessList}
removeRole={this.removeRole}
organization={organization}
/>
);
}