diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 639a6e8128..5727a78c74 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -258,7 +258,7 @@ class AddResourceRole extends React.Component { sortColumns={userSortColumns} displayKey="username" onRowClick={this.handleResourceCheckboxClick} - onSearch={readUsers} + fetchItems={readUsers} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={selectedResourceRows} sortedColumnKey="username" @@ -269,7 +269,7 @@ class AddResourceRole extends React.Component { searchColumns={teamSearchColumns} sortColumns={teamSortColumns} onRowClick={this.handleResourceCheckboxClick} - onSearch={readTeams} + fetchItems={readTeams} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={selectedResourceRows} /> diff --git a/awx/ui_next/src/components/AddRole/CheckboxCard.jsx b/awx/ui_next/src/components/AddRole/CheckboxCard.jsx index a1b56939d0..361bf1a60d 100644 --- a/awx/ui_next/src/components/AddRole/CheckboxCard.jsx +++ b/awx/ui_next/src/components/AddRole/CheckboxCard.jsx @@ -1,19 +1,27 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; -import { Checkbox } from '@patternfly/react-core'; +import { Checkbox as PFCheckbox } from '@patternfly/react-core'; +import styled from 'styled-components'; + +const CheckboxWrapper = styled.div` + display: flex; + border: 1px solid var(--pf-global--BorderColor--200); + border-radius: var(--pf-global--BorderRadius--sm); + padding: 10px; +`; + +const Checkbox = styled(PFCheckbox)` + width: 100%; + & label { + width: 100%; + } +`; class CheckboxCard extends Component { render() { const { name, description, isSelected, onSelect, itemId } = this.props; return ( -
+ -
+ ); } } diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx index 9427bca17b..461327587e 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx @@ -1,8 +1,10 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { withRouter } from 'react-router-dom'; +import { withRouter, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import useRequest from '../../util/useRequest'; + import { SearchColumns, SortColumns } from '../../types'; import PaginatedDataList from '../PaginatedDataList'; import DataListToolbar from '../DataListToolbar'; @@ -10,124 +12,94 @@ import CheckboxListItem from '../CheckboxListItem'; import SelectedList from '../SelectedList'; import { getQSConfig, parseQueryString } from '../../util/qs'; -class SelectResourceStep extends React.Component { - constructor(props) { - super(props); +const QS_Config = sortColumns => { + return getQSConfig('resource', { + page: 1, + page_size: 5, + order_by: `${ + sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username' + }`, + }); +}; +function SelectResourceStep({ + searchColumns, + sortColumns, + displayKey, + onRowClick, + selectedLabel, + selectedResourceRows, + fetchItems, + i18n, +}) { + const location = useLocation(); - this.state = { - isInitialized: false, - count: null, - error: false, + const { + isLoading, + error, + request: readResourceList, + result: { resources, itemCount }, + } = useRequest( + useCallback(async () => { + const queryParams = parseQueryString( + QS_Config(sortColumns), + location.search + ); + + const { + data: { count, results }, + } = await fetchItems(queryParams); + return { resources: results, itemCount: count }; + }, [location, fetchItems, sortColumns]), + { resources: [], - }; - - this.qsConfig = getQSConfig('resource', { - page: 1, - page_size: 5, - order_by: `${ - props.sortColumns.filter(col => col.key === 'name').length - ? 'name' - : 'username' - }`, - }); - } - - componentDidMount() { - this.readResourceList(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - this.readResourceList(); + itemCount: 0, } - } + ); - async readResourceList() { - const { onSearch, location } = this.props; - const queryParams = parseQueryString(this.qsConfig, location.search); + useEffect(() => { + readResourceList(); + }, [readResourceList]); - this.setState({ - isLoading: true, - error: false, - }); - try { - const { data } = await onSearch(queryParams); - const { count, results } = data; - - this.setState({ - resources: results, - count, - isInitialized: true, - isLoading: false, - error: false, - }); - } catch (err) { - this.setState({ - isLoading: false, - error: true, - }); - } - } - - render() { - const { isInitialized, isLoading, count, error, resources } = this.state; - - const { - searchColumns, - sortColumns, - displayKey, - onRowClick, - selectedLabel, - selectedResourceRows, - i18n, - } = this.props; - - return ( - - {isInitialized && ( - -
- {i18n._( - t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.` - )} -
- {selectedResourceRows.length > 0 && ( - - )} - ( - i.id === item.id)} - itemId={item.id} - key={item.id} - name={item[displayKey]} - label={item[displayKey]} - onSelect={() => onRowClick(item)} - onDeselect={() => onRowClick(item)} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> -
+ return ( + +
+ {i18n._( + t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.` )} - {error ?
error
: ''} - - ); - } +
+ {selectedResourceRows.length > 0 && ( + + )} + ( + i.id === item.id)} + itemId={item.id} + key={item.id} + name={item[displayKey]} + label={item[displayKey]} + onSelect={() => onRowClick(item)} + onDeselect={() => onRowClick(item)} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); } SelectResourceStep.propTypes = { @@ -135,7 +107,7 @@ SelectResourceStep.propTypes = { sortColumns: SortColumns, displayKey: PropTypes.string, onRowClick: PropTypes.func, - onSearch: PropTypes.func.isRequired, + fetchItems: PropTypes.func.isRequired, selectedLabel: PropTypes.string, selectedResourceRows: PropTypes.arrayOf(PropTypes.object), }; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index e925044ed5..d309ea706f 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx @@ -1,7 +1,11 @@ import React from 'react'; -import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; + import { shallow } from 'enzyme'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; import { sleep } from '../../../testUtils/testUtils'; import SelectResourceStep from './SelectResourceStep'; @@ -30,12 +34,12 @@ describe('', () => { sortColumns={sortColumns} displayKey="username" onRowClick={() => {}} - onSearch={() => {}} + fetchItems={() => {}} /> ); }); - test('fetches resources on mount', async () => { + test('fetches resources on mount and adds items to list', async () => { const handleSearch = jest.fn().mockResolvedValue({ data: { count: 2, @@ -45,61 +49,24 @@ describe('', () => { ], }, }); - mountWithContexts( - {}} - onSearch={handleSearch} - /> - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + {}} + fetchItems={handleSearch} + /> + ); + }); expect(handleSearch).toHaveBeenCalledWith({ order_by: 'username', page: 1, page_size: 5, }); - }); - - test('readResourceList properly adds rows to state', async () => { - const selectedResourceRows = [{ id: 1, username: 'foo', url: 'item/1' }]; - const handleSearch = jest.fn().mockResolvedValue({ - data: { - count: 2, - results: [ - { id: 1, username: 'foo', url: 'item/1' }, - { id: 2, username: 'bar', url: 'item/2' }, - ], - }, - }); - const history = createMemoryHistory({ - initialEntries: [ - '/organizations/1/access?resource.page=1&resource.order_by=-username', - ], - }); - const wrapper = mountWithContexts( - {}} - onSearch={handleSearch} - selectedResourceRows={selectedResourceRows} - />, - { - context: { router: { history, route: { location: history.location } } }, - } - ).find('SelectResourceStep'); - await wrapper.instance().readResourceList(); - expect(handleSearch).toHaveBeenCalledWith({ - order_by: '-username', - page: 1, - page_size: 5, - }); - expect(wrapper.state('resources')).toEqual([ - { id: 1, username: 'foo', url: 'item/1' }, - { id: 2, username: 'bar', url: 'item/2' }, - ]); + waitForElement(wrapper, 'CheckBoxListItem', el => el.length === 2); }); test('clicking on row fires callback with correct params', async () => { @@ -111,20 +78,24 @@ describe('', () => { { id: 2, username: 'bar', url: 'item/2' }, ], }; - const wrapper = mountWithContexts( - ({ data })} - selectedResourceRows={[]} - /> - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + ({ data })} + selectedResourceRows={[]} + /> + ); + }); await sleep(0); wrapper.update(); const checkboxListItemWrapper = wrapper.find('CheckboxListItem'); expect(checkboxListItemWrapper.length).toBe(2); + checkboxListItemWrapper .first() .find('input[type="checkbox"]') diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx new file mode 100644 index 0000000000..ceac1a32f5 --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx @@ -0,0 +1,156 @@ +import React, { useState, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import useRequest, { useDismissableError } from '../../util/useRequest'; +import SelectableCard from '../SelectableCard'; +import AlertModal from '../AlertModal'; +import ErrorDetail from '../ErrorDetail'; +import Wizard from '../Wizard/Wizard'; +import useSelected from '../../util/useSelected'; +import SelectResourceStep from '../AddRole/SelectResourceStep'; +import SelectRoleStep from '../AddRole/SelectRoleStep'; +import getResourceAccessConfig from './getResourceAccessConfig'; + +const Grid = styled.div` + display: grid; + grid-gap: 20px; + grid-template-columns: 33% 33% 33%; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +`; + +function UserAndTeamAccessAdd({ + i18n, + isOpen, + title, + onSave, + apiModel, + onClose, +}) { + const [selectedResourceType, setSelectedResourceType] = useState(null); + const [stepIdReached, setStepIdReached] = useState(1); + const { id: userId } = useParams(); + const { + selected: resourcesSelected, + handleSelect: handleResourceSelect, + } = useSelected([]); + + const { + selected: rolesSelected, + handleSelect: handleRoleSelect, + } = useSelected([]); + + const { request: handleWizardSave, error: saveError } = useRequest( + useCallback(async () => { + const roleRequests = []; + const resourceRolesTypes = resourcesSelected.flatMap(resource => + Object.values(resource.summary_fields.object_roles) + ); + + rolesSelected.map(role => + resourceRolesTypes.forEach(rolename => { + if (rolename.name === role.name) { + roleRequests.push(apiModel.associateRole(userId, rolename.id)); + } + }) + ); + + await Promise.all(roleRequests); + onSave(); + }, [onSave, rolesSelected, apiModel, userId, resourcesSelected]), + {} + ); + + const { error, dismissError } = useDismissableError(saveError); + + const steps = [ + { + id: 1, + name: i18n._(t`Add resource type`), + component: ( + + {getResourceAccessConfig(i18n).map(resource => ( + setSelectedResourceType(resource)} + /> + ))} + + ), + enableNext: selectedResourceType !== null, + }, + { + id: 2, + name: i18n._(t`Select items from list`), + component: selectedResourceType && ( + + ), + enableNext: resourcesSelected.length > 0, + canJumpTo: stepIdReached >= 2, + }, + { + id: 3, + name: i18n._(t`Select roles to apply`), + component: resourcesSelected?.length > 0 && ( + + ), + nextButtonText: i18n._(t`Save`), + canJumpTo: stepIdReached >= 3, + }, + ]; + + if (error) { + return ( + + {i18n._(t`Failed to associate role`)} + + + ); + } + + return ( + + setStepIdReached(stepIdReached < id ? id : stepIdReached) + } + onSave={handleWizardSave} + /> + ); +} + +export default withI18n()(UserAndTeamAccessAdd); diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx new file mode 100644 index 0000000000..b80c24a493 --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { UsersAPI, JobTemplatesAPI } from '../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import UserAndTeamAccessAdd from './UserAndTeamAccessAdd'; + +jest.mock('../../api/models/Teams'); +jest.mock('../../api/models/Users'); +jest.mock('../../api/models/JobTemplates'); + +describe('', () => { + const resources = { + data: { + results: [ + { + id: 1, + name: 'Job Template Foo Bar', + url: '/api/v2/job_template/1/', + summary_fields: { + object_roles: { + admin_role: { + description: 'Can manage all aspects of the job template', + name: 'Admin', + id: 164, + }, + execute_role: { + description: 'May run the job template', + name: 'Execute', + id: 165, + }, + read_role: { + description: 'May view settings for the job template', + name: 'Read', + id: 166, + }, + }, + }, + }, + ], + count: 1, + }, + }; + let wrapper; + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + onClose={() => {}} + title="Add user permissions" + /> + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mount properly', async () => { + expect(wrapper.find('PFWizard').length).toBe(1); + }); + test('should disable steps', async () => { + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); + expect( + wrapper + .find('WizardNavItem[text="Select items from list"]') + .prop('isDisabled') + ).toBe(true); + expect( + wrapper + .find('WizardNavItem[text="Select roles to apply"]') + .prop('isDisabled') + ).toBe(true); + await act(async () => + wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ + fetchItems: JobTemplatesAPI.read, + label: 'Job template', + selectedResource: 'jobTemplate', + searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + sortColumns: [{ name: 'Name', key: 'name' }], + }) + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + expect( + wrapper.find('WizardNavItem[text="Add resource type"]').prop('isDisabled') + ).toBe(false); + expect( + wrapper + .find('WizardNavItem[text="Select items from list"]') + .prop('isDisabled') + ).toBe(false); + expect( + wrapper + .find('WizardNavItem[text="Select roles to apply"]') + .prop('isDisabled') + ).toBe(true); + }); + + test('should call api to associate role', async () => { + JobTemplatesAPI.read.mockResolvedValue(resources); + UsersAPI.associateRole.mockResolvedValue({}); + + await act(async () => + wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ + fetchItems: JobTemplatesAPI.read, + label: 'Job template', + selectedResource: 'jobTemplate', + searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + sortColumns: [{ name: 'Name', key: 'name' }], + }) + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + expect(JobTemplatesAPI.read).toHaveBeenCalledWith({ + order_by: 'name', + page: 1, + page_size: 5, + }); + + await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0); + expect(JobTemplatesAPI.read).toHaveBeenCalled(); + await act(async () => + wrapper + .find('CheckboxListItem') + .first() + .find('input[type="checkbox"]') + .simulate('change', { target: { checked: true } }) + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + + expect(wrapper.find('RolesStep').length).toBe(1); + + await act(async () => + wrapper + .find('CheckboxCard') + .first() + .prop('onSelect')() + ); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + await expect(UsersAPI.associateRole).toHaveBeenCalled(); + }); + + test('should throw error', async () => { + JobTemplatesAPI.read.mockResolvedValue(resources); + UsersAPI.associateRole.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/users/a/roles', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 'a', + }), + })); + + await act(async () => + wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ + fetchItems: JobTemplatesAPI.read, + label: 'Job template', + selectedResource: 'jobTemplate', + searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + sortColumns: [{ name: 'Name', key: 'name' }], + }) + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0); + expect(JobTemplatesAPI.read).toHaveBeenCalled(); + await act(async () => + wrapper + .find('CheckboxListItem') + .first() + .find('input[type="checkbox"]') + .simulate('change', { target: { checked: true } }) + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + + expect(wrapper.find('RolesStep').length).toBe(1); + + await act(async () => + wrapper + .find('CheckboxCard') + .first() + .prop('onSelect')() + ); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + await expect(UsersAPI.associateRole).toHaveBeenCalled(); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js new file mode 100644 index 0000000000..718476e70e --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js @@ -0,0 +1,208 @@ +import { t } from '@lingui/macro'; +import { + JobTemplatesAPI, + WorkflowJobTemplatesAPI, + CredentialsAPI, + InventoriesAPI, + ProjectsAPI, + OrganizationsAPI, +} from '../../api'; + +export default function getResourceAccessConfig(i18n) { + return [ + { + selectedResource: 'jobTemplate', + label: i18n._(t`Job templates`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Playbook name`), + key: 'playbook', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: queryParams => JobTemplatesAPI.read(queryParams), + }, + { + selectedResource: 'workflowJobTemplate', + label: i18n._(t`Workflow job templates`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Playbook name`), + key: 'playbook', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: queryParams => WorkflowJobTemplatesAPI.read(queryParams), + }, + { + selectedResource: 'credential', + label: i18n._(t`Credentials`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'scm_type', + options: [ + [``, i18n._(t`Manual`)], + [`git`, i18n._(t`Git`)], + [`hg`, i18n._(t`Mercurial`)], + [`svn`, i18n._(t`Subversion`)], + [`insights`, i18n._(t`Red Hat Insights`)], + ], + }, + { + name: i18n._(t`Source Control URL`), + key: 'scm_url', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: queryParams => CredentialsAPI.read(queryParams), + }, + { + selectedResource: 'inventory', + label: i18n._(t`Inventories`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: queryParams => InventoriesAPI.read(queryParams), + }, + { + selectedResource: 'project', + label: i18n._(t`Projects`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'scm_type', + options: [ + [``, i18n._(t`Manual`)], + [`git`, i18n._(t`Git`)], + [`hg`, i18n._(t`Mercurial`)], + [`svn`, i18n._(t`Subversion`)], + [`insights`, i18n._(t`Red Hat Insights`)], + ], + }, + { + name: i18n._(t`Source Control URL`), + key: 'scm_url', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: queryParams => ProjectsAPI.read(queryParams), + }, + { + selectedResource: 'organization', + label: i18n._(t`Organizations`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: queryParams => OrganizationsAPI.read(queryParams), + }, + ]; +} diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js new file mode 100644 index 0000000000..c445f018ba --- /dev/null +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserAndTeamAccessAdd'; diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 79531fb561..516c090cdd 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -1,19 +1,18 @@ -import React, { useCallback, useEffect } from 'react'; -import { useLocation, useRouteMatch, useParams } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Card } from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; import { TeamsAPI } from '../../../api'; import useRequest from '../../../util/useRequest'; import DataListToolbar from '../../../components/DataListToolbar'; -import PaginatedDataList, { - ToolbarAddButton, -} from '../../../components/PaginatedDataList'; +import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import TeamAccessListItem from './TeamAccessListItem'; +import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('team', { page: 1, @@ -22,8 +21,8 @@ const QS_CONFIG = getQSConfig('team', { }); function TeamAccessList({ i18n }) { + const [isWizardOpen, setIsWizardOpen] = useState(false); const { search } = useLocation(); - const match = useRouteMatch(); const { id } = useParams(); const { @@ -57,6 +56,11 @@ function TeamAccessList({ i18n }) { fetchRoles(); }, [fetchRoles]); + const saveRoles = () => { + setIsWizardOpen(false); + fetchRoles(); + }; + const canAdd = options && Object.prototype.hasOwnProperty.call(options, 'POST'); @@ -77,7 +81,7 @@ function TeamAccessList({ i18n }) { }; return ( - + <> ] + ? [ + , + ] : []), ]} /> @@ -117,13 +131,17 @@ function TeamAccessList({ i18n }) { onSelect={() => {}} /> )} - emptyStateControls={ - canAdd ? ( - - ) : null - } /> - + {isWizardOpen && ( + setIsWizardOpen(false)} + title={i18n._(t`Add team permissions`)} + /> + )} + ); } export default withI18n()(TeamAccessList); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx index b7b5e26025..88311c8643 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx @@ -141,4 +141,67 @@ describe('', () => { '/inventories/smart_inventory/77/details' ); }); + test('should not render add button', async () => { + TeamsAPI.readRoleOptions.mockResolvedValueOnce({ + data: {}, + }); + + TeamsAPI.readRoles.mockResolvedValue({ + data: { + results: [ + { + id: 2, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + description: 'Can manage all aspects of the job template', + }, + ], + count: 1, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 18 } }, + }, + }, + }, + } + ); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe( + 0 + ); + }); + test('should open and close wizard', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => + wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(1); + await act(async () => + wrapper.find("Button[aria-label='Close']").prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(0); + }); }); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index db62a14bd6..e48fa6f70d 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -1,15 +1,16 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; + import { getQSConfig, parseQueryString } from '../../../util/qs'; import { UsersAPI } from '../../../api'; import useRequest from '../../../util/useRequest'; -import PaginatedDataList, { - ToolbarAddButton, -} from '../../../components/PaginatedDataList'; +import PaginatedDataList from '../../../components/PaginatedDataList'; import DatalistToolbar from '../../../components/DataListToolbar'; import UserAccessListItem from './UserAccessListItem'; +import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('roles', { page: 1, @@ -22,6 +23,7 @@ const QS_CONFIG = getQSConfig('roles', { function UserAccessList({ i18n }) { const { id } = useParams(); const { search } = useLocation(); + const [isWizardOpen, setIsWizardOpen] = useState(false); const { isLoading, @@ -55,6 +57,11 @@ function UserAccessList({ i18n }) { const canAdd = options && Object.prototype.hasOwnProperty.call(options, 'POST'); + const saveRoles = () => { + setIsWizardOpen(false); + fetchRoles(); + }; + const detailUrl = role => { const { resource_id, resource_type } = role.summary_fields; @@ -72,48 +79,71 @@ function UserAccessList({ i18n }) { }; return ( - { - return ( - {}} - isSelected={false} + <> + { + return ( + {}} + isSelected={false} + /> + ); + }} + renderToolbar={props => ( + { + setIsWizardOpen(true); + }} + > + Add + , + ] + : []), + ]} /> - ); - }} - renderToolbar={props => ( - ] : []), - ]} + )} + /> + {isWizardOpen && ( + setIsWizardOpen(false)} + title={i18n._(t`Add user permissions`)} /> )} - /> + ); } export default withI18n()(UserAccessList); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx index acb1400608..8a59012a30 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -29,6 +29,7 @@ describe('', () => { resource_type_display_name: 'Job Template', user_capabilities: { unattach: true }, }, + description: 'Can manage all aspects of the job template', }, { id: 3, @@ -42,6 +43,7 @@ describe('', () => { resource_type_display_name: 'Job Template', user_capabilities: { unattach: true }, }, + description: 'Can manage all aspects of the job template', }, { id: 4, @@ -55,10 +57,11 @@ describe('', () => { resource_type_display_name: 'Credential', user_capabilities: { unattach: true }, }, + description: 'May run the job template', }, { id: 5, - name: 'Update', + name: 'Read', type: 'role', url: '/api/v2/roles/259/', summary_fields: { @@ -68,6 +71,7 @@ describe('', () => { resource_type_display_name: 'Inventory', user_capabilities: { unattach: true }, }, + description: 'May view settings for the job template', }, { id: 6, @@ -75,15 +79,16 @@ describe('', () => { type: 'role', url: '/api/v2/roles/260/', summary_fields: { - resource_name: 'Smart Inventory Foo', + resource_name: 'Project Foo', resource_id: 77, - resource_type: 'smart_inventory', - resource_type_display_name: 'Inventory', + resource_type: 'project', + resource_type_display_name: 'Project', user_capabilities: { unattach: true }, }, + description: 'Can manage all aspects of the job template', }, ], - count: 4, + count: 5, }, }); @@ -138,7 +143,86 @@ describe('', () => { '/inventories/inventory/76/details' ); expect(wrapper.find('Link#userRole-6').prop('to')).toBe( - '/inventories/smart_inventory/77/details' + '/projects/77/details' ); }); + test('should not render add button', async () => { + UsersAPI.readRoleOptions.mockResolvedValueOnce({ + data: {}, + }); + + UsersAPI.readRoles.mockResolvedValue({ + data: { + results: [ + { + id: 2, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + object_roles: { + admin_role: { + description: 'Can manage all aspects of the job template', + name: 'Admin', + id: 164, + }, + execute_role: { + description: 'May run the job template', + name: 'Execute', + id: 165, + }, + read_role: { + description: 'May view settings for the job template', + name: 'Read', + id: 166, + }, + }, + }, + }, + ], + count: 1, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 18 } }, + }, + }, + }, + } + ); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe( + 0 + ); + }); + test('should open and close wizard', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => + wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(1); + await act(async () => + wrapper.find("Button[aria-label='Close']").prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(0); + }); });