diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js index ea506cc303..0e76edca8c 100644 --- a/awx/ui_next/src/api/models/Groups.js +++ b/awx/ui_next/src/api/models/Groups.js @@ -12,7 +12,9 @@ class Groups extends Base { } associateHost(id, hostId) { - return this.http.post(`${this.baseUrl}${id}/hosts/`, { id: hostId }); + return this.http.post(`${this.baseUrl}${id}/hosts/`, { + id: hostId, + }); } createHost(id, data) { @@ -20,7 +22,9 @@ class Groups extends Base { } readAllHosts(id, params) { - return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params }); + return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { + params, + }); } disassociateHost(id, host) { @@ -29,6 +33,10 @@ class Groups extends Base { disassociate: true, }); } + + readChildren(id, params) { + return this.http.get(`${this.baseUrl}${id}/children/`, params); + } } export default Groups; diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx index ea28e52652..927ad09f78 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx @@ -1,19 +1,25 @@ -import React, { useState, Fragment, useCallback, useEffect } from 'react'; +import React, { 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 useRequest, { useDismissableError } from '../../util/useRequest'; +import { InventoriesAPI } from '../../api'; + 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); +function AdHocCommands({ + onClose, + adHocItems, + itemId, + i18n, + moduleOptions, + credentialTypeId, +}) { const history = useHistory(); const verbosityOptions = [ { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, @@ -22,59 +28,26 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { { 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, + error, request: launchAdHocCommands, } = useRequest( useCallback( async values => { - const { data } = await apiModule.launchAdHocCommands(itemId, values); + const { data } = await InventoriesAPI.launchAdHocCommands( + itemId, + values + ); history.push(`/jobs/command/${data.id}/output`); }, - [apiModule, itemId, history] + [itemId, history] ) ); - const { error, dismissError } = useDismissableError( - launchError || fetchError - ); + const { dismissError } = useDismissableError(error); const handleSubmit = async values => { const { credential, ...remainingValues } = values; @@ -85,14 +58,13 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { ...remainingValues, }; await launchAdHocCommands(manipulatedValues); - setIsWizardOpen(false); }; if (isLaunchLoading) { return ; } - if (error && isWizardOpen) { + if (error) { return ( { dismissError(); - setIsWizardOpen(false); }} > - {launchError ? ( - <> - {i18n._(t`Failed to launch job.`)} - - - ) : ( - - )} + <> + {i18n._(t`Failed to launch job.`)} + + ); } return ( - - {children({ - openAdHocCommands: () => setIsWizardOpen(true), - })} - - {isWizardOpen && ( - setIsWizardOpen(false)} - onLaunch={handleSubmit} - onDismissError={() => dismissError()} - /> - )} - + dismissError()} + /> ); } AdHocCommands.propTypes = { - children: PropTypes.func.isRequired, adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, itemId: PropTypes.number.isRequired, }; diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx index b9582daedc..f83009b46a 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx @@ -18,6 +18,10 @@ const credentials = [ { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]; +const moduleOptions = [ + ['command', 'command'], + ['shell', 'shell'], +]; const adHocItems = [ { name: 'Inventory 1 Org 0', @@ -25,10 +29,6 @@ const adHocItems = [ { name: 'Inventory 2 Org 0' }, ]; -const children = ({ openAdHocCommands }) => ( - + + + ) + } + , setIsModalOpen(true)} onAddNew={() => history.push(addFormUrl)} /> @@ -239,6 +298,16 @@ function InventoryGroupHostList({ i18n }) { title={i18n._(t`Select Hosts`)} /> )} + {isAdHocCommandsOpen && ( + setIsAdHocCommandsOpen(false)} + credentialTypeId={credentialTypeId} + moduleOptions={moduleOptions} + /> + )} {associateError && ( ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ @@ -34,6 +35,17 @@ describe('', () => { }, }, }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + POST: {}, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, + }); await act(async () => { wrapper = mountWithContexts(); }); @@ -95,6 +107,29 @@ describe('', () => { }); }); + test('should render enabled ad hoc commands button', async () => { + GroupsAPI.readAllHosts.mockResolvedValue({ + data: { ...mockHosts }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + + await waitForElement( + wrapper, + 'button[aria-label="Run command"]', + el => el.prop('disabled') === false + ); + }); + test('should show add dropdown button according to permissions', async () => { expect(wrapper.find('AddHostDropdown').length).toBe(1); InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index 5c554af363..66277ab537 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -12,7 +12,7 @@ import { import { getQSConfig, parseQueryString } from '../../../util/qs'; import useSelected from '../../../util/useSelected'; import useRequest from '../../../util/useRequest'; -import { InventoriesAPI, GroupsAPI } from '../../../api'; +import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import DataListToolbar from '../../../components/DataListToolbar'; @@ -22,7 +22,8 @@ import PaginatedDataList, { import InventoryGroupItem from './InventoryGroupItem'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; -import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands'; + +import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import { Kebabified } from '../../../contexts/Kebabified'; const QS_CONFIG = getQSConfig('group', { @@ -51,6 +52,7 @@ const useModal = () => { function InventoryGroupsList({ i18n }) { const [deletionError, setDeletionError] = useState(null); const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const location = useLocation(); const { isModalOpen, toggleModal } = useModal(); const { id: inventoryId } = useParams(); @@ -62,27 +64,36 @@ function InventoryGroupsList({ i18n }) { actions, relatedSearchableKeys, searchableKeys, + moduleOptions, + credentialTypeId, + isAdHocDisabled, }, error: contentError, isLoading, - request: fetchGroups, + request: fetchData, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const [response, actionsResponse] = await Promise.all([ + const [response, groupOptions, adHocOptions, cred] = await Promise.all([ InventoriesAPI.readGroups(inventoryId, params), InventoriesAPI.readGroupsOptions(inventoryId), + InventoriesAPI.readAdHocOptions(inventoryId), + CredentialTypesAPI.read({ namespace: 'ssh' }), ]); + return { groups: response.data.results, groupCount: response.data.count, - actions: actionsResponse.data.actions, + actions: groupOptions.data.actions, relatedSearchableKeys: ( - actionsResponse?.data?.related_search_fields || [] + groupOptions?.data?.related_search_fields || [] ).map(val => val.slice(0, -8)), searchableKeys: Object.keys( - actionsResponse.data.actions?.GET || {} - ).filter(key => actionsResponse.data.actions?.GET[key].filterable), + groupOptions.data.actions?.GET || {} + ).filter(key => groupOptions.data.actions?.GET[key].filterable), + moduleOptions: adHocOptions.data.actions.GET.module_name.choices, + credentialTypeId: cred.data.results[0].id, + isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [inventoryId, location]), { @@ -95,8 +106,8 @@ function InventoryGroupsList({ i18n }) { ); useEffect(() => { - fetchGroups(); - }, [fetchGroups]); + fetchData(); + }, [fetchData]); const { selected, isAllSelected, handleSelect, setSelected } = useSelected( groups @@ -144,7 +155,7 @@ function InventoryGroupsList({ i18n }) { } toggleModal(); - fetchGroups(); + fetchData(); setSelected([]); setIsDeleteLoading(false); }; @@ -153,21 +164,14 @@ function InventoryGroupsList({ i18n }) { const kebabedAdditionalControls = () => { return ( <> - setIsAdHocCommandsOpen(true)} + isDisabled={groupCount === 0 || isAdHocDisabled} > - {({ openAdHocCommands }) => ( - - {i18n._(t`Run command`)} - - )} - + {i18n._(t`Run command`)} + + - setIsAdHocCommandsOpen(true)} + isDisabled={groupCount === 0 || isAdHocDisabled} > - {({ openAdHocCommands }) => ( - - )} - + {i18n._(t`Run command`)} + @@ -321,6 +316,16 @@ function InventoryGroupsList({ i18n }) { ) } /> + {isAdHocCommandsOpen && ( + setIsAdHocCommandsOpen(false)} + credentialTypeId={credentialTypeId} + moduleOptions={moduleOptions} + /> + )} {deletionError && ( ', () => { }, }, }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + POST: {}, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, + }); const history = createMemoryHistory({ initialEntries: ['/inventories/inventory/3/groups'], }); @@ -147,31 +158,17 @@ describe('', () => { expect(el.props().checked).toBe(false); }); }); + test('should render enabled ad hoc commands button', async () => { + await waitForElement( + wrapper, + 'button[aria-label="Run command"]', + el => el.prop('disabled') === false + ); + }); }); describe(' error handling', () => { let wrapper; - test('should show content error when api throws error on initial render', async () => { - InventoriesAPI.readGroupsOptions.mockImplementationOnce(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'ContentError', el => el.length > 0); - }); - - test('should show content error if groups are not successfully fetched from api', async () => { - InventoriesAPI.readGroups.mockImplementation(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper = mountWithContexts(); - }); - - await waitForElement(wrapper, 'ContentError', el => el.length > 0); - }); - - test('should show error modal when group is not successfully deleted from api', async () => { + beforeEach(() => { InventoriesAPI.readGroups.mockResolvedValue({ data: { count: mockGroups.length, @@ -197,7 +194,42 @@ describe(' error handling', () => { }, }) ); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readGroupsOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length > 0); + }); + test('should show content error if groups are not successfully fetched from api', async () => { + InventoriesAPI.readGroups.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length > 0); + }); + + test('should show error modal when group is not successfully deleted from api', async () => { const history = createMemoryHistory({ initialEntries: ['/inventories/inventory/3/groups'], }); @@ -249,4 +281,27 @@ describe(' error handling', () => { .invoke('onClose')(); }); }); + test('should render disabled ad hoc button', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/3/groups'], + }); + + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect( + wrapper.find('button[aria-label="Run command"]').prop('disabled') + ).toBe(true); + }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx index 609de04f5b..8aa095a7a8 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -2,13 +2,19 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { + Button, + Tooltip, + DropdownItem, + ToolbarItem, +} from '@patternfly/react-core'; import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; import useRequest, { useDismissableError, useDeleteItems, } from '../../../util/useRequest'; import useSelected from '../../../util/useSelected'; -import { HostsAPI, InventoriesAPI } from '../../../api'; +import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api'; import DataListToolbar from '../../../components/DataListToolbar'; import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; @@ -17,6 +23,8 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import AssociateModal from '../../../components/AssociateModal'; import DisassociateButton from '../../../components/DisassociateButton'; +import { Kebabified } from '../../../contexts/Kebabified'; +import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import InventoryHostGroupItem from './InventoryHostGroupItem'; const QS_CONFIG = getQSConfig('group', { @@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('group', { function InventoryHostGroupsList({ i18n }) { const [isModalOpen, setIsModalOpen] = useState(false); + const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const { hostId, id: invId } = useParams(); const { search } = useLocation(); @@ -37,6 +46,9 @@ function InventoryHostGroupsList({ i18n }) { actions, relatedSearchableKeys, searchableKeys, + moduleOptions, + isAdHocDisabled, + credentialTypeId, }, error: contentError, isLoading, @@ -49,22 +61,29 @@ function InventoryHostGroupsList({ i18n }) { { data: { count, results }, }, - actionsResponse, + hostGroupOptions, + adHocOptions, + cred, ] = await Promise.all([ HostsAPI.readAllGroups(hostId, params), HostsAPI.readGroupsOptions(hostId), + InventoriesAPI.readAdHocOptions(invId), + CredentialTypesAPI.read({ namespace: 'ssh' }), ]); return { groups: results, itemCount: count, - actions: actionsResponse.data.actions, + actions: hostGroupOptions.data.actions, relatedSearchableKeys: ( - actionsResponse?.data?.related_search_fields || [] + hostGroupOptions?.data?.related_search_fields || [] ).map(val => val.slice(0, -8)), searchableKeys: Object.keys( - actionsResponse.data.actions?.GET || {} - ).filter(key => actionsResponse.data.actions?.GET[key].filterable), + hostGroupOptions.data.actions?.GET || {} + ).filter(key => hostGroupOptions.data.actions?.GET[key].filterable), + moduleOptions: adHocOptions.data.actions.GET.module_name.choices, + credentialTypeId: cred.data.results[0].id, + isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps { @@ -73,6 +92,8 @@ function InventoryHostGroupsList({ i18n }) { actions: {}, relatedSearchableKeys: [], searchableKeys: [], + moduleOptions: [], + isAdHocDisabled: true, } ); @@ -201,6 +222,40 @@ function InventoryHostGroupsList({ i18n }) { />, ] : []), + + {({ isKebabified }) => + isKebabified ? ( + setIsAdHocCommandsOpen(true)} + isDisabled={itemCount === 0 || isAdHocDisabled} + > + {i18n._(t`Run command`)} + + ) : ( + + + + + + ) + } + , , @@ -233,6 +288,16 @@ function InventoryHostGroupsList({ i18n }) { title={i18n._(t`Select Groups`)} /> )} + {isAdHocCommandsOpen && ( + setIsAdHocCommandsOpen(false)} + credentialTypeId={credentialTypeId} + moduleOptions={moduleOptions} + /> + )} {error && ( ', () => { }, }, }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + POST: {}, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, + }); const history = createMemoryHistory({ initialEntries: ['/inventories/inventory/1/hosts/3/groups'], }); @@ -272,4 +283,11 @@ describe('', () => { wrapper.update(); expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1); }); + test('should render enabled ad hoc commands button', async () => { + await waitForElement( + wrapper, + 'button[aria-label="Run command"]', + el => el.prop('disabled') === false + ); + }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx index 6e0168330d..bf25eca01f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx @@ -1,10 +1,16 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { + Button, + Tooltip, + DropdownItem, + ToolbarItem, +} from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import { InventoriesAPI, HostsAPI } from '../../../api'; - +import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; import AlertModal from '../../../components/AlertModal'; import DataListToolbar from '../../../components/DataListToolbar'; import ErrorDetail from '../../../components/ErrorDetail'; @@ -12,6 +18,8 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; +import { Kebabified } from '../../../contexts/Kebabified'; +import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import InventoryHostItem from './InventoryHostItem'; const QS_CONFIG = getQSConfig('host', { @@ -21,48 +29,64 @@ const QS_CONFIG = getQSConfig('host', { }); function InventoryHostList({ i18n }) { - const [actions, setActions] = useState(null); - const [contentError, setContentError] = useState(null); - const [deletionError, setDeletionError] = useState(null); - const [hostCount, setHostCount] = useState(0); - const [hosts, setHosts] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const [selected, setSelected] = useState([]); const { id } = useParams(); const { search } = useLocation(); - const fetchHosts = (hostId, queryString) => { - const params = parseQueryString(QS_CONFIG, queryString); - return InventoriesAPI.readHosts(hostId, params); - }; + const { + result: { + hosts, + hostCount, + actions, + relatedSearchableKeys, + searchableKeys, + moduleOptions, + credentialTypeId, + isAdHocDisabled, + }, + error: contentError, + isLoading, + request: fetchData, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, search); + const [response, hostOptions, adHocOptions, cred] = await Promise.all([ + InventoriesAPI.readHosts(id, params), + InventoriesAPI.readHostsOptions(id), + InventoriesAPI.readAdHocOptions(id), + CredentialTypesAPI.read({ namespace: 'ssh' }), + ]); + + return { + hosts: response.data.results, + hostCount: response.data.count, + actions: hostOptions.data.actions, + relatedSearchableKeys: ( + hostOptions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter( + key => hostOptions.data.actions?.GET[key].filterable + ), + moduleOptions: adHocOptions.data.actions.GET.module_name.choices, + credentialTypeId: cred.data.results[0].id, + isAdHocDisabled: !adHocOptions.data.actions.POST, + }; + }, [id, search]), + { + hosts: [], + hostCount: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + moduleOptions: [], + isAdHocDisabled: true, + } + ); useEffect(() => { - async function fetchData() { - try { - const [ - { - data: { count, results }, - }, - { - data: { actions: optionActions }, - }, - ] = await Promise.all([ - fetchHosts(id, search), - InventoriesAPI.readOptions(), - ]); - - setHosts(results); - setHostCount(count); - setActions(optionActions); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } - } - fetchData(); - }, [id, search]); + }, [fetchData]); const handleSelectAll = isSelected => { setSelected(isSelected ? [...hosts] : []); @@ -75,30 +99,17 @@ function InventoryHostList({ i18n }) { setSelected(selected.concat(row)); } }; - - const handleDelete = async () => { - setIsLoading(true); - - try { + const { + isLoading: isDeleteLoading, + deleteItems: deleteHosts, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { await Promise.all(selected.map(host => HostsAPI.destroy(host.id))); - } catch (error) { - setDeletionError(error); - } finally { - setSelected([]); - try { - const { - data: { count, results }, - } = await fetchHosts(id, search); - - setHosts(results); - setHostCount(count); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } - } - }; + }, [selected]), + { qsConfig: QS_CONFIG, fetchItems: fetchData } + ); const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); @@ -108,7 +119,7 @@ function InventoryHostList({ i18n }) { <> ( , ] : []), + + {({ isKebabified }) => + isKebabified ? ( + setIsAdHocCommandsOpen(true)} + isDisabled={hostCount === 0 || isAdHocDisabled} + aria-label={i18n._(t`Run command`)} + > + {i18n._(t`Run command`)} + + ) : ( + + + + + + ) + } + , , @@ -177,12 +224,22 @@ function InventoryHostList({ i18n }) { ) } /> + {isAdHocCommandsOpen && ( + setIsAdHocCommandsOpen(false)} + credentialTypeId={credentialTypeId} + moduleOptions={moduleOptions} + itemId={id} + /> + )} {deletionError && ( setDeletionError(null)} + onClose={clearDeletionError} > {i18n._(t`Failed to delete one or more hosts.`)} diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx index 66ba9d992d..b71b0289ca 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { InventoriesAPI, HostsAPI } from '../../../api'; +import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api'; import { mountWithContexts, waitForElement, @@ -85,7 +85,7 @@ describe('', () => { results: mockHosts, }, }); - InventoriesAPI.readOptions.mockResolvedValue({ + InventoriesAPI.readHostsOptions.mockResolvedValue({ data: { actions: { GET: {}, @@ -93,6 +93,17 @@ describe('', () => { }, }, }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + POST: {}, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, + }); await act(async () => { wrapper = mountWithContexts(); }); @@ -265,8 +276,15 @@ describe('', () => { expect(wrapper.find('ToolbarAddButton').length).toBe(1); }); + test('should render enabled ad hoc commands button', async () => { + await waitForElement( + wrapper, + 'button[aria-label="Run command"]', + el => el.prop('disabled') === false + ); + }); test('should hide Add button for users without ability to POST', async () => { - InventoriesAPI.readOptions.mockResolvedValueOnce({ + InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ data: { actions: { GET: {}, @@ -283,7 +301,7 @@ describe('', () => { }); test('should show content error when api throws error on initial render', async () => { - InventoriesAPI.readOptions.mockImplementation(() => + InventoriesAPI.readHostsOptions.mockImplementation(() => Promise.reject(new Error()) ); await act(async () => { diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx index aa6290669c..4322463741 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx @@ -1,16 +1,23 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; +import { + Button, + Tooltip, + DropdownItem, + ToolbarItem, +} from '@patternfly/react-core'; import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList from '../../../components/PaginatedDataList'; import SmartInventoryHostListItem from './SmartInventoryHostListItem'; import useRequest from '../../../util/useRequest'; import useSelected from '../../../util/useSelected'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import { InventoriesAPI } from '../../../api'; +import { InventoriesAPI, CredentialTypesAPI } from '../../../api'; import { Inventory } from '../../../types'; +import { Kebabified } from '../../../contexts/Kebabified'; +import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -20,24 +27,35 @@ const QS_CONFIG = getQSConfig('host', { function SmartInventoryHostList({ i18n, inventory }) { const location = useLocation(); + const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const { - result: { hosts, count }, + result: { hosts, count, moduleOptions, credentialTypeId, isAdHocDisabled }, error: contentError, isLoading, request: fetchHosts, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { data } = await InventoriesAPI.readHosts(inventory.id, params); + const [hostResponse, adHocOptions, cred] = await Promise.all([ + InventoriesAPI.readHosts(inventory.id, params), + InventoriesAPI.readAdHocOptions(inventory.id), + CredentialTypesAPI.read({ namespace: 'ssh' }), + ]); + return { - hosts: data.results, - count: data.count, + hosts: hostResponse.data.results, + count: hostResponse.data.count, + moduleOptions: adHocOptions.data.actions.GET.module_name.choices, + credentialTypeId: cred.data.results[0].id, + isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [location.search, inventory.id]), { hosts: [], count: 0, + moduleOptions: [], + isAdHocDisabled: true, } ); @@ -50,66 +68,106 @@ function SmartInventoryHostList({ i18n, inventory }) { }, [fetchHosts]); return ( - ( - setSelected(isSelected ? [...hosts] : [])} - qsConfig={QS_CONFIG} - additionalControls={ - inventory?.summary_fields?.user_capabilities?.adhoc - ? [ - , - ] - : [] - } + <> + ( + + setSelected(isSelected ? [...hosts] : []) + } + qsConfig={QS_CONFIG} + additionalControls={ + inventory?.summary_fields?.user_capabilities?.adhoc + ? [ + + {({ isKebabified }) => + isKebabified ? ( + setIsAdHocCommandsOpen(true)} + isDisabled={count === 0 || isAdHocDisabled} + > + {i18n._(t`Run command`)} + + ) : ( + + + + + + ) + } + , + ] + : [] + } + /> + )} + renderItem={host => ( + row.id === host.id)} + onSelect={() => handleSelect(host)} + /> + )} + /> + {isAdHocCommandsOpen && ( + setIsAdHocCommandsOpen(false)} + credentialTypeId={credentialTypeId} + moduleOptions={moduleOptions} /> )} - renderItem={host => ( - row.id === host.id)} - onSelect={() => handleSelect(host)} - /> - )} - /> + ); } diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx index ae3f00d66f..60fd23d75a 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { InventoriesAPI } from '../../../api'; +import { InventoriesAPI, CredentialTypesAPI } from '../../../api'; import { mountWithContexts, waitForElement, @@ -12,125 +12,109 @@ import mockHosts from '../shared/data.hosts.json'; jest.mock('../../../api'); describe('', () => { - describe('User has adhoc permissions', () => { - let wrapper; - const clonedInventory = { - ...mockInventory, - summary_fields: { - ...mockInventory.summary_fields, - user_capabilities: { - ...mockInventory.summary_fields.user_capabilities, + let wrapper; + const clonedInventory = { + ...mockInventory, + summary_fields: { + ...mockInventory.summary_fields, + user_capabilities: { + ...mockInventory.summary_fields.user_capabilities, + }, + }, + }; + + beforeAll(async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: mockHosts, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + POST: {}, }, }, - }; - - beforeAll(async () => { - InventoriesAPI.readHosts.mockResolvedValue({ - data: mockHosts, - }); - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); - - afterAll(() => { - jest.clearAllMocks(); - wrapper.unmount(); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, }); - - test('initially renders successfully', () => { - expect(wrapper.find('SmartInventoryHostList').length).toBe(1); - }); - - test('should fetch hosts from api and render them in the list', () => { - expect(InventoriesAPI.readHosts).toHaveBeenCalled(); - expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); - }); - - test('should disable run commands button when no hosts are selected', () => { - wrapper.find('DataListCheck').forEach(el => { - expect(el.props().checked).toBe(false); - }); - const runCommandsButton = wrapper.find( - 'button[aria-label="Run commands"]' + await act(async () => { + wrapper = mountWithContexts( + ); - expect(runCommandsButton.length).toBe(1); - expect(runCommandsButton.prop('disabled')).toEqual(true); }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); - test('should enable run commands button when at least one host is selected', () => { - act(() => { - wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')( - true - ); - }); - wrapper.update(); - const runCommandsButton = wrapper.find( - 'button[aria-label="Run commands"]' - ); - expect(runCommandsButton.prop('disabled')).toEqual(false); + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('SmartInventoryHostList').length).toBe(1); + }); + + test('should fetch hosts from api and render them in the list', () => { + expect(InventoriesAPI.readHosts).toHaveBeenCalled(); + expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); + }); + + test('should have run command button', () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); }); + const runCommandsButton = wrapper.find('button[aria-label="Run command"]'); + expect(runCommandsButton.length).toBe(1); + expect(runCommandsButton.prop('disabled')).toBe(false); + }); - test('should select and deselect all items', async () => { - act(() => { - wrapper.find('DataListToolbar').invoke('onSelectAll')(true); - }); - wrapper.update(); - wrapper.find('DataListCheck').forEach(el => { - expect(el.props().checked).toEqual(true); - }); - act(() => { - wrapper.find('DataListToolbar').invoke('onSelectAll')(false); - }); - wrapper.update(); - wrapper.find('DataListCheck').forEach(el => { - expect(el.props().checked).toEqual(false); - }); + test('should select and deselect all items', async () => { + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); }); - - test('should show content error when api throws an error', async () => { - InventoriesAPI.readHosts.mockImplementation(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toEqual(true); + }); + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toEqual(false); }); }); - describe('User does not have adhoc permissions', () => { - let wrapper; - const clonedInventory = { - ...mockInventory, - summary_fields: { - user_capabilities: { - adhoc: false, + test('should show content error when api throws an error', async () => { + InventoriesAPI.readHosts.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + test('should disable run commands button', async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, }, }, - }; - - test('should hide run commands button', async () => { - InventoriesAPI.readHosts.mockResolvedValue({ - data: { results: [], count: 0 }, - }); - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - const runCommandsButton = wrapper.find( - 'button[aria-label="Run commands"]' - ); - expect(runCommandsButton.length).toBe(0); - jest.clearAllMocks(); - wrapper.unmount(); }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const runCommandsButton = wrapper.find('button[aria-label="Run command"]'); + expect(runCommandsButton.prop('disabled')).toBe(true); }); });