From a1002b03fa33c591b6952358f33c5ca3de067f2b Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 27 Mar 2019 17:27:27 -0400 Subject: [PATCH] Add roles modal to org access list --- __tests__/components/AddResourceRole.test.jsx | 260 ++++++++++++++++++ __tests__/components/CheckboxCard.test.jsx | 17 ++ .../components/SelectResourceStep.test.jsx | 170 ++++++++++++ __tests__/components/SelectRoleStep.test.jsx | 63 +++++ __tests__/components/SelectableCard.test.jsx | 27 ++ package-lock.json | 12 +- src/api.js | 18 ++ src/components/AddRole/AddResourceRole.jsx | 258 +++++++++++++++++ src/components/AddRole/CheckboxCard.jsx | 50 ++++ src/components/AddRole/SelectResourceStep.jsx | 216 +++++++++++++++ src/components/AddRole/SelectRoleStep.jsx | 69 +++++ src/components/AddRole/SelectableCard.jsx | 39 +++ src/components/AddRole/styles.scss | 28 ++ .../DataListToolbar/DataListToolbar.jsx | 7 +- src/components/Lookup/Lookup.jsx | 2 +- src/components/Pagination/styles.scss | 1 + src/components/SelectedList/SelectedList.jsx | 56 +++- src/index.jsx | 1 + .../components/OrganizationAccessList.jsx | 52 +++- 19 files changed, 1322 insertions(+), 24 deletions(-) create mode 100644 __tests__/components/AddResourceRole.test.jsx create mode 100644 __tests__/components/CheckboxCard.test.jsx create mode 100644 __tests__/components/SelectResourceStep.test.jsx create mode 100644 __tests__/components/SelectRoleStep.test.jsx create mode 100644 __tests__/components/SelectableCard.test.jsx create mode 100644 src/components/AddRole/AddResourceRole.jsx create mode 100644 src/components/AddRole/CheckboxCard.jsx create mode 100644 src/components/AddRole/SelectResourceStep.jsx create mode 100644 src/components/AddRole/SelectRoleStep.jsx create mode 100644 src/components/AddRole/SelectableCard.jsx create mode 100644 src/components/AddRole/styles.scss 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 ( +
+
+
{label}
+
+ ); + } +} + +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 />
    {results.map(i => ( diff --git a/src/components/Pagination/styles.scss b/src/components/Pagination/styles.scss index 78141870ba..e238b25266 100644 --- a/src/components/Pagination/styles.scss +++ b/src/components/Pagination/styles.scss @@ -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; diff --git a/src/components/SelectedList/SelectedList.jsx b/src/components/SelectedList/SelectedList.jsx index eb6bd995d0..bf3b295d6d 100644 --- a/src/components/SelectedList/SelectedList.jsx +++ b/src/components/SelectedList/SelectedList.jsx @@ -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,7 +36,14 @@ 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; return (
    @@ -46,16 +54,33 @@ class SelectedList extends Component {
    - {selected - .slice(0, showOverflow ? selected.length : showOverflowAfter) - .map(selectedItem => ( - onRemove(selectedItem)} - > - {selectedItem.name} - - ))} + {isReadOnly ? ( + + {selected + .slice(0, showOverflow ? selected.length : showOverflowAfter) + .map(selectedItem => ( + + )) + } + + ) : ( + + {selected + .slice(0, showOverflow ? selected.length : showOverflowAfter) + .map(selectedItem => ( + onRemove(selectedItem)} + > + {selectedItem[displayKey]} + + )) + } + + )} {( !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; diff --git a/src/index.jsx b/src/index.jsx index 099ab7a584..4654c25ed2 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -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'; diff --git a/src/pages/Organizations/components/OrganizationAccessList.jsx b/src/pages/Organizations/components/OrganizationAccessList.jsx index 5aecddd279..3897cae617 100644 --- a/src/pages/Organizations/components/OrganizationAccessList.jsx +++ b/src/pages/Organizations/components/OrganizationAccessList.jsx @@ -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 }) => ( @@ -328,6 +357,25 @@ class OrganizationAccessList extends React.Component { columns={this.columns} onSearch={() => { }} onSort={this.onSort} + add={( + + + {isModalOpen && ( + + )} + + )} /> {showWarning && (