diff --git a/__tests__/components/AddResourceRole.test.jsx b/__tests__/components/AddResourceRole.test.jsx
new file mode 100644
index 0000000000..19cf538b91
--- /dev/null
+++ b/__tests__/components/AddResourceRole.test.jsx
@@ -0,0 +1,260 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { I18nProvider } from '@lingui/react';
+import AddResourceRole from '../../src/components/AddRole/AddResourceRole';
+
+describe('', () => {
+ 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', () => {
+ mount(
+
+
+
+ );
+ });
+ test('handleRoleCheckboxClick properly updates state', () => {
+ const wrapper = mount(
+
+
+
+ ).find('AddResourceRole');
+ 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 = mount(
+
+
+
+ ).find('AddResourceRole');
+ 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 = mount(
+
+
+
+ ).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 = mount(
+
+
+
+ ).find('AddResourceRole');
+ wrapper.instance().readUsers({
+ foo: 'bar'
+ });
+ expect(readUsers).toHaveBeenCalledWith({
+ foo: 'bar'
+ });
+ wrapper.instance().readTeams({
+ foo: 'bar'
+ });
+ expect(readTeams).toHaveBeenCalledWith({
+ foo: 'bar'
+ });
+ });
+ test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
+ const wrapper = mount(
+
+
+
+ ).find('AddResourceRole');
+ 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 = mount(
+
+
+
+ ).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();
+ });
+});
diff --git a/__tests__/components/CheckboxCard.test.jsx b/__tests__/components/CheckboxCard.test.jsx
new file mode 100644
index 0000000000..de02dca288
--- /dev/null
+++ b/__tests__/components/CheckboxCard.test.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import CheckboxCard from '../../src/components/AddRole/CheckboxCard';
+
+describe('', () => {
+ let wrapper;
+ test('initially renders without crashing', () => {
+ wrapper = mount(
+
+ );
+ expect(wrapper.length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/__tests__/components/SelectResourceStep.test.jsx b/__tests__/components/SelectResourceStep.test.jsx
new file mode 100644
index 0000000000..33af41429b
--- /dev/null
+++ b/__tests__/components/SelectResourceStep.test.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { I18nProvider } from '@lingui/react';
+import SelectResourceStep from '../../src/components/AddRole/SelectResourceStep';
+
+describe('', () => {
+ const columns = [
+ { name: 'Username', key: 'username', isSortable: true }
+ ];
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ test('initially renders without crashing', () => {
+ mount(
+
+
+
+ );
+ });
+ test('fetches resources on mount', async () => {
+ const handleSearch = jest.fn().mockResolvedValue({
+ data: {
+ count: 2,
+ results: [
+ { id: 1, username: 'foo' },
+ { id: 2, username: 'bar' }
+ ]
+ }
+ });
+ mount(
+
+
+
+ );
+ expect(handleSearch).toHaveBeenCalledWith({
+ is_superuser: false,
+ 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 mount(
+
+
+
+ ).find('SelectResourceStep');
+ await wrapper.instance().readResourceList({
+ page: 1,
+ order_by: '-username'
+ });
+ expect(handleSearch).toHaveBeenCalledWith({
+ is_superuser: false,
+ 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 = mount(
+
+
+
+ ).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 = mount(
+
+
+
+ ).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 = mount(
+
+
+
+ );
+ 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' });
+ });
+});
diff --git a/__tests__/components/SelectRoleStep.test.jsx b/__tests__/components/SelectRoleStep.test.jsx
new file mode 100644
index 0000000000..857ec0a9f6
--- /dev/null
+++ b/__tests__/components/SelectRoleStep.test.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import SelectRoleStep from '../../src/components/AddRole/SelectRoleStep';
+
+describe('', () => {
+ 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 = mount(
+
+ );
+ expect(wrapper.length).toBe(1);
+ wrapper.unmount();
+ });
+ test('clicking role fires onRolesClick callback', () => {
+ const onRolesClick = jest.fn();
+ wrapper = mount(
+
+ );
+ 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();
+ });
+});
diff --git a/__tests__/components/SelectableCard.test.jsx b/__tests__/components/SelectableCard.test.jsx
new file mode 100644
index 0000000000..b9608f6333
--- /dev/null
+++ b/__tests__/components/SelectableCard.test.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import SelectableCard from '../../src/components/AddRole/SelectableCard';
+
+describe('', () => {
+ let wrapper;
+ const onClick = jest.fn();
+ test('initially renders without crashing when not selected', () => {
+ wrapper = mount(
+
+ );
+ expect(wrapper.length).toBe(1);
+ wrapper.unmount();
+ });
+ test('initially renders without crashing when selected', () => {
+ wrapper = mount(
+
+ );
+ expect(wrapper.length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 8f85c50e78..356499ff37 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/src/api.js b/src/api.js
index fcf8403fd4..bda5fd5319 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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;
diff --git a/src/components/AddRole/AddResourceRole.jsx b/src/components/AddRole/AddResourceRole.jsx
new file mode 100644
index 0000000000..d24250730b
--- /dev/null
+++ b/src/components/AddRole/AddResourceRole.jsx
@@ -0,0 +1,258 @@
+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(queryParams);
+ }
+
+ 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 }
+ ];
+
+ const steps = [
+ {
+ name: i18nMark('Select Users Or Teams'),
+ component: (
+
+ {({ i18n }) => (
+
+ this.handleResourceSelect('users')}
+ />
+ this.handleResourceSelect('teams')}
+ />
+
+ )}
+
+ ),
+ enableNext: selectedResource !== null
+ },
+ {
+ name: i18nMark('Select items from list'),
+ component: (
+
+ {({ i18n }) => (
+
+ {selectedResource === 'users' && (
+
+ )}
+ {selectedResource === 'teams' && (
+
+ )}
+
+ )}
+
+ ),
+ enableNext: selectedResourceRows.length > 0
+ },
+ {
+ name: i18nMark('Apply roles'),
+ component: (
+
+ {({ i18n }) => (
+
+ )}
+
+ ),
+ enableNext: selectedRoleRows.length > 0
+ }
+ ];
+
+ return (
+
+ {({ i18n }) => (
+
+ )}
+
+ );
+ }
+}
+
+AddResourceRole.propTypes = {
+ onClose: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired,
+ roles: PropTypes.shape()
+};
+
+AddResourceRole.defaultProps = {
+ roles: {}
+};
+
+export default AddResourceRole;
diff --git a/src/components/AddRole/CheckboxCard.jsx b/src/components/AddRole/CheckboxCard.jsx
new file mode 100644
index 0000000000..5486fa05f3
--- /dev/null
+++ b/src/components/AddRole/CheckboxCard.jsx
@@ -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 (
+
+
+ {name}
+ {description}
+
+ )}
+ value={itemId}
+ />
+
+ );
+ }
+}
+
+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;
diff --git a/src/components/AddRole/SelectResourceStep.jsx b/src/components/AddRole/SelectResourceStep.jsx
new file mode 100644
index 0000000000..962277aec1
--- /dev/null
+++ b/src/components/AddRole/SelectResourceStep.jsx
@@ -0,0 +1,216 @@
+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';
+
+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, defaultSearchParams } = 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(Object.assign(queryParams, defaultSearchParams));
+ 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,
+ title
+ } = this.props;
+
+ return (
+
+
+ {(resources.length === 0) ? (
+
+
+
+ {emptyListTitle}
+
+
+ {emptyListBody}
+
+
+ ) : (
+
+
+ {title}
+
+
+
+ {resources.map(i => (
+ item.id === i.id)}
+ itemId={i.id}
+ key={i.id}
+ name={i[displayKey]}
+ onSelect={() => onRowClick(i)}
+ />
+ ))}
+
+
+
+ )}
+
+ {selectedResourceRows.length > 0 && (
+
+ )}
+ { error ? error
: '' }
+
+ );
+ }
+}
+
+SelectResourceStep.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ defaultSearchParams: PropTypes.shape(),
+ 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,
+ title: PropTypes.string
+};
+
+SelectResourceStep.defaultProps = {
+ defaultSearchParams: {},
+ displayKey: 'name',
+ emptyListBody: i18nMark('Please add items to populate this list'),
+ emptyListTitle: i18nMark('No Items Found'),
+ onRowClick: () => {},
+ selectedLabel: i18nMark('Selected Items'),
+ selectedResourceRows: [],
+ sortedColumnKey: 'name',
+ title: ''
+};
+
+export default SelectResourceStep;
diff --git a/src/components/AddRole/SelectRoleStep.jsx b/src/components/AddRole/SelectRoleStep.jsx
new file mode 100644
index 0000000000..e0d052d76a
--- /dev/null
+++ b/src/components/AddRole/SelectRoleStep.jsx
@@ -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 (
+
+
+ {selectedResourceRows.length > 0 && (
+
+ )}
+
+
+ {Object.keys(roles).map(role => (
+ item.id === roles[role].id)
+ }
+ key={roles[role].id}
+ name={roles[role].name}
+ onSelect={() => onRolesClick(roles[role])}
+ />
+ ))}
+
+
+ );
+ }
+}
+
+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;
diff --git a/src/components/AddRole/SelectableCard.jsx b/src/components/AddRole/SelectableCard.jsx
new file mode 100644
index 0000000000..150bae29ad
--- /dev/null
+++ b/src/components/AddRole/SelectableCard.jsx
@@ -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 (
+
+ );
+ }
+}
+
+SelectableCard.propTypes = {
+ label: PropTypes.string,
+ onClick: PropTypes.func.isRequired,
+ isSelected: PropTypes.bool
+};
+
+SelectableCard.defaultProps = {
+ label: '',
+ isSelected: false
+};
+
+export default SelectableCard;
diff --git a/src/components/AddRole/styles.scss b/src/components/AddRole/styles.scss
new file mode 100644
index 0000000000..21a8a73989
--- /dev/null
+++ b/src/components/AddRole/styles.scss
@@ -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 10px;
+ }
+
+ .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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/DataListToolbar/DataListToolbar.jsx b/src/components/DataListToolbar/DataListToolbar.jsx
index d77ab68ae3..cc5b387902 100644
--- a/src/components/DataListToolbar/DataListToolbar.jsx
+++ b/src/components/DataListToolbar/DataListToolbar.jsx
@@ -39,6 +39,7 @@ class DataListToolbar extends React.Component {
isAllSelected,
isLookup,
isCompact,
+ noLeftMargin,
onSort,
onSearch,
onCompact,
@@ -54,7 +55,7 @@ class DataListToolbar extends React.Component {
-
+
{ showSelectAll && (
@@ -152,6 +153,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 +180,8 @@ DataListToolbar.defaultProps = {
onCompact: null,
onExpand: null,
isCompact: false,
- add: null
+ add: null,
+ noLeftMargin: false
};
export default DataListToolbar;
diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx
index f4f77cae6b..6d74e96e55 100644
--- a/src/components/Lookup/Lookup.jsx
+++ b/src/components/Lookup/Lookup.jsx
@@ -218,7 +218,7 @@ class Lookup extends React.Component {
columns={columns}
onSearch={this.onSearch}
onSort={this.onSort}
- isLookup
+ noLeftMargin
/>