diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index 077a534d27..c9d774e002 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -99,6 +99,17 @@ class Inventories extends InstanceGroupsMixin(Base) { `${this.baseUrl}${inventoryId}/update_inventory_sources/` ); } + + readAdHocOptions(inventoryId) { + return this.http.options(`${this.baseUrl}${inventoryId}/ad_hoc_commands/`); + } + + launchAdHocCommands(inventoryId, values) { + return this.http.post( + `${this.baseUrl}${inventoryId}/ad_hoc_commands/`, + values + ); + } } export default Inventories; diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx new file mode 100644 index 0000000000..ea28e52652 --- /dev/null +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx @@ -0,0 +1,144 @@ +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 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, credentialTypeId }, + } = useRequest( + useCallback(async () => { + const [choices, credId] = await Promise.all([ + apiModule.readAdHocOptions(itemId), + CredentialTypesAPI.read({ namespace: 'ssh' }), + ]); + const itemObject = (item, index) => { + return { + key: index, + value: item, + label: `${item}`, + isDisabled: false, + }; + }; + + const options = choices.data.actions.GET.module_name.choices.map( + (choice, index) => itemObject(choice[0], index) + ); + + return { + moduleOptions: [itemObject('', -1), ...options], + credentialTypeId: credId.data.results[0].id, + }; + }, [itemId, apiModule]), + { moduleOptions: [] } + ); + + useEffect(() => { + fetchModuleOptions(); + }, [fetchModuleOptions]); + + const { + isloading: isLaunchLoading, + error: launchError, + request: launchAdHocCommands, + } = useRequest( + useCallback( + async values => { + const { data } = await apiModule.launchAdHocCommands(itemId, values); + history.push(`/jobs/command/${data.id}/output`); + }, + + [apiModule, itemId, history] + ) + ); + + const { error, dismissError } = useDismissableError( + launchError || fetchError + ); + + const handleSubmit = async values => { + const { credential, ...remainingValues } = values; + const newCredential = credential[0].id; + + const manipulatedValues = { + credential: newCredential, + ...remainingValues, + }; + await launchAdHocCommands(manipulatedValues); + setIsWizardOpen(false); + }; + + if (isLaunchLoading) { + return ; + } + + if (error && isWizardOpen) { + return ( + { + dismissError(); + setIsWizardOpen(false); + }} + > + {launchError ? ( + <> + {i18n._(t`Failed to launch job.`)} + + + ) : ( + + )} + + ); + } + return ( + + {children({ + openAdHocCommands: () => setIsWizardOpen(true), + })} + + {isWizardOpen && ( + setIsWizardOpen(false)} + onLaunch={handleSubmit} + onDismissError={() => dismissError()} + /> + )} + + ); +} + +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 new file mode 100644 index 0000000000..b9582daedc --- /dev/null +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx @@ -0,0 +1,347 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { CredentialTypesAPI, InventoriesAPI, CredentialsAPI } from '../../api'; +import AdHocCommands from './AdHocCommands'; + +jest.mock('../../api/models/CredentialTypes'); +jest.mock('../../api/models/Inventories'); +jest.mock('../../api/models/Credentials'); + +const credentials = [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, +]; +const adHocItems = [ + { + name: 'Inventory 1 Org 0', + }, + { name: 'Inventory 2 Org 0' }, +]; + +const children = ({ openAdHocCommands }) => ( + - - , + + {({ isKebabified }) => ( + <> + {isKebabified ? ( + kebabedAdditionalControls() + ) : ( + + + + + {({ openAdHocCommands }) => ( + + )} + + + + + +
+ +
+
+
+
+ )} + + )} +
, ]} /> )} @@ -241,6 +325,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')(); }); }); }); diff --git a/awx/ui_next/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.jsx b/awx/ui_next/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.jsx index 7dd5b2b4bd..044c5adafd 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.jsx @@ -1,7 +1,7 @@ import 'styled-components/macro'; import React, { useEffect, useRef } from 'react'; import { withI18n } from '@lingui/react'; -import { t, Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; import { useField, useFormikContext } from 'formik'; import { Switch, Text } from '@patternfly/react-core'; import { @@ -69,32 +69,30 @@ function CustomMessagesSubForm({ defaultMessages, type, i18n }) { css="margin-bottom: var(--pf-c-content--MarginBottom)" > - - Use custom messages to change the content of notifications sent - when a job starts, succeeds, or fails. Use curly braces to - access information about the job:{' '} - - {'{{'} job_friendly_name {'}}'} - - ,{' '} - - {'{{'} url {'}}'} - - , or attributes of the job such as{' '} - - {'{{'} job.status {'}}'} - - . You may apply a number of possible variables in the message. - Refer to the{' '} - - Ansible Tower documentation - {' '} - for more details. - + Use custom messages to change the content of notifications sent + when a job starts, succeeds, or fails. Use curly braces to access + information about the job:{' '} + + {'{{'} job_friendly_name {'}}'} + + ,{' '} + + {'{{'} url {'}}'} + + , or attributes of the job such as{' '} + + {'{{'} job.status {'}}'} + + . You may apply a number of possible variables in the message. + Refer to the{' '} + + Ansible Tower documentation + {' '} + for more details.