From 4f6d7e56eb57d8257dc4a6c699df00dcbb22b75d Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 20 May 2020 12:40:55 -0400 Subject: [PATCH 1/3] Adds support to user and team access add --- .../UserAccessAdd/UserAndTeamAccessAdd.jsx | 155 ++++++++++++ .../UserAndTeamAccessAdd.test.jsx | 225 ++++++++++++++++++ .../src/components/UserAccessAdd/index.js | 1 + .../UserAccessAdd/resources.data.jsx | 208 ++++++++++++++++ .../Team/TeamAccess/TeamAccessList.jsx | 48 ++-- .../Team/TeamAccess/TeamAccessList.test.jsx | 63 +++++ .../User/UserAccess/UserAccessList.jsx | 116 +++++---- .../User/UserAccess/UserAccessList.test.jsx | 96 +++++++- 8 files changed, 848 insertions(+), 64 deletions(-) create mode 100644 awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx create mode 100644 awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx create mode 100644 awx/ui_next/src/components/UserAccessAdd/index.js create mode 100644 awx/ui_next/src/components/UserAccessAdd/resources.data.jsx diff --git a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx new file mode 100644 index 0000000000..ad2c254f06 --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx @@ -0,0 +1,155 @@ +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 getResourceTypes from './resources.data'; + +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(); + 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: ( + + {getResourceTypes(i18n).map(resource => ( + setSelectedResourceType(resource)} + /> + ))} + + ), + }, + { + 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/UserAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx new file mode 100644 index 0000000000..0a703a7024 --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -0,0 +1,225 @@ +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('WizardNavItem[text="Select ttems 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 ttems 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')() + ); + 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/UserAccessAdd/index.js b/awx/ui_next/src/components/UserAccessAdd/index.js new file mode 100644 index 0000000000..c445f018ba --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserAndTeamAccessAdd'; diff --git a/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx b/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx new file mode 100644 index 0000000000..0000036c59 --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx @@ -0,0 +1,208 @@ +import { t } from '@lingui/macro'; +import { + JobTemplatesAPI, + WorkflowJobTemplatesAPI, + CredentialsAPI, + InventoriesAPI, + ProjectsAPI, + OrganizationsAPI, +} from '../../api'; + +export default function getResourceTypes(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: () => JobTemplatesAPI.read(), + }, + { + 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: () => WorkflowJobTemplatesAPI.read(), + }, + { + 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: () => CredentialsAPI.read(), + }, + { + 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: () => InventoriesAPI.read(), + }, + { + 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: () => ProjectsAPI.read(), + }, + { + 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: () => OrganizationsAPI.read(), + }, + ]; +} diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 79531fb561..6c6f2c2910 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/UserAccessAdd/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..b44a83f303 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/UserAccessAdd/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); + }); }); From 585ca082e330af99ae7bd26b3a77366ad3138375 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 28 May 2020 10:20:15 -0400 Subject: [PATCH 2/3] Improves naming and updates resource list and adds search functionality --- .../components/AddRole/AddResourceRole.jsx | 4 +- .../components/AddRole/SelectResourceStep.jsx | 202 ++++++++---------- .../AddRole/SelectResourceStep.test.jsx | 99 +++------ .../UserAndTeamAccessAdd.jsx | 9 +- .../UserAndTeamAccessAdd.test.jsx | 11 +- .../getResourceAccessConfig.js} | 14 +- .../index.js | 0 .../Team/TeamAccess/TeamAccessList.jsx | 2 +- .../User/UserAccess/UserAccessList.jsx | 2 +- 9 files changed, 147 insertions(+), 196 deletions(-) rename awx/ui_next/src/components/{UserAccessAdd => UserAndTeamAccessAdd}/UserAndTeamAccessAdd.jsx (94%) rename awx/ui_next/src/components/{UserAccessAdd => UserAndTeamAccessAdd}/UserAndTeamAccessAdd.test.jsx (94%) rename awx/ui_next/src/components/{UserAccessAdd/resources.data.jsx => UserAndTeamAccessAdd/getResourceAccessConfig.js} (90%) rename awx/ui_next/src/components/{UserAccessAdd => UserAndTeamAccessAdd}/index.js (100%) 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/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/UserAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx similarity index 94% rename from awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx rename to awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx index ad2c254f06..ceac1a32f5 100644 --- a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx @@ -11,7 +11,7 @@ import Wizard from '../Wizard/Wizard'; import useSelected from '../../util/useSelected'; import SelectResourceStep from '../AddRole/SelectResourceStep'; import SelectRoleStep from '../AddRole/SelectRoleStep'; -import getResourceTypes from './resources.data'; +import getResourceAccessConfig from './getResourceAccessConfig'; const Grid = styled.div` display: grid; @@ -28,7 +28,7 @@ function UserAndTeamAccessAdd({ apiModel, onClose, }) { - const [selectedResourceType, setSelectedResourceType] = useState(); + const [selectedResourceType, setSelectedResourceType] = useState(null); const [stepIdReached, setStepIdReached] = useState(1); const { id: userId } = useParams(); const { @@ -70,7 +70,7 @@ function UserAndTeamAccessAdd({ name: i18n._(t`Add resource type`), component: ( - {getResourceTypes(i18n).map(resource => ( + {getResourceAccessConfig(i18n).map(resource => ( ), + enableNext: selectedResourceType !== null, }, { id: 2, @@ -94,7 +95,7 @@ function UserAndTeamAccessAdd({ sortColumns={selectedResourceType.sortColumns} displayKey="name" onRowClick={handleResourceSelect} - onSearch={selectedResourceType.fetchItems} + fetchItems={selectedResourceType.fetchItems} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={resourcesSelected} sortedColumnKey="username" diff --git a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx similarity index 94% rename from awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx rename to awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx index 0a703a7024..b80c24a493 100644 --- a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -65,9 +65,10 @@ describe('', () => { 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 ttems from list"]') + .find('WizardNavItem[text="Select items from list"]') .prop('isDisabled') ).toBe(true); expect( @@ -93,7 +94,7 @@ describe('', () => { ).toBe(false); expect( wrapper - .find('WizardNavItem[text="Select ttems from list"]') + .find('WizardNavItem[text="Select items from list"]') .prop('isDisabled') ).toBe(false); expect( @@ -119,6 +120,12 @@ describe('', () => { 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 () => diff --git a/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js similarity index 90% rename from awx/ui_next/src/components/UserAccessAdd/resources.data.jsx rename to awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js index 0000036c59..718476e70e 100644 --- a/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js @@ -8,7 +8,7 @@ import { OrganizationsAPI, } from '../../api'; -export default function getResourceTypes(i18n) { +export default function getResourceAccessConfig(i18n) { return [ { selectedResource: 'jobTemplate', @@ -38,7 +38,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => JobTemplatesAPI.read(), + fetchItems: queryParams => JobTemplatesAPI.read(queryParams), }, { selectedResource: 'workflowJobTemplate', @@ -68,7 +68,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => WorkflowJobTemplatesAPI.read(), + fetchItems: queryParams => WorkflowJobTemplatesAPI.read(queryParams), }, { selectedResource: 'credential', @@ -109,7 +109,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => CredentialsAPI.read(), + fetchItems: queryParams => CredentialsAPI.read(queryParams), }, { selectedResource: 'inventory', @@ -135,7 +135,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => InventoriesAPI.read(), + fetchItems: queryParams => InventoriesAPI.read(queryParams), }, { selectedResource: 'project', @@ -176,7 +176,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => ProjectsAPI.read(), + fetchItems: queryParams => ProjectsAPI.read(queryParams), }, { selectedResource: 'organization', @@ -202,7 +202,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => OrganizationsAPI.read(), + fetchItems: queryParams => OrganizationsAPI.read(queryParams), }, ]; } diff --git a/awx/ui_next/src/components/UserAccessAdd/index.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js similarity index 100% rename from awx/ui_next/src/components/UserAccessAdd/index.js rename to awx/ui_next/src/components/UserAndTeamAccessAdd/index.js diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 6c6f2c2910..516c090cdd 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -12,7 +12,7 @@ import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import TeamAccessListItem from './TeamAccessListItem'; -import UserAndTeamAccessAdd from '../../../components/UserAccessAdd/UserAndTeamAccessAdd'; +import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('team', { page: 1, diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index b44a83f303..e48fa6f70d 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -10,7 +10,7 @@ import useRequest from '../../../util/useRequest'; import PaginatedDataList from '../../../components/PaginatedDataList'; import DatalistToolbar from '../../../components/DataListToolbar'; import UserAccessListItem from './UserAccessListItem'; -import UserAndTeamAccessAdd from '../../../components/UserAccessAdd/UserAndTeamAccessAdd'; +import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('roles', { page: 1, From ca6ae24032c816ba8c7557e240f4dc631c2a0394 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 29 May 2020 09:19:14 -0400 Subject: [PATCH 3/3] makes whole card selectable --- .../src/components/AddRole/CheckboxCard.jsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) 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 ( -
+ -
+ ); } }