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 }) => (
-
@@ -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`)}
+
+ ) : (
+
+
+ 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`)}
+
+ ) : (
+
+
+ setIsAdHocCommandsOpen(true)}
+ isDisabled={hostCount === 0 || isAdHocDisabled}
+ >
+ {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
- ? [
-
- {i18n._(t`Run commands`)}
- ,
- ]
- : []
- }
+ <>
+ (
+
+ 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`)}
+
+ ) : (
+
+
+ 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);
});
});