From 0a88d4264559b31b9d580fad7d165fa37d151ac6 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 5 Jan 2021 14:14:42 -0500 Subject: [PATCH] Updates files to pre lingUI upgrade work --- .../components/AddRole/AddResourceRole.jsx | 449 ++++++++---------- .../AddRole/AddResourceRole.test.jsx | 370 +++++++-------- .../src/components/AddRole/SelectRoleStep.jsx | 102 ++-- .../AnsibleSelect/AnsibleSelect.jsx | 78 ++- .../AnsibleSelect/AnsibleSelect.test.jsx | 10 +- .../ExpandCollapse/ExpandCollapse.jsx | 54 +-- .../DeleteRoleConfirmationModal.jsx | 124 ++--- .../ResourceAccessList/ResourceAccessList.jsx | 1 + .../ResourceAccessListItem.jsx | 150 +++--- awx/ui_next/src/components/Sort/Sort.jsx | 202 ++++---- awx/ui_next/src/components/Sort/Sort.test.jsx | 53 +-- .../src/screens/Job/JobOutput/JobOutput.jsx | 30 +- .../screens/Project/shared/ProjectForm.jsx | 12 +- .../Project/shared/ProjectSyncButton.jsx | 2 +- .../screens/Team/TeamList/TeamListItem.jsx | 115 +++-- 15 files changed, 819 insertions(+), 933 deletions(-) diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2f12953afa..e339142b52 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams); const readTeamsOptions = async () => TeamsAPI.readOptions(); -class AddResourceRole extends React.Component { - constructor(props) { - super(props); - - this.state = { - selectedResource: null, - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }; - - this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind( - this - ); - this.handleResourceSelect = this.handleResourceSelect.bind(this); - this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this); - this.handleWizardNext = this.handleWizardNext.bind(this); - this.handleWizardSave = this.handleWizardSave.bind(this); - this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this); - } - - handleResourceCheckboxClick(user) { - const { selectedResourceRows, currentStepId } = this.state; +function AddResourceRole({ onSave, onClose, roles, i18n, resource }) { + const [selectedResource, setSelectedResource] = useState(null); + const [selectedResourceRows, setSelectedResourceRows] = useState([]); + const [selectedRoleRows, setSelectedRoleRows] = useState([]); + const [currentStepId, setCurrentStepId] = useState(1); + const [maxEnabledStep, setMaxEnabledStep] = useState(1); + const handleResourceCheckboxClick = user => { const selectedIndex = selectedResourceRows.findIndex( selectedRow => selectedRow.id === user.id ); - if (selectedIndex > -1) { selectedResourceRows.splice(selectedIndex, 1); - const stateToUpdate = { selectedResourceRows }; if (selectedResourceRows.length === 0) { - stateToUpdate.maxEnabledStep = currentStepId; + setMaxEnabledStep(currentStepId); } - this.setState(stateToUpdate); + setSelectedRoleRows(selectedResourceRows); } else { - this.setState(prevState => ({ - selectedResourceRows: [...prevState.selectedResourceRows, user], - })); + setSelectedResourceRows([...selectedResourceRows, user]); } - } - - handleRoleCheckboxClick(role) { - const { selectedRoleRows } = this.state; + }; + const handleRoleCheckboxClick = role => { const selectedIndex = selectedRoleRows.findIndex( selectedRow => selectedRow.id === role.id ); if (selectedIndex > -1) { selectedRoleRows.splice(selectedIndex, 1); - this.setState({ selectedRoleRows }); + setSelectedRoleRows(selectedRoleRows); } else { - this.setState(prevState => ({ - selectedRoleRows: [...prevState.selectedRoleRows, role], - })); + setSelectedRoleRows([...selectedRoleRows, role]); } - } + }; - handleResourceSelect(resourceType) { - this.setState({ - selectedResource: resourceType, - selectedResourceRows: [], - selectedRoleRows: [], - }); - } + const handleResourceSelect = resourceType => { + setSelectedResource(resourceType); + setSelectedResourceRows([]); + setSelectedRoleRows([]); + }; - handleWizardNext(step) { - this.setState({ - currentStepId: step.id, - maxEnabledStep: step.id, - }); - } + const handleWizardNext = step => { + setCurrentStepId(step.id); + setMaxEnabledStep(step.id); + }; - handleWizardGoToStep(step) { - this.setState({ - currentStepId: step.id, - }); - } - - async handleWizardSave() { - const { onSave } = this.props; - const { - selectedResourceRows, - selectedRoleRows, - selectedResource, - } = this.state; + const handleWizardGoToStep = step => { + setCurrentStepId(step.id); + }; + const handleWizardSave = async () => { try { const roleRequests = []; @@ -134,201 +96,186 @@ class AddResourceRole extends React.Component { } catch (err) { // TODO: handle this error } + }; + + // Object roles can be user only, so we remove them when + // showing role choices for team access + const selectableRoles = { ...roles }; + if (selectedResource === 'teams') { + Object.keys(roles).forEach(key => { + if (selectableRoles[key].user_only) { + delete selectableRoles[key]; + } + }); } - render() { - const { - selectedResource, - selectedResourceRows, - selectedRoleRows, - currentStepId, - maxEnabledStep, - } = this.state; - const { onClose, roles, i18n, resource } = this.props; + const userSearchColumns = [ + { + name: i18n._(t`Username`), + key: 'username__icontains', + isDefault: true, + }, + { + name: i18n._(t`First Name`), + key: 'first_name__icontains', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name__icontains', + }, + ]; + const userSortColumns = [ + { + name: i18n._(t`Username`), + key: 'username', + }, + { + name: i18n._(t`First Name`), + key: 'first_name', + }, + { + name: i18n._(t`Last Name`), + key: 'last_name', + }, + ]; + const teamSearchColumns = [ + { + 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', + }, + ]; - // Object roles can be user only, so we remove them when - // showing role choices for team access - const selectableRoles = { ...roles }; - if (selectedResource === 'teams') { - Object.keys(roles).forEach(key => { - if (selectableRoles[key].user_only) { - delete selectableRoles[key]; - } - }); - } + const teamSortColumns = [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]; - const userSearchColumns = [ - { - name: i18n._(t`Username`), - key: 'username__icontains', - isDefault: true, - }, - { - name: i18n._(t`First Name`), - key: 'first_name__icontains', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name__icontains', - }, - ]; + let wizardTitle = ''; - const userSortColumns = [ - { - name: i18n._(t`Username`), - key: 'username', - }, - { - name: i18n._(t`First Name`), - key: 'first_name', - }, - { - name: i18n._(t`Last Name`), - key: 'last_name', - }, - ]; + switch (selectedResource) { + case 'users': + wizardTitle = i18n._(t`Add User Roles`); + break; + case 'teams': + wizardTitle = i18n._(t`Add Team Roles`); + break; + default: + wizardTitle = i18n._(t`Add Roles`); + } - const teamSearchColumns = [ - { - 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', - }, - ]; - - const teamSortColumns = [ - { - name: i18n._(t`Name`), - key: 'name', - }, - ]; - - let wizardTitle = ''; - - switch (selectedResource) { - case 'users': - wizardTitle = i18n._(t`Add User Roles`); - break; - case 'teams': - wizardTitle = i18n._(t`Add Team Roles`); - break; - default: - wizardTitle = i18n._(t`Add Roles`); - } - - const steps = [ - { - id: 1, - name: i18n._(t`Select a Resource Type`), - component: ( -
-
- {i18n._( - t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` - )} -
- - this.handleResourceSelect('users')} - /> - {resource?.type === 'credential' && - !resource?.organization ? null : ( - this.handleResourceSelect('teams')} - /> + const steps = [ + { + id: 1, + name: i18n._(t`Select a Resource Type`), + component: ( +
+
+ {i18n._( + t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.` )}
- ), - enableNext: selectedResource !== null, - }, - { - id: 2, - name: i18n._(t`Select Items from List`), - component: ( - - {selectedResource === 'users' && ( - - )} - {selectedResource === 'teams' && ( - - )} - - ), - enableNext: selectedResourceRows.length > 0, - canJumpTo: maxEnabledStep >= 2, - }, - { - id: 3, - name: i18n._(t`Select Roles to Apply`), - component: ( - handleResourceSelect('users')} /> - ), - nextButtonText: i18n._(t`Save`), - enableNext: selectedRoleRows.length > 0, - canJumpTo: maxEnabledStep >= 3, - }, - ]; + {resource?.type === 'credential' && !resource?.organization ? null : ( + handleResourceSelect('teams')} + /> + )} +
+ ), + enableNext: selectedResource !== null, + }, + { + id: 2, + name: i18n._(t`Select Items from List`), + component: ( + + {selectedResource === 'users' && ( + + )} + {selectedResource === 'teams' && ( + + )} + + ), + enableNext: selectedResourceRows.length > 0, + canJumpTo: maxEnabledStep >= 2, + }, + { + id: 3, + name: i18n._(t`Select Roles to Apply`), + component: ( + + ), + nextButtonText: i18n._(t`Save`), + enableNext: selectedRoleRows.length > 0, + canJumpTo: maxEnabledStep >= 3, + }, + ]; - const currentStep = steps.find(step => step.id === currentStepId); + const currentStep = steps.find(step => step.id === currentStepId); - // TODO: somehow internationalize steps and currentStep.nextButtonText - return ( - - ); - } + // TODO: somehow internationalize steps and currentStep.nextButtonText + return ( + handleWizardGoToStep(step)} + steps={steps} + title={wizardTitle} + nextButtonText={currentStep.nextButtonText || undefined} + backButtonText={i18n._(t`Back`)} + cancelButtonText={i18n._(t`Cancel`)} + /> + ); } AddResourceRole.propTypes = { diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx index a681999391..264f7cdb28 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx @@ -1,22 +1,46 @@ /* eslint-disable react/jsx-pascal-case */ import React from 'react'; import { shallow } from 'enzyme'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; import AddResourceRole, { _AddResourceRole } from './AddResourceRole'; import { TeamsAPI, UsersAPI } from '../../api'; -jest.mock('../../api'); +jest.mock('../../api/models/Teams'); +jest.mock('../../api/models/Users'); + +// TODO: Once error handling is functional in +// this component write tests for it describe('<_AddResourceRole />', () => { UsersAPI.read.mockResolvedValue({ data: { count: 2, results: [ - { id: 1, username: 'foo' }, - { id: 2, username: 'bar' }, + { id: 1, username: 'foo', url: '' }, + { id: 2, username: 'bar', url: '' }, ], }, }); + UsersAPI.readOptions.mockResolvedValue({ + data: { related: {}, actions: { GET: {} } }, + }); + TeamsAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { id: 1, name: 'Team foo', url: '' }, + { id: 2, name: 'Team bar', url: '' }, + ], + }, + }); + TeamsAPI.readOptions.mockResolvedValue({ + data: { related: {}, actions: { GET: {} } }, + }); const roles = { admin_role: { description: 'Can manage all aspects of the organization', @@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => { /> ); }); - test('handleRoleCheckboxClick properly updates state', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.setState({ - selectedRoleRows: [ - { - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }, - ], + test('should save properly', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - wrapper.instance().handleRoleCheckboxClick({ - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }); - expect(wrapper.state('selectedRoleRows')).toEqual([]); - wrapper.instance().handleRoleCheckboxClick({ - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }); - expect(wrapper.state('selectedRoleRows')).toEqual([ - { - description: 'Can manage all aspects of the organization', - name: 'Admin', - id: 1, - }, - ]); - }); - test('handleResourceCheckboxClick properly updates state', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> - ); - wrapper.setState({ - selectedResourceRows: [ - { - id: 1, - username: 'foobar', - }, - ], - }); - wrapper.instance().handleResourceCheckboxClick({ - id: 1, - username: 'foobar', - }); - expect(wrapper.state('selectedResourceRows')).toEqual([]); - wrapper.instance().handleResourceCheckboxClick({ - id: 1, - username: 'foobar', - }); - expect(wrapper.state('selectedResourceRows')).toEqual([ - { - id: 1, - username: 'foobar', - }, - ]); - }); - test('clicking user/team cards updates state', () => { - const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); - const wrapper = mountWithContexts( - {}} onSave={() => {}} roles={roles} />, - { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); + wrapper.update(); + + // Step 1 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('handleResourceSelect clears out selected lists and sets selectedResource', () => { - const wrapper = shallow( - <_AddResourceRole - onClose={() => {}} - onSave={() => {}} - roles={roles} - i18n={{ _: val => val.toString() }} - /> + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() ); - 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: [], - currentStepId: 1, - maxEnabledStep: 1, - }); - wrapper.instance().handleResourceSelect('teams'); - expect(wrapper.state()).toEqual({ - selectedResource: 'teams', - selectedResourceRows: [], - selectedRoleRows: [], - currentStepId: 1, - maxEnabledStep: 1, - }); + wrapper.update(); + + // Step 2 + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + act(() => + wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe( + true + ); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Step 3 + act(() => + wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe( + true + ); + + // Save + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + expect(UsersAPI.associateRole).toBeCalledWith(1, 1); }); - test('handleWizardSave makes correct api calls, calls onSave when done', async () => { - const handleSave = jest.fn(); - const wrapper = mountWithContexts( - {}} onSave={handleSave} roles={roles} />, - { context: { network: { handleHttpError: () => {} } } } - ).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', - }, - ], + + test('should successfuly click user/team cards', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - await wrapper.instance().handleWizardSave(); - expect(UsersAPI.associateRole).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', - }, - ], + wrapper.update(); + + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(2); + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + + await waitForElement( + wrapper, + 'SelectableCard[label="Users"]', + el => el.prop('isSelected') === true + ); + act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')()); + wrapper.update(); + + await waitForElement( + wrapper, + 'SelectableCard[label="Teams"]', + el => el.prop('isSelected') === true + ); + }); + + test('should reset values with resource type changes', async () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + {}} onSave={() => {}} roles={roles} />, + { context: { network: { handleHttpError: () => {} } } } + ); }); - await wrapper.instance().handleWizardSave(); - expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2); - expect(handleSave).toHaveBeenCalled(); + wrapper.update(); + + // Step 1 + const selectableCardWrapper = wrapper.find('SelectableCard'); + expect(selectableCardWrapper.length).toBe(2); + act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // Step 2 + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + act(() => + wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe( + true + ); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Step 3 + act(() => + wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true) + ); + wrapper.update(); + expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe( + true + ); + + // Go back to step 1 + act(() => { + wrapper + .find('WizardNavItem[content="Select a Resource Type"]') + .find('button') + .prop('onClick')({ id: 1 }); + }); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem[content="Select a Resource Type"]') + .prop('isCurrent') + ).toBe(true); + + // Go back to step 1 and this time select teams. Doing so should clear following steps + act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')()); + wrapper.update(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // Make sure no teams have been selected + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + wrapper + .find('DataListCheck') + .map(item => expect(item.prop('checked')).toBe(false)); + act(() => wrapper.find('Button[type="submit"]').prop('onClick')()); + wrapper.update(); + + // Make sure that no roles have been selected + wrapper + .find('Checkbox') + .map(card => expect(card.prop('isChecked')).toBe(false)); + + // Make sure the save button is disabled + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); }); test('should not display team as a choice in case credential does not have organization', () => { - const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect'); const wrapper = mountWithContexts( {}} @@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => { resource={{ type: 'credential', organization: null }} />, { context: { network: { handleHttpError: () => {} } } } - ).find('AddResourceRole'); - const selectableCardWrapper = wrapper.find('SelectableCard'); - expect(selectableCardWrapper.length).toBe(1); - selectableCardWrapper.first().simulate('click'); - expect(spy).toHaveBeenCalledWith('users'); - expect(wrapper.state('selectedResource')).toBe('users'); + ); + + expect(wrapper.find('SelectableCard').length).toBe(1); + wrapper.find('SelectableCard[label="Users"]').simulate('click'); + wrapper.update(); + expect( + wrapper.find('SelectableCard[label="Users"]').prop('isSelected') + ).toBe(true); }); }); diff --git a/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx b/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx index 826c2d52aa..32f0e6a96c 100644 --- a/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectRoleStep.jsx @@ -7,59 +7,55 @@ import { t } from '@lingui/macro'; import CheckboxCard from './CheckboxCard'; import SelectedList from '../SelectedList'; -class RolesStep extends React.Component { - render() { - const { - onRolesClick, - roles, - selectedListKey, - selectedListLabel, - selectedResourceRows, - selectedRoleRows, - i18n, - } = this.props; - - return ( - -
- {i18n._( - t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` - )} -
-
- {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])} - /> - ))} -
-
- ); - } +function RolesStep({ + onRolesClick, + roles, + selectedListKey, + selectedListLabel, + selectedResourceRows, + selectedRoleRows, + i18n, +}) { + return ( + +
+ {i18n._( + t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` + )} +
+
+ {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 = { diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 4f49268c0a..62b8983eb4 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormSelect, FormSelectOption } from '@patternfly/react-core'; -class AnsibleSelect extends React.Component { - constructor(props) { - super(props); - this.onSelectChange = this.onSelectChange.bind(this); - } - - onSelectChange(val, event) { - const { onChange, name } = this.props; +function AnsibleSelect({ + id, + data, + i18n, + isValid, + onBlur, + value, + className, + isDisabled, + onChange, + name, +}) { + const onSelectChange = (val, event) => { event.target.name = name; onChange(event, val); - } + }; - render() { - const { - id, - data, - i18n, - isValid, - onBlur, - value, - className, - isDisabled, - } = this.props; - - return ( - - {data.map(option => ( - - ))} - - ); - } + return ( + + {data.map(option => ( + + ))} + + ); } const Option = shape({ diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx index ced058754a..bb671c8823 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect'; +import AnsibleSelect from './AnsibleSelect'; const mockData = [ { @@ -16,6 +16,7 @@ const mockData = [ ]; describe('', () => { + const onChange = jest.fn(); test('initially renders succesfully', async () => { mountWithContexts( ', () => { }); test('calls "onSelectChange" on dropdown select change', () => { - const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange'); const wrapper = mountWithContexts( {}} + onChange={onChange} data={mockData} /> ); - expect(spy).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); wrapper.find('select').simulate('change'); - expect(spy).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); }); test('Returns correct select options', () => { diff --git a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx index 7ffce947d8..00a33b68c2 100644 --- a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx +++ b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx @@ -31,35 +31,31 @@ const ToolbarItem = styled(PFToolbarItem)` // TODO: Recommend renaming this component to avoid confusion // with ExpandingContainer -class ExpandCollapse extends React.Component { - render() { - const { isCompact, onCompact, onExpand, i18n } = this.props; - - return ( - - - - - - - - - ); - } +function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) { + return ( + + + + + + + + + ); } ExpandCollapse.propTypes = { diff --git a/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx b/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx index 8ac2f79bc4..407ebdb499 100644 --- a/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/DeleteRoleConfirmationModal.jsx @@ -7,69 +7,71 @@ import { t } from '@lingui/macro'; import AlertModal from '../AlertModal'; import { Role } from '../../types'; -class DeleteRoleConfirmationModal extends React.Component { - static propTypes = { - role: Role.isRequired, - username: string, - onCancel: func.isRequired, - onConfirm: func.isRequired, - }; - - static defaultProps = { - username: '', - }; - - isTeamRole() { - const { role } = this.props; +function DeleteRoleConfirmationModal({ + role, + username, + onCancel, + onConfirm, + i18n, +}) { + const isTeamRole = () => { return typeof role.team_id !== 'undefined'; - } + }; - render() { - const { role, username, onCancel, onConfirm, i18n } = this.props; - const title = i18n._( - t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` - ); - return ( - - {i18n._(t`Delete`)} - , - , - ]} - > - {this.isTeamRole() ? ( - - {i18n._( - t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` - )} -
-
- {i18n._( - t`If you only want to remove access for this particular user, please remove them from the team.` - )} -
- ) : ( - - {i18n._( - t`Are you sure you want to remove ${role.name} access from ${username}?` - )} - - )} -
- ); - } + const title = i18n._( + t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` + ); + return ( + + {i18n._(t`Delete`)} + , + , + ]} + > + {isTeamRole() ? ( + + {i18n._( + t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` + )} +
+
+ {i18n._( + t`If you only want to remove access for this particular user, please remove them from the team.` + )} +
+ ) : ( + + {i18n._( + t`Are you sure you want to remove ${role.name} access from ${username}?` + )} + + )} +
+ ); } +DeleteRoleConfirmationModal.propTypes = { + role: Role.isRequired, + username: string, + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + +DeleteRoleConfirmationModal.defaultProps = { + username: '', +}; + export default withI18n()(DeleteRoleConfirmationModal); diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index b5b1765d45..0f5f7c1c64 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) { setDeletionRole(role); setShowDeleteModal(true); }} + i18n={i18n} /> )} /> diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx index 3fe656a3fb..d641e67e01 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx @@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)` align-items: start; `; -class ResourceAccessListItem extends React.Component { - static propTypes = { +function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) { + ResourceAccessListItem.propTypes = { accessRecord: AccessRecord.isRequired, onRoleDelete: func.isRequired, }; - constructor(props) { - super(props); - this.renderChip = this.renderChip.bind(this); - } - - getRoleLists() { - const { accessRecord } = this.props; + const getRoleLists = () => { const teamRoles = []; const userRoles = []; @@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component { accessRecord.summary_fields.direct_access.map(sort); accessRecord.summary_fields.indirect_access.map(sort); return [teamRoles, userRoles]; - } + }; - renderChip(role) { - const { accessRecord, onRoleDelete } = this.props; + const renderChip = role => { return ( ); - } + }; - render() { - const { accessRecord, i18n } = this.props; - const [teamRoles, userRoles] = this.getRoleLists(); + const [teamRoles, userRoles] = getRoleLists(); - return ( - - - - {accessRecord.username && ( - - {accessRecord.id ? ( - - - {accessRecord.username} - - - ) : ( - + return ( + + + + {accessRecord.username && ( + + {accessRecord.id ? ( + + {accessRecord.username} - - )} - - )} - {accessRecord.first_name || accessRecord.last_name ? ( - - - - ) : null} - , - + + + ) : ( + + {accessRecord.username} + + )} + + )} + {accessRecord.first_name || accessRecord.last_name ? ( - {userRoles.length > 0 && ( - - {userRoles.map(this.renderChip)} - - } - /> - )} - {teamRoles.length > 0 && ( - - {teamRoles.map(this.renderChip)} - - } - /> - )} + - , - ]} - /> - - - ); - } + ) : null} + , + + + {userRoles.length > 0 && ( + + {userRoles.map(renderChip)} + + } + /> + )} + {teamRoles.length > 0 && ( + + {teamRoles.map(renderChip)} + + } + /> + )} + + , + ]} + /> + + + ); } export default withI18n()(ResourceAccessListItem); diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index c0a513cd48..ee8599b531 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -1,7 +1,7 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; -import { withRouter } from 'react-router-dom'; +import { useLocation, withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; import { Button, @@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div` border-bottom-color: var(--pf-global--BorderColor--200); `; -class Sort extends React.Component { - constructor(props) { - super(props); +function Sort({ columns, qsConfig, onSort, i18n }) { + const location = useLocation(); + const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false); - let sortKey; - let sortOrder; - let isNumeric; + let sortKey; + let sortOrder; + let isNumeric; - const { qsConfig, location } = this.props; - const queryParams = parseQueryString(qsConfig, location.search); - if (queryParams.order_by && queryParams.order_by.startsWith('-')) { - sortKey = queryParams.order_by.substr(1); - sortOrder = 'descending'; - } else if (queryParams.order_by) { - sortKey = queryParams.order_by; - sortOrder = 'ascending'; - } - - if (qsConfig.integerFields.find(field => field === sortKey)) { - isNumeric = true; - } else { - isNumeric = false; - } - - this.state = { - isSortDropdownOpen: false, - sortKey, - sortOrder, - isNumeric, - }; - - this.handleDropdownToggle = this.handleDropdownToggle.bind(this); - this.handleDropdownSelect = this.handleDropdownSelect.bind(this); - this.handleSort = this.handleSort.bind(this); + const queryParams = parseQueryString(qsConfig, location.search); + if (queryParams.order_by && queryParams.order_by.startsWith('-')) { + sortKey = queryParams.order_by.substr(1); + sortOrder = 'descending'; + } else if (queryParams.order_by) { + sortKey = queryParams.order_by; + sortOrder = 'ascending'; } - handleDropdownToggle(isSortDropdownOpen) { - this.setState({ isSortDropdownOpen }); + if (qsConfig.integerFields.find(field => field === sortKey)) { + isNumeric = true; + } else { + isNumeric = false; } - handleDropdownSelect({ target }) { - const { columns, onSort, qsConfig } = this.props; - const { sortOrder } = this.state; + const handleDropdownToggle = isOpen => { + setIsSortDropdownOpen(isOpen); + }; + + const handleDropdownSelect = ({ target }) => { const { innerText } = target; - const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); - - let isNumeric; - - if (qsConfig.integerFields.find(field => field === sortKey)) { + const [{ key }] = columns.filter(({ name }) => name === innerText); + sortKey = key; + if (qsConfig.integerFields.find(field => field === key)) { isNumeric = true; } else { isNumeric = false; } - this.setState({ isSortDropdownOpen: false, sortKey, isNumeric }); + setIsSortDropdownOpen(false); onSort(sortKey, sortOrder); - } + }; - handleSort() { - const { onSort } = this.props; - const { sortKey, sortOrder } = this.state; - const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending'; - this.setState({ sortOrder: newSortOrder }); - onSort(sortKey, newSortOrder); - } + const handleSort = () => { + onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending'); + }; - render() { - const { up } = DropdownPosition; - const { columns, i18n } = this.props; - const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state; + const { up } = DropdownPosition; - const defaultSortedColumn = columns.find(({ key }) => key === sortKey); + const defaultSortedColumn = columns.find(({ key }) => key === sortKey); - if (!defaultSortedColumn) { - throw new Error( - 'sortKey must match one of the column keys, check the sortColumns prop passed to ' - ); - } - - const sortedColumnName = defaultSortedColumn?.name; - - const sortDropdownItems = columns - .filter(({ key }) => key !== sortKey) - .map(({ key, name }) => ( - - {name} - - )); - - let SortIcon; - if (isNumeric) { - SortIcon = - sortOrder === 'ascending' - ? SortNumericDownIcon - : SortNumericDownAltIcon; - } else { - SortIcon = - sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; - } - - return ( - - {sortedColumnName && ( - - {(sortDropdownItems.length > 0 && ( - - {sortedColumnName} - - } - dropdownItems={sortDropdownItems} - /> - )) || {sortedColumnName}} - - - )} - + if (!defaultSortedColumn) { + throw new Error( + 'sortKey must match one of the column keys, check the sortColumns prop passed to ' ); } + + const sortedColumnName = defaultSortedColumn?.name; + + const sortDropdownItems = columns + .filter(({ key }) => key !== sortKey) + .map(({ key, name }) => ( + + {name} + + )); + + let SortIcon; + if (isNumeric) { + SortIcon = + sortOrder === 'ascending' ? SortNumericDownIcon : SortNumericDownAltIcon; + } else { + SortIcon = + sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon; + } + return ( + + {sortedColumnName && ( + + {(sortDropdownItems.length > 0 && ( + + {sortedColumnName} + + } + dropdownItems={sortDropdownItems} + /> + )) || {sortedColumnName}} + + + + )} + + ); } Sort.propTypes = { diff --git a/awx/ui_next/src/components/Sort/Sort.test.jsx b/awx/ui_next/src/components/Sort/Sort.test.jsx index 1764cf0298..c6cf89ad0d 100644 --- a/awx/ui_next/src/components/Sort/Sort.test.jsx +++ b/awx/ui_next/src/components/Sort/Sort.test.jsx @@ -1,5 +1,10 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; + import Sort from './Sort'; describe('', () => { @@ -105,7 +110,7 @@ describe('', () => { expect(onSort).toHaveBeenCalledWith('foo', 'ascending'); }); - test('Changing dropdown correctly passes back new sort key', () => { + test('Changing dropdown correctly passes back new sort key', async () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, @@ -131,44 +136,18 @@ describe('', () => { const wrapper = mountWithContexts( - ).find('Sort'); - - wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } }); + ); + act(() => wrapper.find('Dropdown').invoke('onToggle')(true)); + wrapper.update(); + await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true); + wrapper + .find('li') + .at(0) + .prop('onClick')({ target: { innerText: 'Bar' } }); + wrapper.update(); expect(onSort).toBeCalledWith('bar', 'ascending'); }); - test('Opening dropdown correctly updates state', () => { - const qsConfig = { - namespace: 'item', - defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, - integerFields: ['page', 'page_size'], - }; - - const columns = [ - { - name: 'Foo', - key: 'foo', - }, - { - name: 'Bar', - key: 'bar', - }, - { - name: 'Bakery', - key: 'bakery', - }, - ]; - - const onSort = jest.fn(); - - const wrapper = mountWithContexts( - - ).find('Sort'); - expect(wrapper.state('isSortDropdownOpen')).toEqual(false); - wrapper.instance().handleDropdownToggle(true); - expect(wrapper.state('isSortDropdownOpen')).toEqual(true); - }); - test('It displays correct sort icon', () => { const forwardNumericIconSelector = 'SortNumericDownIcon'; const reverseNumericIconSelector = 'SortNumericDownAltIcon'; diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index 2bc991b969..88e32be49f 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -1,6 +1,6 @@ import React, { Component, Fragment } from 'react'; import { withRouter } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; +import { I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; import { @@ -518,7 +518,7 @@ class JobOutput extends Component { } render() { - const { job, i18n } = this.props; + const { job } = this.props; const { contentError, @@ -596,15 +596,21 @@ class JobOutput extends Component { {deletionError && ( - this.setState({ deletionError: null })} - title={i18n._(t`Job Delete Error`)} - label={i18n._(t`Job Delete Error`)} - > - - + <> + + {({ i18n }) => ( + this.setState({ deletionError: null })} + title={i18n._(t`Job Delete Error`)} + label={i18n._(t`Job Delete Error`)} + > + + + )} + + )} ); @@ -612,4 +618,4 @@ class JobOutput extends Component { } export { JobOutput as _JobOutput }; -export default withI18n()(withRouter(JobOutput)); +export default withRouter(JobOutput); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index c5b454246f..8c52218c1e 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -310,7 +310,17 @@ function ProjectForm({ i18n, project, submitError, ...props }) { const { summary_fields = {} } = project; const [contentError, setContentError] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [scmSubFormState, setScmSubFormState] = useState(null); + const [scmSubFormState, setScmSubFormState] = useState({ + scm_url: '', + scm_branch: '', + scm_refspec: '', + credential: '', + scm_clean: false, + scm_delete_on_update: false, + scm_update_on_launch: false, + allow_override: false, + scm_update_cache_timeout: 0, + }); const [scmTypeOptions, setScmTypeOptions] = useState(null); const [credentials, setCredentials] = useState({ scm: { typeId: null, value: null }, diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx index 864142b046..5a4cc23fdc 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx @@ -6,8 +6,8 @@ import { SyncIcon } from '@patternfly/react-icons'; import { number } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; - import useRequest, { useDismissableError } from '../../../util/useRequest'; + import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import { ProjectsAPI } from '../../../api'; diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index 47b2b4011c..6d3691da71 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -27,71 +27,68 @@ const DataListAction = styled(_DataListAction)` grid-template-columns: 40px; `; -class TeamListItem extends React.Component { - static propTypes = { +function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) { + TeamListItem.propTypes = { team: Team.isRequired, detailUrl: string.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; - render() { - const { team, isSelected, onSelect, detailUrl, i18n } = this.props; - const labelId = `check-action-${team.id}`; + const labelId = `check-action-${team.id}`; - return ( - - - - - - {team.name} - - , - - {team.summary_fields.organization && ( - - {i18n._(t`Organization`)}{' '} - - {team.summary_fields.organization.name} - - - )} - , - ]} - /> - - {team.summary_fields.user_capabilities.edit ? ( - - - - ) : ( - '' - )} - - - - ); - } + return ( + + + + + + {team.name} + + , + + {team.summary_fields.organization && ( + + {i18n._(t`Organization`)}{' '} + + {team.summary_fields.organization.name} + + + )} + , + ]} + /> + + {team.summary_fields.user_capabilities.edit ? ( + + + + ) : ( + '' + )} + + + + ); } export default withI18n()(TeamListItem);