Merge pull request #10510 from AlexSCorey/10440-8660-fix

Renders Command Module in job detail view and improves Ad Hoc Commands execution permissions

SUMMARY
This closes #10440 and #8660.
On the job details view we now render the command module name, and the arguments.  We also remove the run command button for user that do not have permission to launch a ad hoc command.
ISSUE TYPE
-enhancement
COMPONENT NAME

UI

AWX VERSION
ADDITIONAL INFORMATION

Reviewed-by: Kersom <None>
Reviewed-by: Sarah Akus <sakus@redhat.com>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-07-20 15:28:24 +00:00 committed by GitHub
commit e77d297a28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 441 additions and 165 deletions

View File

@ -3,7 +3,7 @@ import { useHistory, useParams } from 'react-router-dom';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { Button, DropdownItem } from '@patternfly/react-core';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import { InventoriesAPI, CredentialTypesAPI } from 'api';
@ -14,7 +14,12 @@ import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard';
import ContentError from '../ContentError';
function AdHocCommands({ adHocItems, hasListItems, onLaunchLoading }) {
function AdHocCommands({
adHocItems,
hasListItems,
onLaunchLoading,
moduleOptions,
}) {
const history = useHistory();
const { id } = useParams();
@ -35,29 +40,21 @@ function AdHocCommands({ adHocItems, hasListItems, onLaunchLoading }) {
}, [isKebabified, isWizardOpen, onKebabModalChange]);
const {
result: {
moduleOptions,
credentialTypeId,
isAdHocDisabled,
organizationId,
},
result: { credentialTypeId, organizationId },
request: fetchData,
error: fetchError,
} = useRequest(
useCallback(async () => {
const [options, { data }, cred] = await Promise.all([
InventoriesAPI.readAdHocOptions(id),
const [{ data }, cred] = await Promise.all([
InventoriesAPI.readDetail(id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
moduleOptions: options.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !options.data.actions.POST,
organizationId: data.organization,
};
}, [id]),
{ moduleOptions: [], isAdHocDisabled: true, organizationId: null }
{ organizationId: null }
);
useEffect(() => {
fetchData();
@ -77,10 +74,6 @@ function AdHocCommands({ adHocItems, hasListItems, onLaunchLoading }) {
)
);
const { error, dismissError } = useDismissableError(
launchError || fetchError
);
const handleSubmit = async values => {
const { credential, execution_environment, ...remainingValues } = values;
const newCredential = credential[0].id;
@ -97,6 +90,10 @@ function AdHocCommands({ adHocItems, hasListItems, onLaunchLoading }) {
onLaunchLoading,
]);
const { error, dismissError } = useDismissableError(
launchError || fetchError
);
if (error && isWizardOpen) {
return (
<AlertModal
@ -123,27 +120,29 @@ function AdHocCommands({ adHocItems, hasListItems, onLaunchLoading }) {
// render buttons for drop down and for toolbar
// if modal is open render the modal
<>
{isKebabified ? (
<DropdownItem
key="cancel-job"
isDisabled={isAdHocDisabled || !hasListItems}
component="button"
aria-label={t`Run Command`}
onClick={() => setIsWizardOpen(true)}
>
{t`Run Command`}
</DropdownItem>
) : (
<Button
ouiaId="run-command-button"
variant="secondary"
aria-label={t`Run Command`}
onClick={() => setIsWizardOpen(true)}
isDisabled={isAdHocDisabled || !hasListItems}
>
{t`Run Command`}
</Button>
)}
<Tooltip content={t`Run ad hoc command`}>
{isKebabified ? (
<DropdownItem
key="cancel-job"
isDisabled={!hasListItems}
component="button"
aria-label={t`Run Command`}
onClick={() => setIsWizardOpen(true)}
>
{t`Run Command`}
</DropdownItem>
) : (
<Button
ouiaId="run-command-button"
variant="secondary"
aria-label={t`Run Command`}
onClick={() => setIsWizardOpen(true)}
isDisabled={!hasListItems}
>
{t`Run Command`}
</Button>
)}
</Tooltip>
{isWizardOpen && (
<AdHocCommandsWizard

View File

@ -47,21 +47,6 @@ describe('<AdHocCommands />', () => {
BRAND_NAME: 'AWX',
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
@ -95,21 +80,6 @@ describe('<AdHocCommands />', () => {
});
test('should open the wizard', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
InventoriesAPI.readDetail.mockResolvedValue({ data: { organization: 1 } });
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
@ -126,12 +96,21 @@ describe('<AdHocCommands />', () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
moduleOptions={[
['command', 'command'],
['foo', 'foo'],
]}
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
await waitForElement(
wrapper,
'button[aria-label="Run Command"]',
el => el.length === 1
);
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
@ -167,18 +146,26 @@ describe('<AdHocCommands />', () => {
},
});
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {} } },
data: { actions: { GET: {}, POST: {} } },
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
adHocItems={adHocItems}
hasListItems
moduleOptions={[
['command', 'command'],
['foo', 'foo'],
]}
onLaunchLoading={() => jest.fn()}
/>
);
});
await waitForElement(
wrapper,
'button[aria-label="Run Command"]',
el => el.length === 1
);
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
@ -277,23 +264,6 @@ describe('<AdHocCommands />', () => {
},
})
);
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: {
choices: [[1], [2]],
},
},
},
},
});
InventoriesAPI.readDetail.mockResolvedValue({
data: { organization: 1 },
});
@ -334,18 +304,26 @@ describe('<AdHocCommands />', () => {
},
});
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {} } },
data: { actions: { GET: {}, POST: {} } },
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
adHocItems={adHocItems}
moduleOptions={[
['command', 'command'],
['foo', 'foo'],
]}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
await waitForElement(
wrapper,
'button[aria-label="Run Command"]',
el => el.length === 1
);
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
@ -424,45 +402,18 @@ describe('<AdHocCommands />', () => {
await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
});
test('should disable run command button due to permissions', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run Command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
});
test('should disable run command button due to lack of list items', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
moduleOptions={[
['command', 'command'],
['foo', 'foo'],
]}
adHocItems={adHocItems}
hasListItems={false}
onLaunchLoading={() => jest.fn()}
@ -475,7 +426,7 @@ describe('<AdHocCommands />', () => {
});
test('should open alert modal when error on fetching data', async () => {
InventoriesAPI.readAdHocOptions.mockRejectedValue(
InventoriesAPI.readDetail.mockRejectedValue(
new Error({
response: {
config: {
@ -490,6 +441,10 @@ describe('<AdHocCommands />', () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
moduleOptions={[
['command', 'command'],
['foo', 'foo'],
]}
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}

View File

@ -43,6 +43,8 @@ function InventoryGroupHostList() {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
isAdHocDisabled,
},
error: contentError,
isLoading,
@ -50,12 +52,15 @@ function InventoryGroupHostList() {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([
const [response, actionsResponse, options] = await Promise.all([
GroupsAPI.readAllHosts(groupId, params),
InventoriesAPI.readHostsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
]);
return {
moduleOptions: options.data.actions.GET.module_name.choices,
isAdHocDisabled: !options.data.actions.POST,
hosts: response.data.results,
hostCount: response.data.count,
actions: actionsResponse.data.actions,
@ -68,6 +73,8 @@ function InventoryGroupHostList() {
};
}, [groupId, inventoryId, location.search]),
{
moduleOptions: [],
isAdHocDisabled: true,
hosts: [],
hostCount: 0,
actions: {},
@ -225,11 +232,16 @@ function InventoryGroupHostList() {
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [addButton] : []),
<AdHocCommands
adHocItems={selected}
hasListItems={hostCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
...(!isAdHocDisabled
? [
<AdHocCommands
adHocItems={selected}
hasListItems={hostCount > 0}
moduleOptions={moduleOptions}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}

View File

@ -35,6 +35,21 @@ describe('<InventoryGroupHostList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />);
});
@ -52,6 +67,7 @@ describe('<InventoryGroupHostList />', () => {
test('should fetch inventory group hosts from api and render them in the list', () => {
expect(GroupsAPI.readAllHosts).toHaveBeenCalled();
expect(InventoriesAPI.readHostsOptions).toHaveBeenCalled();
expect(InventoriesAPI.readAdHocOptions).toHaveBeenCalled();
expect(wrapper.find('InventoryGroupHostListItem').length).toBe(3);
});
@ -95,7 +111,7 @@ describe('<InventoryGroupHostList />', () => {
});
});
test('should show add dropdown button according to permissions', async () => {
test('should show add dropdown button and Run Commands according to permissions', async () => {
expect(wrapper.find('AddDropDownButton').length).toBe(1);
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
data: {
@ -109,6 +125,7 @@ describe('<InventoryGroupHostList />', () => {
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('AddDropDownButton').length).toBe(0);
expect(wrapper.find('AdHocCommands').length).toBe(1);
});
test('expected api calls are made for multi-delete', async () => {
@ -271,4 +288,24 @@ describe('<InventoryGroupHostList />', () => {
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should not render ad hoc commands button', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />);
});
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
});

View File

@ -38,6 +38,8 @@ function InventoryGroupsList() {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
isAdHocDisabled,
},
error: contentError,
isLoading,
@ -45,12 +47,15 @@ function InventoryGroupsList() {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, groupOptions] = await Promise.all([
const [response, groupOptions, options] = await Promise.all([
InventoriesAPI.readGroups(inventoryId, params),
InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
]);
return {
moduleOptions: options.data.actions.GET.module_name.choices,
isAdHocDisabled: !options.data.actions.POST,
groups: response.data.results,
groupCount: response.data.count,
actions: groupOptions.data.actions,
@ -68,6 +73,8 @@ function InventoryGroupsList() {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
@ -171,22 +178,29 @@ function InventoryGroupsList() {
/>,
]
: []),
<AdHocCommands
adHocItems={selected}
hasListItems={groupCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
...(!isAdHocDisabled
? [
<AdHocCommands
adHocItems={selected}
hasListItems={groupCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
moduleOptions={moduleOptions}
/>,
]
: []),
<Tooltip content={renderTooltip()} position="top" key="delete">
<InventoryGroupsDeleteModal
groups={selected}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
onAfterDelete={() => {
fetchData();
clearSelected();
}}
/>
<div>
<InventoryGroupsDeleteModal
groups={selected}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
onAfterDelete={() => {
fetchData();
clearSelected();
}}
/>
</div>
</Tooltip>,
]}
/>

View File

@ -76,6 +76,21 @@ describe('<InventoryGroupsList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
@ -86,7 +101,12 @@ describe('<InventoryGroupsList />', () => {
</Route>,
{
context: {
router: { history, route: { location: history.location } },
router: {
history,
route: {
location: history.location,
},
},
},
}
);
@ -103,6 +123,10 @@ describe('<InventoryGroupsList />', () => {
expect(wrapper.find('InventoryGroupItem').length).toBe(3);
});
test('should render Run Commands button', async () => {
expect(wrapper.find('AdHocCommands')).toHaveLength(1);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper
@ -165,6 +189,27 @@ describe('<InventoryGroupsList />', () => {
expect(el.find('input').props().checked).toBe(false);
});
});
test('should not render ad hoc commands button', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
});
describe('<InventoryGroupsList/> error handling', () => {
@ -185,6 +230,21 @@ describe('<InventoryGroupsList/> error handling', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
GroupsAPI.destroy.mockRejectedValue(
new Error({
response: {

View File

@ -41,6 +41,8 @@ function InventoryHostGroupsList() {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
isAdHocDisabled,
},
error: contentError,
isLoading,
@ -54,12 +56,16 @@ function InventoryHostGroupsList() {
data: { count, results },
},
hostGroupOptions,
adHocOptions,
] = await Promise.all([
HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId),
InventoriesAPI.readAdHocOptions(invId),
]);
return {
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
isAdHocDisabled: !adHocOptions.data.actions.POST,
groups: results,
itemCount: count,
actions: hostGroupOptions.data.actions,
@ -77,6 +83,8 @@ function InventoryHostGroupsList() {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
@ -209,11 +217,16 @@ function InventoryHostGroupsList() {
/>,
]
: []),
<AdHocCommands
adHocItems={selected}
hasListItems={itemCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
...(!isAdHocDisabled
? [
<AdHocCommands
adHocItems={selected}
hasListItems={itemCount > 0}
moduleOptions={moduleOptions}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}

View File

@ -80,6 +80,21 @@ describe('<InventoryHostGroupsList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
});
@ -111,6 +126,10 @@ describe('<InventoryHostGroupsList />', () => {
expect(wrapper.find('InventoryHostGroupItem').length).toBe(3);
});
test('should render Run Commands button', async () => {
expect(wrapper.find('AdHocCommands')).toHaveLength(1);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper
@ -174,6 +193,27 @@ describe('<InventoryHostGroupsList />', () => {
});
});
test('should not render Run Commands button', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHostGroupsList />);
});
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
test('should show content error when api throws error on initial render', async () => {
HostsAPI.readAllGroups.mockImplementation(() =>
Promise.reject(new Error())

View File

@ -35,6 +35,8 @@ function InventoryHostList() {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
isAdHocDisabled,
},
error: contentError,
isLoading,
@ -42,12 +44,15 @@ function InventoryHostList() {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search);
const [response, hostOptions] = await Promise.all([
const [response, hostOptions, adHocOptions] = await Promise.all([
InventoriesAPI.readHosts(id, params),
InventoriesAPI.readHostsOptions(id),
InventoriesAPI.readAdHocOptions(id),
]);
return {
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
isAdHocDisabled: !adHocOptions.data.actions.POST,
hosts: response.data.results,
hostCount: response.data.count,
actions: hostOptions.data.actions,
@ -65,6 +70,8 @@ function InventoryHostList() {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
@ -152,11 +159,16 @@ function InventoryHostList() {
/>,
]
: []),
<AdHocCommands
adHocItems={selected}
hasListItems={hostCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
...(!isAdHocDisabled
? [
<AdHocCommands
moduleOptions={moduleOptions}
adHocItems={selected}
hasListItems={hostCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []),
<ToolbarDeleteButton
key="delete"
onDelete={handleDeleteHosts}

View File

@ -93,6 +93,23 @@ describe('<InventoryHostList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHostList />);
});
@ -108,6 +125,10 @@ describe('<InventoryHostList />', () => {
expect(wrapper.find('InventoryHostItem').length).toBe(3);
});
test('should render Run Commands button', async () => {
expect(wrapper.find('AdHocCommands')).toHaveLength(1);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper
@ -322,4 +343,27 @@ describe('<InventoryHostList />', () => {
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should not render Run Commands button', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<InventoryHostList inventory={mockInventory} />
);
});
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
});

View File

@ -43,18 +43,23 @@ function InventoryRelatedGroupList() {
relatedSearchableKeys,
searchableKeys,
canAdd,
moduleOptions,
isAdHocDisabled,
},
isLoading,
error: contentError,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actions] = await Promise.all([
const [response, actions, adHocOptions] = await Promise.all([
GroupsAPI.readChildren(groupId, params),
InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
]);
return {
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
isAdHocDisabled: !adHocOptions.data.actions.POST,
groups: response.data.results,
itemCount: response.data.count,
relatedSearchableKeys: (
@ -68,7 +73,13 @@ function InventoryRelatedGroupList() {
Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'),
};
}, [groupId, location.search, inventoryId]),
{ groups: [], itemCount: 0, canAdd: false }
{
groups: [],
itemCount: 0,
canAdd: false,
moduleOptions: [],
isAdHocDisabled: true,
}
);
useEffect(() => {
fetchRelated();
@ -199,11 +210,16 @@ function InventoryRelatedGroupList() {
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [addButton] : []),
<AdHocCommands
adHocItems={selected}
hasListItems={itemCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
...(!isAdHocDisabled
? [
<AdHocCommands
adHocItems={selected}
hasListItems={itemCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
moduleOptions={moduleOptions}
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={disassociateGroups}

View File

@ -87,6 +87,21 @@ describe('<InventoryRelatedGroupList />', () => {
],
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
});
@ -107,6 +122,10 @@ describe('<InventoryRelatedGroupList />', () => {
expect(wrapper.find('InventoryRelatedGroupListItem').length).toBe(3);
});
test('should render Run Commands Button', async () => {
expect(wrapper.find('AdHocCommands')).toHaveLength(1);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('input[aria-label="Select row 0"]').props().checked
@ -220,4 +239,25 @@ describe('<InventoryRelatedGroupList />', () => {
);
expect(GroupsAPI.associateChildGroup).toBeCalledTimes(1);
});
test('should not render Run Commands button', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
});
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
});

View File

@ -76,7 +76,7 @@ function JobDetail({ job }) {
project_update: t`Source Control Update`,
inventory_update: t`Inventory Sync`,
job: job.job_type === 'check' ? t`Playbook Check` : t`Playbook Run`,
ad_hoc_command: t`Command`,
ad_hoc_command: t`Run Command`,
system_job: t`Management Job`,
workflow_job: t`Workflow Job`,
};
@ -337,6 +337,8 @@ function JobDetail({ job }) {
}
/>
)}
<Detail label={t`Module Name`} value={job.module_name} />
<Detail label={t`Module Arguments`} value={job.module_args} />
<UserDateDetail
label={t`Created`}
date={job.created}

View File

@ -110,6 +110,38 @@ describe('<JobDetail />', () => {
).toHaveLength(1);
});
test('should display module name and module arguments', () => {
wrapper = mountWithContexts(
<JobDetail
job={{
...mockJobData,
type: 'ad_hoc_command',
module_name: 'command',
module_args: 'echo hello_world',
summary_fields: {
...mockJobData.summary_fields,
credential: {
id: 2,
name: 'Machine cred',
description: '',
kind: 'ssh',
cloud: false,
kubernetes: false,
credential_type_id: 1,
},
source_workflow_job: {
id: 1234,
name: 'Test Source Workflow',
},
},
}}
/>
);
assertDetail('Module Name', 'command');
assertDetail('Module Arguments', 'echo hello_world');
assertDetail('Job Type', 'Run Command');
});
test('should show schedule that launched workflow job', async () => {
wrapper = mountWithContexts(
<JobDetail