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

View File

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

View File

@@ -43,6 +43,8 @@ function InventoryGroupHostList() {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -50,12 +52,15 @@ function InventoryGroupHostList() {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([ const [response, actionsResponse, options] = await Promise.all([
GroupsAPI.readAllHosts(groupId, params), GroupsAPI.readAllHosts(groupId, params),
InventoriesAPI.readHostsOptions(inventoryId), InventoriesAPI.readHostsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
]); ]);
return { return {
moduleOptions: options.data.actions.GET.module_name.choices,
isAdHocDisabled: !options.data.actions.POST,
hosts: response.data.results, hosts: response.data.results,
hostCount: response.data.count, hostCount: response.data.count,
actions: actionsResponse.data.actions, actions: actionsResponse.data.actions,
@@ -68,6 +73,8 @@ function InventoryGroupHostList() {
}; };
}, [groupId, inventoryId, location.search]), }, [groupId, inventoryId, location.search]),
{ {
moduleOptions: [],
isAdHocDisabled: true,
hosts: [], hosts: [],
hostCount: 0, hostCount: 0,
actions: {}, actions: {},
@@ -225,11 +232,16 @@ function InventoryGroupHostList() {
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[ additionalControls={[
...(canAdd ? [addButton] : []), ...(canAdd ? [addButton] : []),
<AdHocCommands ...(!isAdHocDisabled
adHocItems={selected} ? [
hasListItems={hostCount > 0} <AdHocCommands
onLaunchLoading={setIsAdHocLaunchLoading} adHocItems={selected}
/>, hasListItems={hostCount > 0}
moduleOptions={moduleOptions}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []),
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} 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 () => { await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />); wrapper = mountWithContexts(<InventoryGroupHostList />);
}); });
@@ -52,6 +67,7 @@ describe('<InventoryGroupHostList />', () => {
test('should fetch inventory group hosts from api and render them in the list', () => { test('should fetch inventory group hosts from api and render them in the list', () => {
expect(GroupsAPI.readAllHosts).toHaveBeenCalled(); expect(GroupsAPI.readAllHosts).toHaveBeenCalled();
expect(InventoriesAPI.readHostsOptions).toHaveBeenCalled(); expect(InventoriesAPI.readHostsOptions).toHaveBeenCalled();
expect(InventoriesAPI.readAdHocOptions).toHaveBeenCalled();
expect(wrapper.find('InventoryGroupHostListItem').length).toBe(3); 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); expect(wrapper.find('AddDropDownButton').length).toBe(1);
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
data: { data: {
@@ -109,6 +125,7 @@ describe('<InventoryGroupHostList />', () => {
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('AddDropDownButton').length).toBe(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 () => { test('expected api calls are made for multi-delete', async () => {
@@ -271,4 +288,24 @@ describe('<InventoryGroupHostList />', () => {
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); 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, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -45,12 +47,15 @@ function InventoryGroupsList() {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); 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.readGroups(inventoryId, params),
InventoriesAPI.readGroupsOptions(inventoryId), InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
]); ]);
return { return {
moduleOptions: options.data.actions.GET.module_name.choices,
isAdHocDisabled: !options.data.actions.POST,
groups: response.data.results, groups: response.data.results,
groupCount: response.data.count, groupCount: response.data.count,
actions: groupOptions.data.actions, actions: groupOptions.data.actions,
@@ -68,6 +73,8 @@ function InventoryGroupsList() {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -171,22 +178,29 @@ function InventoryGroupsList() {
/>, />,
] ]
: []), : []),
<AdHocCommands ...(!isAdHocDisabled
adHocItems={selected} ? [
hasListItems={groupCount > 0} <AdHocCommands
onLaunchLoading={setIsAdHocLaunchLoading} adHocItems={selected}
/>, hasListItems={groupCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
moduleOptions={moduleOptions}
/>,
]
: []),
<Tooltip content={renderTooltip()} position="top" key="delete"> <Tooltip content={renderTooltip()} position="top" key="delete">
<InventoryGroupsDeleteModal <div>
groups={selected} <InventoryGroupsDeleteModal
isDisabled={ groups={selected}
selected.length === 0 || selected.some(cannotDelete) isDisabled={
} selected.length === 0 || selected.some(cannotDelete)
onAfterDelete={() => { }
fetchData(); onAfterDelete={() => {
clearSelected(); fetchData();
}} clearSelected();
/> }}
/>
</div>
</Tooltip>, </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({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'], initialEntries: ['/inventories/inventory/3/groups'],
}); });
@@ -86,7 +101,12 @@ describe('<InventoryGroupsList />', () => {
</Route>, </Route>,
{ {
context: { 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); 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 () => { test('should check and uncheck the row item', async () => {
expect( expect(
wrapper wrapper
@@ -165,6 +189,27 @@ describe('<InventoryGroupsList />', () => {
expect(el.find('input').props().checked).toBe(false); 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', () => { 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( GroupsAPI.destroy.mockRejectedValue(
new Error({ new Error({
response: { response: {

View File

@@ -41,6 +41,8 @@ function InventoryHostGroupsList() {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -54,12 +56,16 @@ function InventoryHostGroupsList() {
data: { count, results }, data: { count, results },
}, },
hostGroupOptions, hostGroupOptions,
adHocOptions,
] = await Promise.all([ ] = await Promise.all([
HostsAPI.readAllGroups(hostId, params), HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId), HostsAPI.readGroupsOptions(hostId),
InventoriesAPI.readAdHocOptions(invId),
]); ]);
return { return {
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
isAdHocDisabled: !adHocOptions.data.actions.POST,
groups: results, groups: results,
itemCount: count, itemCount: count,
actions: hostGroupOptions.data.actions, actions: hostGroupOptions.data.actions,
@@ -77,6 +83,8 @@ function InventoryHostGroupsList() {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -209,11 +217,16 @@ function InventoryHostGroupsList() {
/>, />,
] ]
: []), : []),
<AdHocCommands ...(!isAdHocDisabled
adHocItems={selected} ? [
hasListItems={itemCount > 0} <AdHocCommands
onLaunchLoading={setIsAdHocLaunchLoading} adHocItems={selected}
/>, hasListItems={itemCount > 0}
moduleOptions={moduleOptions}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []),
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} 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({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/3/groups'], initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
}); });
@@ -111,6 +126,10 @@ describe('<InventoryHostGroupsList />', () => {
expect(wrapper.find('InventoryHostGroupItem').length).toBe(3); 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 () => { test('should check and uncheck the row item', async () => {
expect( expect(
wrapper 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 () => { test('should show content error when api throws error on initial render', async () => {
HostsAPI.readAllGroups.mockImplementation(() => HostsAPI.readAllGroups.mockImplementation(() =>
Promise.reject(new Error()) Promise.reject(new Error())

View File

@@ -35,6 +35,8 @@ function InventoryHostList() {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -42,12 +44,15 @@ function InventoryHostList() {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search); const params = parseQueryString(QS_CONFIG, search);
const [response, hostOptions] = await Promise.all([ const [response, hostOptions, adHocOptions] = await Promise.all([
InventoriesAPI.readHosts(id, params), InventoriesAPI.readHosts(id, params),
InventoriesAPI.readHostsOptions(id), InventoriesAPI.readHostsOptions(id),
InventoriesAPI.readAdHocOptions(id),
]); ]);
return { return {
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
isAdHocDisabled: !adHocOptions.data.actions.POST,
hosts: response.data.results, hosts: response.data.results,
hostCount: response.data.count, hostCount: response.data.count,
actions: hostOptions.data.actions, actions: hostOptions.data.actions,
@@ -65,6 +70,8 @@ function InventoryHostList() {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -152,11 +159,16 @@ function InventoryHostList() {
/>, />,
] ]
: []), : []),
<AdHocCommands ...(!isAdHocDisabled
adHocItems={selected} ? [
hasListItems={hostCount > 0} <AdHocCommands
onLaunchLoading={setIsAdHocLaunchLoading} moduleOptions={moduleOptions}
/>, adHocItems={selected}
hasListItems={hostCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []),
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
onDelete={handleDeleteHosts} 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 () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHostList />); wrapper = mountWithContexts(<InventoryHostList />);
}); });
@@ -108,6 +125,10 @@ describe('<InventoryHostList />', () => {
expect(wrapper.find('InventoryHostItem').length).toBe(3); 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 () => { test('should check and uncheck the row item', async () => {
expect( expect(
wrapper wrapper
@@ -322,4 +343,27 @@ describe('<InventoryHostList />', () => {
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); 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, relatedSearchableKeys,
searchableKeys, searchableKeys,
canAdd, canAdd,
moduleOptions,
isAdHocDisabled,
}, },
isLoading, isLoading,
error: contentError, error: contentError,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const [response, actions] = await Promise.all([ const [response, actions, adHocOptions] = await Promise.all([
GroupsAPI.readChildren(groupId, params), GroupsAPI.readChildren(groupId, params),
InventoriesAPI.readGroupsOptions(inventoryId), InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
]); ]);
return { return {
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
isAdHocDisabled: !adHocOptions.data.actions.POST,
groups: response.data.results, groups: response.data.results,
itemCount: response.data.count, itemCount: response.data.count,
relatedSearchableKeys: ( relatedSearchableKeys: (
@@ -68,7 +73,13 @@ function InventoryRelatedGroupList() {
Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'), Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'),
}; };
}, [groupId, location.search, inventoryId]), }, [groupId, location.search, inventoryId]),
{ groups: [], itemCount: 0, canAdd: false } {
groups: [],
itemCount: 0,
canAdd: false,
moduleOptions: [],
isAdHocDisabled: true,
}
); );
useEffect(() => { useEffect(() => {
fetchRelated(); fetchRelated();
@@ -199,11 +210,16 @@ function InventoryRelatedGroupList() {
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[ additionalControls={[
...(canAdd ? [addButton] : []), ...(canAdd ? [addButton] : []),
<AdHocCommands ...(!isAdHocDisabled
adHocItems={selected} ? [
hasListItems={itemCount > 0} <AdHocCommands
onLaunchLoading={setIsAdHocLaunchLoading} adHocItems={selected}
/>, hasListItems={itemCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
moduleOptions={moduleOptions}
/>,
]
: []),
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={disassociateGroups} 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 () => { await act(async () => {
wrapper = mountWithContexts(<InventoryRelatedGroupList />); wrapper = mountWithContexts(<InventoryRelatedGroupList />);
}); });
@@ -107,6 +122,10 @@ describe('<InventoryRelatedGroupList />', () => {
expect(wrapper.find('InventoryRelatedGroupListItem').length).toBe(3); 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 () => { test('should check and uncheck the row item', async () => {
expect( expect(
wrapper.find('input[aria-label="Select row 0"]').props().checked wrapper.find('input[aria-label="Select row 0"]').props().checked
@@ -220,4 +239,25 @@ describe('<InventoryRelatedGroupList />', () => {
); );
expect(GroupsAPI.associateChildGroup).toBeCalledTimes(1); 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`, project_update: t`Source Control Update`,
inventory_update: t`Inventory Sync`, inventory_update: t`Inventory Sync`,
job: job.job_type === 'check' ? t`Playbook Check` : t`Playbook Run`, 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`, system_job: t`Management Job`,
workflow_job: t`Workflow 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 <UserDateDetail
label={t`Created`} label={t`Created`}
date={job.created} date={job.created}

View File

@@ -110,6 +110,38 @@ describe('<JobDetail />', () => {
).toHaveLength(1); ).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 () => { test('should show schedule that launched workflow job', async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobDetail <JobDetail