diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx index 0fa1990a6d..e5e44ab91f 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx @@ -2,23 +2,30 @@ import React, { useState, Fragment, useCallback, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import PropTypes from 'prop-types'; import useRequest, { useDismissableError } from '../../util/useRequest'; import AlertModal from '../AlertModal'; import { CredentialTypesAPI } from '../../api'; import ErrorDetail from '../ErrorDetail'; -import AdHocCommandsForm from './AdHocCommandsWizard'; +import AdHocCommandsWizard from './AdHocCommandsWizard'; import ContentLoading from '../ContentLoading'; import ContentError from '../ContentError'; function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { const [isWizardOpen, setIsWizardOpen] = useState(false); const history = useHistory(); - + const verbosityOptions = [ + { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, + { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, + { value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) }, + { value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, + { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, + ]; const { error: fetchError, request: fetchModuleOptions, - result: { moduleOptions, verbosityOptions, credentialTypeId }, + result: { moduleOptions, credentialTypeId }, } = useRequest( useCallback(async () => { const [choices, credId] = await Promise.all([ @@ -38,13 +45,8 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { (choice, index) => itemObject(choice[0], index) ); - const verbosityItems = choices.data.actions.GET.verbosity.choices.map( - (choice, index) => itemObject(choice[0], index) - ); - return { moduleOptions: [itemObject('', -1), ...options], - verbosityOptions: [itemObject('', -1), ...verbosityItems], credentialTypeId: credId.data.results[0].id, }; }, [itemId, apiModule]), @@ -65,6 +67,7 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { const { data } = await apiModule.launchAdHocCommands(itemId, values); history.push(`/jobs/${data.module_name}/${data.id}/output`); }, + [apiModule, itemId, history] ) ); @@ -73,16 +76,11 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { launchError || fetchError ); - const handleSubmit = async (values, limitTypedValue) => { - const { credential, limit, ...remainingValues } = values; + const handleSubmit = async values => { + const { credential, ...remainingValues } = values; const newCredential = credential[0].id; - if (limitTypedValue) { - values.limit = limit.concat(limitTypedValue); - } - const stringifyLimit = values.limit.join(', ').trim(); const manipulatedValues = { - limit: stringifyLimit[0], credential: newCredential, ...remainingValues, }; @@ -105,7 +103,14 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { setIsWizardOpen(false); }} > - + {launchError ? ( + <> + {i18n._(t`Failed to launch job.`)} + + + ) : ( + + )} ); } @@ -116,30 +121,24 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { })} {isWizardOpen && ( - setIsWizardOpen(false)} onLaunch={handleSubmit} - error={error} onDismissError={() => dismissError()} /> )} - {launchError && ( - - {i18n._(t`Failed to launch job.`)} - - - )} ); } +AdHocCommands.propTypes = { + children: PropTypes.func.isRequired, + adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, + itemId: PropTypes.number.isRequired, +}; + export default withI18n()(AdHocCommands); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx index dadd88fc86..ad0deb1c62 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx @@ -20,7 +20,7 @@ const credentials = [ ]; const adHocItems = [ { - name: ' Inventory 1 Org 0', + name: 'Inventory 1 Org 0', }, { name: 'Inventory 2 Org 0' }, ]; @@ -43,6 +43,7 @@ describe('', () => { apiModule={InventoriesAPI} adHocItems={adHocItems} itemId={1} + credentialTypeId={1} > {children} @@ -57,6 +58,7 @@ describe('', () => { apiModule={InventoriesAPI} adHocItems={adHocItems} itemId={1} + credentialTypeId={1} > {children} @@ -91,12 +93,13 @@ describe('', () => { apiModule={InventoriesAPI} adHocItems={adHocItems} itemId={1} + credentialTypeId={1} > {children} ); }); - wrapper.find('button').prop('onClick')(); + await act(async () => wrapper.find('button').prop('onClick')()); wrapper.update(); @@ -128,29 +131,32 @@ describe('', () => { count: 5, }, }); + InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } }); await act(async () => { wrapper = mountWithContexts( {children} ); }); - wrapper.find('button').prop('onClick')(); + await act(async () => wrapper.find('button').prop('onClick')()); wrapper.update(); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); + expect( wrapper - .find('WizardNavItem[content="Machine Credential"]') + .find('WizardNavItem[content="Machine credential"]') .prop('isDisabled') ).toBe(true); - act(() => { + await act(async () => { wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( {}, 'command' @@ -194,7 +200,7 @@ describe('', () => { escalation: false, extra_vars: '---', forks: 0, - limit: 'I', + limit: 'Inventory 1 Org 0, Inventory 2 Org 0', module_args: 'command', verbosity: 1, }); @@ -203,6 +209,7 @@ describe('', () => { expect(wrapper.find('AdHocCommandsWizard').length).toBe(0); }); + test('should throw error on submission properly', async () => { InventoriesAPI.launchAdHocCommands.mockRejectedValue( new Error({ @@ -245,24 +252,25 @@ describe('', () => { {children} ); }); - wrapper.find('button').prop('onClick')(); + await act(async () => wrapper.find('button').prop('onClick')()); wrapper.update(); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect( wrapper - .find('WizardNavItem[content="Machine Credential"]') + .find('WizardNavItem[content="Machine credential"]') .prop('isDisabled') ).toBe(true); - act(() => { + await act(async () => { wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( {}, 'command' @@ -327,12 +335,13 @@ describe('', () => { apiModule={InventoriesAPI} adHocItems={adHocItems} itemId={1} + credentialTypeId={1} > {children} ); }); - wrapper.find('button').prop('onClick')(); + await act(async () => wrapper.find('button').prop('onClick')()); wrapper.update(); expect(wrapper.find('ErrorDetail').length).toBe(1); }); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx index 1fb7d30253..273a4ee2fb 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx @@ -2,10 +2,11 @@ import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { withFormik, useFormikContext } from 'formik'; +import PropTypes from 'prop-types'; import Wizard from '../Wizard'; import AdHocCredentialStep from './AdHocCredentialStep'; -import DetailsStep from './DetailsStep'; +import AdHocDetailsStep from './AdHocDetailsStep'; function AdHocCommandsWizard({ onLaunch, @@ -16,31 +17,43 @@ function AdHocCommandsWizard({ credentialTypeId, }) { const [currentStepId, setCurrentStepId] = useState(1); - const [limitTypedValue, setLimitTypedValue] = useState(''); const [enableLaunch, setEnableLaunch] = useState(false); const { values } = useFormikContext(); + const enabledNextOnDetailsStep = () => { + if (!values.module_args) { + return false; + } + + if (values.module_args === 'shell' || values.module_args === 'command') { + if (values.arguments) { + return true; + // eslint-disable-next-line no-else-return + } else { + return false; + } + } + return undefined; // makes the linter happy; + }; const steps = [ { id: 1, key: 1, name: i18n._(t`Details`), component: ( - setLimitTypedValue(value)} - limitValue={limitTypedValue} /> ), - enableNext: values.module_args && values.arguments && values.verbosity, + enableNext: enabledNextOnDetailsStep(), nextButtonText: i18n._(t`Next`), }, { id: 2, key: 2, - name: i18n._(t`Machine Credential`), + name: i18n._(t`Machine credential`), component: ( step.id === currentStepId); - const submit = () => { - onLaunch(values, limitTypedValue); - }; - return ( setCurrentStepId(step.id)} onClose={() => onCloseWizard()} - onSave={submit} + onSave={() => { + onLaunch(values); + }} steps={steps} - title={i18n._(t`Ad Hoc Commands`)} + title={i18n._(t`Run command`)} nextButtonText={currentStep.nextButtonText || undefined} backButtonText={i18n._(t`Back`)} cancelButtonText={i18n._(t`Cancel`)} @@ -76,14 +87,14 @@ function AdHocCommandsWizard({ } const FormikApp = withFormik({ - mapPropsToValues({ adHocItems }) { - const adHocItemStrings = adHocItems.map(item => item.name); + mapPropsToValues({ adHocItems, verbosityOptions }) { + const adHocItemStrings = adHocItems.map(item => item.name).join(', '); return { limit: adHocItemStrings || [], credential: [], module_args: '', arguments: '', - verbosity: '', + verbosity: verbosityOptions[0].value, forks: 0, changes: false, escalation: false, @@ -92,4 +103,11 @@ const FormikApp = withFormik({ }, })(AdHocCommandsWizard); +FormikApp.propTypes = { + onLaunch: PropTypes.func.isRequired, + moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + onCloseWizard: PropTypes.func.isRequired, + credentialTypeId: PropTypes.number.isRequired, +}; export default withI18n()(FormikApp); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx index 54dc1c9daf..0b4fe8347a 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx @@ -10,7 +10,13 @@ import AdHocCommandsWizard from './AdHocCommandsWizard'; jest.mock('../../api/models/CredentialTypes'); jest.mock('../../api/models/Inventories'); jest.mock('../../api/models/Credentials'); - +const verbosityOptions = [ + { value: '0', key: '0', label: '0 (Normal)' }, + { value: '1', key: '1', label: '1 (Verbose)' }, + { value: '2', key: '2', label: '2 (More Verbose)' }, + { value: '3', key: '3', label: '3 (Debug)' }, + { value: '4', key: '4', label: '4 (Connection Debug)' }, +]; const adHocItems = [ { name: 'Inventory 1' }, { name: 'Inventory 2' }, @@ -26,7 +32,7 @@ describe('', () => { adHocItems={adHocItems} onLaunch={onLaunch} moduleOptions={[]} - verbosityOptions={[]} + verbosityOptions={verbosityOptions} onCloseWizard={() => {}} credentialTypeId={1} /> @@ -39,14 +45,11 @@ describe('', () => { }); test('should mount properly', async () => { - // wrapper.update(); expect(wrapper.find('AdHocCommandsWizard').length).toBe(1); }); test('next and nav item should be disabled', async () => { - // wrapper.update(); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); - expect( wrapper.find('WizardNavItem[content="Details"]').prop('isCurrent') ).toBe(true); @@ -55,12 +58,12 @@ describe('', () => { ).toBe(false); expect( wrapper - .find('WizardNavItem[content="Machine Credential"]') + .find('WizardNavItem[content="Machine credential"]') .prop('isDisabled') ).toBe(true); expect( wrapper - .find('WizardNavItem[content="Machine Credential"]') + .find('WizardNavItem[content="Machine credential"]') .prop('isCurrent') ).toBe(false); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); @@ -69,7 +72,7 @@ describe('', () => { test('next button should become active, and should navigate to the next step', async () => { await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); - act(() => { + await act(async () => { wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( {}, 'command' @@ -83,22 +86,25 @@ describe('', () => { expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( false ); - wrapper.find('Button[type="submit"]').prop('onClick')(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); }); test('launch button should become active', async () => { CredentialsAPI.read.mockResolvedValue({ data: { results: [ - { id: 1, name: 'Cred 1' }, - { id: 2, name: 'Cred2' }, + { id: 1, name: 'Cred 1', url: '' }, + { id: 2, name: 'Cred2', url: '' }, ], count: 2, }, }); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); - act(() => { + await act(async () => { wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( {}, 'command' @@ -112,7 +118,9 @@ describe('', () => { expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( false ); - wrapper.find('Button[type="submit"]').prop('onClick')(); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); wrapper.update(); await waitForElement(wrapper, 'OptionsList', el => el.length > 0); @@ -133,7 +141,11 @@ describe('', () => { expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( false ); - wrapper.find('Button[type="submit"]').prop('onClick')(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + expect(onLaunch).toHaveBeenCalled(); }); @@ -152,7 +164,7 @@ describe('', () => { ); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); - act(() => { + await act(async () => { wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( {}, 'command' @@ -166,11 +178,12 @@ describe('', () => { expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( false ); - wrapper.find('Button[type="submit"]').prop('onClick')(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); wrapper.update(); - expect(wrapper.find('ContentLoading').length).toBe(1); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('ContentError').length).toBe(1); }); }); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx index 3c1c81d147..ade3528ea1 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx @@ -2,9 +2,11 @@ import React, { useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import PropTypes from 'prop-types'; import { useField } from 'formik'; import { Form, FormGroup } from '@patternfly/react-core'; import { CredentialsAPI } from '../../api'; +import { FieldTooltip } from '../FormField'; import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; import useRequest from '../../util/useRequest'; @@ -68,6 +70,13 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) { !credentialMeta.touched || !credentialMeta.error ? 'default' : 'error' } helperTextInvalid={credentialMeta.error} + labelIcon={ + + } > } @@ -85,7 +75,9 @@ function CredentialStep({ name="arguments" type="text" label={i18n._(t`Arguments`)} - isRequired + isRequired={ + moduleField.value === 'command' || moduleField.value === 'shell' + } tooltip={i18n._( t`These arguments are used with the specified module.` )} @@ -118,54 +110,26 @@ function CredentialStep({ }} /> - - - {i18n._( - t`The pattern used to target hosts in the inventory. Leaving the field blank, all, and * will all target all hosts in the inventory. You can find more information about Ansible's host patterns` - )}{' '} - - {i18n._(`here`)} - - - } - /> + tooltip={ + + {i18n._( + t`The pattern used to target hosts in the inventory. Leaving the field blank, all, and * will all target all hosts in the inventory. You can find more information about Ansible's host patterns` + )}{' '} + + {i18n._(`here`)} + + } - > - - - {limitField.value.map((item, index) => ( - - ))} - - { - onLimitChange(value); - }} - /> - - + /> {i18n._( - t`The number of parallel or simultaneous processes to use while executing the playbook. Inputting no value will use the default value from the ` + t`The number of parallel or simultaneous processes to use while executing the playbook. Inputting no value will use the default value from the ansible configuration file. You can find more information` )}{' '} - {i18n._(t`ansible configuration file.`)} + {i18n._(t`here.`)} } @@ -225,13 +189,13 @@ function CredentialStep({ content={

{i18n._(t`Enables creation of a provisioning - callback URL. Using the URL a host can contact BRAND_NAME + callback URL. Using the URL a host can contact ${brandName} and request a configuration update using this job template`)}   - --{i18n._(t`become`)}   + --become {i18n._(t`option to the`)}   - {i18n._(t`ansible`)}   + ansible {i18n._(t`command`)}

} @@ -297,4 +261,9 @@ function CredentialStep({ ); } +CredentialStep.propTypes = { + moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, +}; + export default withI18n()(CredentialStep); diff --git a/awx/ui_next/src/components/AdHocCommands/DetailsStep.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx similarity index 82% rename from awx/ui_next/src/components/AdHocCommands/DetailsStep.test.jsx rename to awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx index b5cb42e9da..7dc7b82ba5 100644 --- a/awx/ui_next/src/components/AdHocCommands/DetailsStep.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; -import DetailsStep from './DetailsStep'; +import DetailsStep from './AdHocDetailsStep'; jest.mock('../../api/models/Credentials'); @@ -17,7 +17,6 @@ const moduleOptions = [ { key: 1, value: 'shell', label: 'shell', isDisabled: false }, ]; const onLimitChange = jest.fn(); -const limitValue = ''; const initialValues = { limit: ['Inventory 1', 'inventory 2'], credential: [], @@ -46,7 +45,6 @@ describe('', () => { verbosityOptions={verbosityOptions} moduleOptions={moduleOptions} onLimitChange={onLimitChange} - limitValue={limitValue} /> ); @@ -61,7 +59,6 @@ describe('', () => { verbosityOptions={verbosityOptions} moduleOptions={moduleOptions} onLimitChange={onLimitChange} - limitValue={limitValue} /> ); @@ -69,7 +66,7 @@ describe('', () => { expect(wrapper.find('FormGroup[label="Module"]').length).toBe(1); expect(wrapper.find('FormField[name="arguments"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Verbosity"]').length).toBe(1); - expect(wrapper.find('FormGroup[label="Limit"]').length).toBe(1); + expect(wrapper.find('FormField[label="Limit"]').length).toBe(1); expect(wrapper.find('FormField[name="forks"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Show changes"]').length).toBe(1); expect( @@ -86,7 +83,6 @@ describe('', () => { verbosityOptions={verbosityOptions} moduleOptions={moduleOptions} onLimitChange={onLimitChange} - limitValue={limitValue} /> ); @@ -100,6 +96,12 @@ describe('', () => { wrapper.find('input#arguments').simulate('change', { target: { value: 'foo', name: 'arguments' }, }); + wrapper.find('input#limit').simulate('change', { + target: { + value: 'Inventory 1, inventory 2, new inventory', + name: 'limit', + }, + }); wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1); wrapper.find('TextInputBase[name="forks"]').simulate('change', { @@ -121,7 +123,9 @@ describe('', () => { 1 ); expect(wrapper.find('TextInputBase[name="forks"]').prop('value')).toBe(10); - expect(wrapper.find('TextInputBase[label="Limit"]').prop('value')).toBe(''); + expect(wrapper.find('TextInputBase[name="limit"]').prop('value')).toBe( + 'Inventory 1, inventory 2, new inventory' + ); expect(wrapper.find('Switch').prop('isChecked')).toBe(true); expect( wrapper @@ -129,22 +133,4 @@ describe('', () => { .prop('isChecked') ).toBe(true); }); - - test('should mount with proper limit value', async () => { - await act(async () => { - wrapper = mountWithContexts( - - - - ); - }); - expect(wrapper.find('TextInputBase[label="Limit"]').prop('value')).toBe( - 'foo value' - ); - }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index ef9dde9d94..7b1a56dd8a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -2,7 +2,13 @@ import React, { useCallback, useState, useEffect } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button, Tooltip } from '@patternfly/react-core'; +import { + Button, + Tooltip, + DropdownItem, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useSelected from '../../../util/useSelected'; import useRequest from '../../../util/useRequest'; @@ -17,6 +23,7 @@ import PaginatedDataList, { import InventoryGroupItem from './InventoryGroupItem'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands'; +import { Kebabified } from '../../../contexts/Kebabified'; const QS_CONFIG = getQSConfig('group', { page: 1, @@ -141,9 +148,37 @@ function InventoryGroupsList({ i18n }) { setSelected([]); setIsDeleteLoading(false); }; - const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const kebabedAdditionalControls = () => { + return ( + <> + + {({ openAdHocCommands }) => ( + + {i18n._(t`Run command`)} + + )} + + + {i18n._(t`Delete`)} + + + ); + }; return ( <> @@ -213,48 +248,66 @@ function InventoryGroupsList({ i18n }) { />, ] : []), - -
- -
-
, - [ - - - {({ openAdHocCommands }) => ( - + + {({ isKebabified }) => ( + <> + {isKebabified ? ( + kebabedAdditionalControls() + ) : ( + + + + + {({ openAdHocCommands }) => ( + + )} + + + + + +
+ +
+
+
+
)} -
-
, - ], + + )} + , ]} /> )} @@ -271,6 +324,7 @@ function InventoryGroupsList({ i18n }) { setDeletionError(null)} > diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx index 0827f68780..6684a2a01e 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx @@ -88,6 +88,10 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); test('initially renders successfully', () => { expect(wrapper.find('InventoryGroupsList').length).toBe(1); @@ -143,15 +147,17 @@ describe('', () => { expect(el.props().checked).toBe(false); }); }); - +}); +describe(' error handling', () => { + let wrapper; test('should show content error when api throws error on initial render', async () => { - InventoriesAPI.readGroupsOptions.mockImplementation(() => + InventoriesAPI.readGroupsOptions.mockImplementationOnce(() => Promise.reject(new Error()) ); await act(async () => { wrapper = mountWithContexts(); }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); + await waitForElement(wrapper, 'ContentError', el => el.length > 0); }); test('should show content error if groups are not successfully fetched from api', async () => { @@ -159,26 +165,27 @@ describe('', () => { Promise.reject(new Error()) ); await act(async () => { - wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(); + wrapper = mountWithContexts(); }); - wrapper.update(); - await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); - }); - await waitForElement( - wrapper, - 'InventoryGroupsDeleteModal', - el => el.props().isModalOpen === true - ); - await act(async () => { - wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') - .invoke('onClick')(); - }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); + + await waitForElement(wrapper, 'ContentError', el => el.length > 0); }); test('should show error modal when group is not successfully deleted from api', async () => { + InventoriesAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); GroupsAPI.destroy.mockRejectedValue( new Error({ response: { @@ -190,6 +197,25 @@ describe('', () => { }, }) ); + + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/3/groups'], + }); + + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => { wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(); }); @@ -213,11 +239,14 @@ describe('', () => { }); await waitForElement( wrapper, - 'AlertModal[title="Error!"] Modal', + 'AlertModal[aria-label="deletion error"] Modal', el => el.props().isOpen === true && el.props().title === 'Error!' ); + await act(async () => { - wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + wrapper + .find('AlertModal[aria-label="deletion error"]') + .invoke('onClose')(); }); }); });