Merge pull request #8243 from AlexSCorey/AdHocCommandsOnLists

Adds Ad Hoc Commands To Remaining Lists

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-10-07 21:55:14 +00:00
committed by GitHub
18 changed files with 848 additions and 548 deletions

View File

@@ -12,7 +12,9 @@ class Groups extends Base {
} }
associateHost(id, hostId) { 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) { createHost(id, data) {
@@ -20,7 +22,9 @@ class Groups extends Base {
} }
readAllHosts(id, params) { 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) { disassociateHost(id, host) {
@@ -29,6 +33,10 @@ class Groups extends Base {
disassociate: true, disassociate: true,
}); });
} }
readChildren(id, params) {
return this.http.get(`${this.baseUrl}${id}/children/`, params);
}
} }
export default Groups; export default Groups;

View File

@@ -1,19 +1,25 @@
import React, { useState, Fragment, useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import useRequest, { useDismissableError } from '../../util/useRequest'; import useRequest, { useDismissableError } from '../../util/useRequest';
import { InventoriesAPI } from '../../api';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { CredentialTypesAPI } from '../../api';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard'; import AdHocCommandsWizard from './AdHocCommandsWizard';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { function AdHocCommands({
const [isWizardOpen, setIsWizardOpen] = useState(false); onClose,
adHocItems,
itemId,
i18n,
moduleOptions,
credentialTypeId,
}) {
const history = useHistory(); const history = useHistory();
const verbosityOptions = [ const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { 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: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection 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 { const {
isloading: isLaunchLoading, isloading: isLaunchLoading,
error: launchError, error,
request: launchAdHocCommands, request: launchAdHocCommands,
} = useRequest( } = useRequest(
useCallback( useCallback(
async values => { async values => {
const { data } = await apiModule.launchAdHocCommands(itemId, values); const { data } = await InventoriesAPI.launchAdHocCommands(
itemId,
values
);
history.push(`/jobs/command/${data.id}/output`); history.push(`/jobs/command/${data.id}/output`);
}, },
[apiModule, itemId, history] [itemId, history]
) )
); );
const { error, dismissError } = useDismissableError( const { dismissError } = useDismissableError(error);
launchError || fetchError
);
const handleSubmit = async values => { const handleSubmit = async values => {
const { credential, ...remainingValues } = values; const { credential, ...remainingValues } = values;
@@ -85,14 +58,13 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
...remainingValues, ...remainingValues,
}; };
await launchAdHocCommands(manipulatedValues); await launchAdHocCommands(manipulatedValues);
setIsWizardOpen(false);
}; };
if (isLaunchLoading) { if (isLaunchLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
if (error && isWizardOpen) { if (error) {
return ( return (
<AlertModal <AlertModal
isOpen={error} isOpen={error}
@@ -100,43 +72,29 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => { onClose={() => {
dismissError(); dismissError();
setIsWizardOpen(false);
}} }}
> >
{launchError ? ( <>
<> {i18n._(t`Failed to launch job.`)}
{i18n._(t`Failed to launch job.`)} <ErrorDetail error={error} />
<ErrorDetail error={error} /> </>
</>
) : (
<ContentError error={error} />
)}
</AlertModal> </AlertModal>
); );
} }
return ( return (
<Fragment> <AdHocCommandsWizard
{children({ adHocItems={adHocItems}
openAdHocCommands: () => setIsWizardOpen(true), moduleOptions={moduleOptions}
})} verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
{isWizardOpen && ( onCloseWizard={onClose}
<AdHocCommandsWizard onLaunch={handleSubmit}
adHocItems={adHocItems} onDismissError={() => dismissError()}
moduleOptions={moduleOptions} />
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
)}
</Fragment>
); );
} }
AdHocCommands.propTypes = { AdHocCommands.propTypes = {
children: PropTypes.func.isRequired,
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired, itemId: PropTypes.number.isRequired,
}; };

View File

@@ -18,6 +18,10 @@ const credentials = [
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]; ];
const moduleOptions = [
['command', 'command'],
['shell', 'shell'],
];
const adHocItems = [ const adHocItems = [
{ {
name: 'Inventory 1 Org 0', name: 'Inventory 1 Org 0',
@@ -25,10 +29,6 @@ const adHocItems = [
{ name: 'Inventory 2 Org 0' }, { name: 'Inventory 2 Org 0' },
]; ];
const children = ({ openAdHocCommands }) => (
<button type="submit" onClick={() => openAdHocCommands()} />
);
describe('<AdHocCommands />', () => { describe('<AdHocCommands />', () => {
let wrapper; let wrapper;
afterEach(() => { afterEach(() => {
@@ -40,111 +40,38 @@ describe('<AdHocCommands />', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} css="margin-right: 20px"
adHocItems={adHocItems} onClose={() => {}}
itemId={1} itemId={1}
credentialTypeId={1} credentialTypeId={1}
> adHocItems={adHocItems}
{children} moduleOptions={moduleOptions}
</AdHocCommands> />
); );
}); });
expect(wrapper.find('AdHocCommands').length).toBe(1); expect(wrapper.find('AdHocCommands').length).toBe(1);
}); });
test('calls api on Mount', async () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
expect(wrapper.find('AdHocCommands').length).toBe(1);
expect(InventoriesAPI.readAdHocOptions).toBeCalledWith(1);
expect(CredentialTypesAPI.read).toBeCalledWith({ namespace: 'ssh' });
});
test('should open the wizard', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
});
test('should submit properly', async () => { test('should submit properly', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({ InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { data: {
results: credentials, results: credentials,
count: 5, count: 5,
}, },
}); });
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} css="margin-right: 20px"
adHocItems={adHocItems} onClose={() => {}}
itemId={1} itemId={1}
credentialTypeId={1} credentialTypeId={1}
> adHocItems={adHocItems}
{children} moduleOptions={moduleOptions}
</AdHocCommands> />
); );
}); });
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
@@ -177,6 +104,7 @@ describe('<AdHocCommands />', () => {
); );
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
// second step of wizard // second step of wizard
await act(async () => { await act(async () => {
wrapper wrapper
.find('input[aria-labelledby="check-action-item-4"]') .find('input[aria-labelledby="check-action-item-4"]')
@@ -205,10 +133,6 @@ describe('<AdHocCommands />', () => {
module_name: 'command', module_name: 'command',
verbosity: 1, verbosity: 1,
}); });
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
}); });
test('should throw error on submission properly', async () => { test('should throw error on submission properly', async () => {
@@ -251,16 +175,15 @@ describe('<AdHocCommands />', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} css="margin-right: 20px"
adHocItems={adHocItems} onClose={() => {}}
itemId={1}
credentialTypeId={1} credentialTypeId={1}
> itemId={1}
{children} adHocItems={adHocItems}
</AdHocCommands> moduleOptions={moduleOptions}
/>
); );
}); });
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
@@ -312,36 +235,6 @@ describe('<AdHocCommands />', () => {
wrapper.find('Button[type="submit"]').prop('onClick')() wrapper.find('Button[type="submit"]').prop('onClick')()
); );
waitForElement(wrapper, 'ErrorDetail', el => el.length > 0); await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
});
test('should open alert modal when error on fetching data', async () => {
InventoriesAPI.readAdHocOptions.mockRejectedValue(
new Error({
response: {
config: {
method: 'options',
url: '/api/v2/inventories/1/',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
}); });
}); });

View File

@@ -1,13 +1,26 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
import { Tooltip } from '@patternfly/react-core';
import { withFormik, useFormikContext } from 'formik'; import { withFormik, useFormikContext } from 'formik';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components';
import Wizard from '../Wizard'; import Wizard from '../Wizard';
import AdHocCredentialStep from './AdHocCredentialStep'; import AdHocCredentialStep from './AdHocCredentialStep';
import AdHocDetailsStep from './AdHocDetailsStep'; import AdHocDetailsStep from './AdHocDetailsStep';
const AlertText = styled.div`
color: var(--pf-global--danger-color--200);
font-weight: var(--pf-global--FontWeight--bold);
`;
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px;
color: var(--pf-global--danger-color--100);
`;
function AdHocCommandsWizard({ function AdHocCommandsWizard({
onLaunch, onLaunch,
i18n, i18n,
@@ -19,7 +32,7 @@ function AdHocCommandsWizard({
const [currentStepId, setCurrentStepId] = useState(1); const [currentStepId, setCurrentStepId] = useState(1);
const [enableLaunch, setEnableLaunch] = useState(false); const [enableLaunch, setEnableLaunch] = useState(false);
const { values } = useFormikContext(); const { values, errors, touched } = useFormikContext();
const enabledNextOnDetailsStep = () => { const enabledNextOnDetailsStep = () => {
if (!values.module_name) { if (!values.module_name) {
@@ -36,11 +49,26 @@ function AdHocCommandsWizard({
} }
return undefined; // makes the linter happy; return undefined; // makes the linter happy;
}; };
const hasDetailsStepError = errors.module_args && touched.module_args;
const steps = [ const steps = [
{ {
id: 1, id: 1,
key: 1, key: 1,
name: i18n._(t`Details`), name: hasDetailsStepError ? (
<AlertText>
{i18n._(t`Details`)}
<Tooltip
position="right"
content={i18n._(t`This step contains errors`)}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon />
</Tooltip>
</AlertText>
) : (
i18n._(t`Details`)
),
component: ( component: (
<AdHocDetailsStep <AdHocDetailsStep
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
@@ -60,7 +88,7 @@ function AdHocCommandsWizard({
onEnableLaunch={() => setEnableLaunch(true)} onEnableLaunch={() => setEnableLaunch(true)}
/> />
), ),
enableNext: enableLaunch, enableNext: enableLaunch && Object.values(errors).length === 0,
nextButtonText: i18n._(t`Launch`), nextButtonText: i18n._(t`Launch`),
canJumpTo: currentStepId >= 2, canJumpTo: currentStepId >= 2,
}, },

View File

@@ -148,6 +148,20 @@ describe('<AdHocCommandsWizard/>', () => {
expect(onLaunch).toHaveBeenCalled(); expect(onLaunch).toHaveBeenCalled();
}); });
test('should show error in navigation bar', async () => {
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
await act(async () => {
wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
{},
'command'
);
wrapper.find('input#module_args').simulate('change', {
target: { value: '', name: 'module_args' },
});
});
waitForElement(wrapper, 'ExclamationCircleIcon', el => el.length > 0);
});
test('expect credential step to throw error', async () => { test('expect credential step to throw error', async () => {
CredentialsAPI.read.mockRejectedValue( CredentialsAPI.read.mockRejectedValue(

View File

@@ -65,6 +65,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
<FormGroup <FormGroup
fieldId="credential" fieldId="credential"
label={i18n._(t`Machine Credential`)} label={i18n._(t`Machine Credential`)}
aria-label={i18n._(t`Machine Credential`)}
isRequired isRequired
validated={ validated={
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error' !credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'

View File

@@ -27,32 +27,43 @@ const TooltipWrapper = styled.div`
// in failing tests. // in failing tests.
const brandName = BrandName; const brandName = BrandName;
function CredentialStep({ i18n, verbosityOptions, moduleOptions }) { function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
const [module_nameField, module_nameMeta, module_nameHelpers] = useField({ const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
name: 'module_name', name: 'module_name',
validate: required(null, i18n), validate: required(null, i18n),
}); });
const [variablesField] = useField('extra_vars'); const [variablesField] = useField('extra_vars');
const [diff_modeField, , diff_modeHelpers] = useField('diff_mode'); const [diffModeField, , diffModeHelpers] = useField('diff_mode');
const [become_enabledField, , become_enabledHelpers] = useField( const [becomeEnabledField, , becomeEnabledHelpers] = useField(
'become_enabled' 'become_enabled'
); );
const [verbosityField, verbosityMeta, verbosityHelpers] = useField({ const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
name: 'verbosity', name: 'verbosity',
validate: required(null, i18n), validate: required(null, i18n),
}); });
const argumentsRequired =
moduleNameField.value === 'command' || moduleNameField.value === 'shell';
const [, argumentsMeta, argumentsHelpers] = useField({
name: 'module_args',
validate: argumentsRequired && required(null, i18n),
});
const isValid = !argumentsMeta.error || !argumentsMeta.touched;
return ( return (
<Form> <Form>
<FormColumnLayout> <FormColumnLayout>
<FormFullWidthLayout> <FormFullWidthLayout>
<FormGroup <FormGroup
fieldId="module_name" fieldId="module_name"
aria-label={i18n._(t`Module`)}
label={i18n._(t`Module`)} label={i18n._(t`Module`)}
isRequired isRequired
helperTextInvalid={module_nameMeta.error} helperTextInvalid={moduleNameMeta.error}
validated={ validated={
!module_nameMeta.touched || !module_nameMeta.error !moduleNameMeta.touched || !moduleNameMeta.error
? 'default' ? 'default'
: 'error' : 'error'
} }
@@ -65,33 +76,52 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
} }
> >
<AnsibleSelect <AnsibleSelect
{...module_nameField} {...moduleNameField}
isValid={!module_nameMeta.touched || !module_nameMeta.error} placeHolder={i18n._(t`Select a module`)}
isValid={!moduleNameMeta.touched || !moduleNameMeta.error}
id="module_name" id="module_name"
data={moduleOptions || []} data={[
{
value: '',
key: '',
label: i18n._(t`Choose a module`),
isDisabled: true,
},
...moduleOptions.map(value => ({
value: value[0],
label: value[0],
key: value[0],
})),
]}
onChange={(event, value) => { onChange={(event, value) => {
module_nameHelpers.setValue(value); if (value !== 'command' && value !== 'shell') {
argumentsHelpers.setTouched(false);
}
moduleNameHelpers.setValue(value);
}} }}
/> />
</FormGroup> </FormGroup>
<FormField <FormField
id="module_args" id="module_args"
name="module_args" name="module_args"
aria-label={i18n._(t`Arguments`)}
type="text" type="text"
label={i18n._(t`Arguments`)} label={i18n._(t`Arguments`)}
validate={required(null, i18n)} validated={isValid ? 'default' : 'error'}
onBlur={() => argumentsHelpers.setTouched(true)}
placeholder={i18n._(t`Enter arguments`)}
isRequired={ isRequired={
module_nameField.value === 'command' || moduleNameField.value === 'command' ||
module_nameField.value === 'shell' moduleNameField.value === 'shell'
} }
tooltip={ tooltip={
module_nameField.value ? ( moduleNameField.value ? (
<> <>
{i18n._( {i18n._(
t`These arguments are used with the specified module. You can find information about ${module_nameField.value} by clicking ` t`These arguments are used with the specified module. You can find information about ${moduleNameField.value} by clicking `
)} )}
<a <a
href={`https://docs.ansible.com/ansible/latest/modules/${module_nameField.value}_module.html`} href={`https://docs.ansible.com/ansible/latest/modules/${moduleNameField.value}_module.html`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -106,6 +136,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
/> />
<FormGroup <FormGroup
fieldId="verbosity" fieldId="verbosity"
aria-label={i18n._(t`Verbosity`)}
label={i18n._(t`Verbosity`)} label={i18n._(t`Verbosity`)}
isRequired isRequired
validated={ validated={
@@ -137,6 +168,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
name="limit" name="limit"
type="text" type="text"
label={i18n._(t`Limit`)} label={i18n._(t`Limit`)}
aria-label={i18n._(t`Limit`)}
tooltip={ tooltip={
<span> <span>
{i18n._( {i18n._(
@@ -158,6 +190,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
type="number" type="number"
min="0" min="0"
label={i18n._(t`Forks`)} label={i18n._(t`Forks`)}
aria-label={i18n._(t`Forks`)}
tooltip={ tooltip={
<span> <span>
{i18n._( {i18n._(
@@ -176,6 +209,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
<FormColumnLayout> <FormColumnLayout>
<FormGroup <FormGroup
label={i18n._(t`Show changes`)} label={i18n._(t`Show changes`)}
aria-label={i18n._(t`Show changes`)}
labelIcon={ labelIcon={
<FieldTooltip <FieldTooltip
content={i18n._( content={i18n._(
@@ -189,9 +223,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
id="diff_mode" id="diff_mode"
label={i18n._(t`On`)} label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)} labelOff={i18n._(t`Off`)}
isChecked={diff_modeField.value} isChecked={diffModeField.value}
onChange={() => { onChange={() => {
diff_modeHelpers.setValue(!diff_modeField.value); diffModeHelpers.setValue(!diffModeField.value);
}} }}
aria-label={i18n._(t`toggle changes`)} aria-label={i18n._(t`toggle changes`)}
/> />
@@ -222,9 +256,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
</span> </span>
} }
id="become_enabled" id="become_enabled"
isChecked={become_enabledField.value} isChecked={becomeEnabledField.value}
onChange={checked => { onChange={checked => {
become_enabledHelpers.setValue(checked); becomeEnabledHelpers.setValue(checked);
}} }}
/> />
</FormCheckboxLayout> </FormCheckboxLayout>
@@ -273,6 +307,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
</TooltipWrapper> </TooltipWrapper>
} }
label={i18n._(t`Extra variables`)} label={i18n._(t`Extra variables`)}
aria-label={i18n._(t`Extra variables`)}
/> />
</FormFullWidthLayout> </FormFullWidthLayout>
</FormColumnLayout> </FormColumnLayout>
@@ -280,9 +315,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
); );
} }
CredentialStep.propTypes = { AdHocDetailsStep.propTypes = {
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
}; };
export default withI18n()(CredentialStep); export default withI18n()(AdHocDetailsStep);

View File

@@ -12,9 +12,8 @@ const verbosityOptions = [
{ key: 1, value: 1, label: '1', isDisabled: false }, { key: 1, value: 1, label: '1', isDisabled: false },
]; ];
const moduleOptions = [ const moduleOptions = [
{ key: -1, value: '', label: '', isDisabled: false }, ['command', 'command'],
{ key: 0, value: 'command', label: 'command', isDisabled: false }, ['shell', 'shell'],
{ key: 1, value: 'shell', label: 'shell', isDisabled: false },
]; ];
const onLimitChange = jest.fn(); const onLimitChange = jest.fn();
const initialValues = { const initialValues = {

View File

@@ -2,8 +2,14 @@ import React, { useEffect, useCallback, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom'; import { useHistory, useLocation, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs'; import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs';
import { GroupsAPI, InventoriesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import useRequest, { import useRequest, {
useDeleteItems, useDeleteItems,
@@ -16,6 +22,8 @@ import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList from '../../../components/PaginatedDataList';
import AssociateModal from '../../../components/AssociateModal'; import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton'; import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryGroupHostListItem from './InventoryGroupHostListItem'; import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddHostDropdown from './AddHostDropdown'; import AddHostDropdown from './AddHostDropdown';
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('host', {
function InventoryGroupHostList({ i18n }) { function InventoryGroupHostList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { id: inventoryId, groupId } = useParams(); const { id: inventoryId, groupId } = useParams();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
@@ -38,6 +47,9 @@ function InventoryGroupHostList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -45,9 +57,16 @@ function InventoryGroupHostList({ i18n }) {
} = 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,
adHocOptions,
cred,
] = await Promise.all([
GroupsAPI.readAllHosts(groupId, params), GroupsAPI.readAllHosts(groupId, params),
InventoriesAPI.readHostsOptions(inventoryId), InventoriesAPI.readHostsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
@@ -60,6 +79,9 @@ function InventoryGroupHostList({ i18n }) {
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
}; };
}, [groupId, inventoryId, location.search]), }, [groupId, inventoryId, location.search]),
{ {
@@ -68,6 +90,8 @@ function InventoryGroupHostList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -195,6 +219,40 @@ function InventoryGroupHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it.
The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} onDisassociate={handleDisassociate}
@@ -222,6 +280,7 @@ function InventoryGroupHostList({ i18n }) {
emptyStateControls={ emptyStateControls={
canAdd && ( canAdd && (
<AddHostDropdown <AddHostDropdown
key="associate"
onAddExisting={() => setIsModalOpen(true)} onAddExisting={() => setIsModalOpen(true)}
onAddNew={() => history.push(addFormUrl)} onAddNew={() => history.push(addFormUrl)}
/> />
@@ -239,6 +298,16 @@ function InventoryGroupHostList({ i18n }) {
title={i18n._(t`Select Hosts`)} title={i18n._(t`Select Hosts`)}
/> />
)} )}
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventoryId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{associateError && ( {associateError && (
<AlertModal <AlertModal
isOpen={associateError} isOpen={associateError}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { GroupsAPI, InventoriesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -11,6 +11,7 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api/models/Groups'); jest.mock('../../../api/models/Groups');
jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/CredentialTypes');
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useParams: () => ({ useParams: () => ({
@@ -34,6 +35,17 @@ describe('<InventoryGroupHostList />', () => {
}, },
}, },
}); });
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 () => { await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />); wrapper = mountWithContexts(<InventoryGroupHostList />);
}); });
@@ -95,6 +107,29 @@ describe('<InventoryGroupHostList />', () => {
}); });
}); });
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(<InventoryGroupHostList />);
});
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
test('should show add dropdown button according to permissions', async () => { test('should show add dropdown button according to permissions', async () => {
expect(wrapper.find('AddHostDropdown').length).toBe(1); expect(wrapper.find('AddHostDropdown').length).toBe(1);
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({

View File

@@ -12,7 +12,7 @@ import {
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import { InventoriesAPI, GroupsAPI } from '../../../api'; import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
@@ -22,7 +22,8 @@ import PaginatedDataList, {
import InventoryGroupItem from './InventoryGroupItem'; import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import { Kebabified } from '../../../contexts/Kebabified'; import { Kebabified } from '../../../contexts/Kebabified';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -51,6 +52,7 @@ const useModal = () => {
function InventoryGroupsList({ i18n }) { function InventoryGroupsList({ i18n }) {
const [deletionError, setDeletionError] = useState(null); const [deletionError, setDeletionError] = useState(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const { isModalOpen, toggleModal } = useModal(); const { isModalOpen, toggleModal } = useModal();
const { id: inventoryId } = useParams(); const { id: inventoryId } = useParams();
@@ -62,27 +64,36 @@ function InventoryGroupsList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchGroups, request: fetchData,
} = 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, groupOptions, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readGroups(inventoryId, params), InventoriesAPI.readGroups(inventoryId, params),
InventoriesAPI.readGroupsOptions(inventoryId), InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
groups: response.data.results, groups: response.data.results,
groupCount: response.data.count, groupCount: response.data.count,
actions: actionsResponse.data.actions, actions: groupOptions.data.actions,
relatedSearchableKeys: ( relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || [] groupOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} groupOptions.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).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]), }, [inventoryId, location]),
{ {
@@ -95,8 +106,8 @@ function InventoryGroupsList({ i18n }) {
); );
useEffect(() => { useEffect(() => {
fetchGroups(); fetchData();
}, [fetchGroups]); }, [fetchData]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected( const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
groups groups
@@ -144,7 +155,7 @@ function InventoryGroupsList({ i18n }) {
} }
toggleModal(); toggleModal();
fetchGroups(); fetchData();
setSelected([]); setSelected([]);
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
@@ -153,21 +164,14 @@ function InventoryGroupsList({ i18n }) {
const kebabedAdditionalControls = () => { const kebabedAdditionalControls = () => {
return ( return (
<> <>
<AdHocCommandsButton <DropdownItem
adHocItems={selected} key="run command"
apiModule={InventoriesAPI} onClick={() => setIsAdHocCommandsOpen(true)}
itemId={parseInt(inventoryId, 10)} isDisabled={groupCount === 0 || isAdHocDisabled}
> >
{({ openAdHocCommands }) => ( {i18n._(t`Run command`)}
<DropdownItem </DropdownItem>
key="run command"
onClick={openAdHocCommands}
isDisabled={groupCount === 0}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
<DropdownItem <DropdownItem
variant="danger" variant="danger"
aria-label={i18n._(t`Delete`)} aria-label={i18n._(t`Delete`)}
@@ -264,23 +268,14 @@ function InventoryGroupsList({ i18n }) {
position="top" position="top"
key="adhoc" key="adhoc"
> >
<AdHocCommandsButton <Button
css="margin-right: 20px" variant="secondary"
adHocItems={selected} aria-label={i18n._(t`Run command`)}
apiModule={InventoriesAPI} onClick={() => setIsAdHocCommandsOpen(true)}
itemId={parseInt(inventoryId, 10)} isDisabled={groupCount === 0 || isAdHocDisabled}
> >
{({ openAdHocCommands }) => ( {i18n._(t`Run command`)}
<Button </Button>
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={groupCount === 0}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
</Tooltip> </Tooltip>
</ToolbarItem> </ToolbarItem>
<ToolbarItem> <ToolbarItem>
@@ -321,6 +316,16 @@ function InventoryGroupsList({ i18n }) {
) )
} }
/> />
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventoryId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={deletionError}

View File

@@ -6,7 +6,7 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import { InventoriesAPI, GroupsAPI } from '../../../api'; import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
import InventoryGroupsList from './InventoryGroupsList'; import InventoryGroupsList from './InventoryGroupsList';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -71,6 +71,17 @@ describe('<InventoryGroupsList />', () => {
}, },
}, },
}); });
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({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'], initialEntries: ['/inventories/inventory/3/groups'],
}); });
@@ -147,31 +158,17 @@ describe('<InventoryGroupsList />', () => {
expect(el.props().checked).toBe(false); 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('<InventoryGroupsList/> error handling', () => { describe('<InventoryGroupsList/> error handling', () => {
let wrapper; let wrapper;
test('should show content error when api throws error on initial render', async () => { beforeEach(() => {
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
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(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show error modal when group is not successfully deleted from api', async () => {
InventoriesAPI.readGroups.mockResolvedValue({ InventoriesAPI.readGroups.mockResolvedValue({
data: { data: {
count: mockGroups.length, count: mockGroups.length,
@@ -197,7 +194,42 @@ describe('<InventoryGroupsList/> 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(<InventoryGroupsList />);
});
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(<InventoryGroupsList />);
});
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({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'], initialEntries: ['/inventories/inventory/3/groups'],
}); });
@@ -249,4 +281,27 @@ describe('<InventoryGroupsList/> error handling', () => {
.invoke('onClose')(); .invoke('onClose')();
}); });
}); });
test('should render disabled ad hoc button', async () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/inventories/inventory/:id/groups">
<InventoryGroupsList />
</Route>,
{
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);
});
}); });

View File

@@ -2,13 +2,19 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom'; import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
import useRequest, { import useRequest, {
useDismissableError, useDismissableError,
useDeleteItems, useDeleteItems,
} from '../../../util/useRequest'; } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import { HostsAPI, InventoriesAPI } from '../../../api'; import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
@@ -17,6 +23,8 @@ import PaginatedDataList, {
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import AssociateModal from '../../../components/AssociateModal'; import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton'; import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostGroupItem from './InventoryHostGroupItem'; import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('group', {
function InventoryHostGroupsList({ i18n }) { function InventoryHostGroupsList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { hostId, id: invId } = useParams(); const { hostId, id: invId } = useParams();
const { search } = useLocation(); const { search } = useLocation();
@@ -37,6 +46,9 @@ function InventoryHostGroupsList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
credentialTypeId,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -49,22 +61,29 @@ function InventoryHostGroupsList({ i18n }) {
{ {
data: { count, results }, data: { count, results },
}, },
actionsResponse, hostGroupOptions,
adHocOptions,
cred,
] = await Promise.all([ ] = await Promise.all([
HostsAPI.readAllGroups(hostId, params), HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId), HostsAPI.readGroupsOptions(hostId),
InventoriesAPI.readAdHocOptions(invId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
groups: results, groups: results,
itemCount: count, itemCount: count,
actions: actionsResponse.data.actions, actions: hostGroupOptions.data.actions,
relatedSearchableKeys: ( relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || [] hostGroupOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} hostGroupOptions.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).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 }, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps
{ {
@@ -73,6 +92,8 @@ function InventoryHostGroupsList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -201,6 +222,40 @@ function InventoryHostGroupsList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
key="run command"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={itemCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single group or host, a selection of multiple hosts, or a selection of multiple groups.`
)}
position="top"
key="adhoc"
>
<Button
key="run command"
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={itemCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} onDisassociate={handleDisassociate}
@@ -233,6 +288,16 @@ function InventoryHostGroupsList({ i18n }) {
title={i18n._(t`Select Groups`)} title={i18n._(t`Select Groups`)}
/> />
)} )}
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(invId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{error && ( {error && (
<AlertModal <AlertModal
isOpen={error} isOpen={error}

View File

@@ -6,7 +6,7 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import { HostsAPI, InventoriesAPI } from '../../../api'; import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import InventoryHostGroupsList from './InventoryHostGroupsList'; import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -80,6 +80,17 @@ describe('<InventoryHostGroupsList />', () => {
}, },
}, },
}); });
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({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/3/groups'], initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
}); });
@@ -272,4 +283,11 @@ describe('<InventoryHostGroupsList />', () => {
wrapper.update(); wrapper.update();
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1); 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
);
});
}); });

View File

@@ -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 { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, parseQueryString } from '../../../util/qs'; 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 AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
@@ -12,6 +18,8 @@ import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostItem from './InventoryHostItem'; import InventoryHostItem from './InventoryHostItem';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
@@ -21,48 +29,64 @@ const QS_CONFIG = getQSConfig('host', {
}); });
function InventoryHostList({ i18n }) { function InventoryHostList({ i18n }) {
const [actions, setActions] = useState(null); const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
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 [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const { id } = useParams(); const { id } = useParams();
const { search } = useLocation(); const { search } = useLocation();
const fetchHosts = (hostId, queryString) => { const {
const params = parseQueryString(QS_CONFIG, queryString); result: {
return InventoriesAPI.readHosts(hostId, params); 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(() => { 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(); fetchData();
}, [id, search]); }, [fetchData]);
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []); setSelected(isSelected ? [...hosts] : []);
@@ -75,30 +99,17 @@ function InventoryHostList({ i18n }) {
setSelected(selected.concat(row)); setSelected(selected.concat(row));
} }
}; };
const {
const handleDelete = async () => { isLoading: isDeleteLoading,
setIsLoading(true); deleteItems: deleteHosts,
deletionError,
try { clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
await Promise.all(selected.map(host => HostsAPI.destroy(host.id))); await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
} catch (error) { }, [selected]),
setDeletionError(error); { qsConfig: QS_CONFIG, fetchItems: fetchData }
} finally { );
setSelected([]);
try {
const {
data: { count, results },
} = await fetchHosts(id, search);
setHosts(results);
setHostCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
};
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
@@ -108,7 +119,7 @@ function InventoryHostList({ i18n }) {
<> <>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={isLoading} hasContentLoading={isLoading || isDeleteLoading}
items={hosts} items={hosts}
itemCount={hostCount} itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)} pluralizedItemName={i18n._(t`Hosts`)}
@@ -133,6 +144,8 @@ function InventoryHostList({ i18n }) {
isNumeric: true, isNumeric: true,
}, },
]} ]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => ( renderToolbar={props => (
<DataListToolbar <DataListToolbar
{...props} {...props}
@@ -149,9 +162,43 @@ function InventoryHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
key="run command"
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
aria-label={i18n._(t`Run command`)}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
key="run command"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
onDelete={handleDelete} onDelete={deleteHosts}
itemsToDelete={selected} itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)} pluralizedItemName={i18n._(t`Hosts`)}
/>, />,
@@ -177,12 +224,22 @@ function InventoryHostList({ i18n }) {
) )
} }
/> />
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
itemId={id}
/>
)}
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={deletionError}
variant="error" variant="error"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more hosts.`)} {i18n._(t`Failed to delete one or more hosts.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { InventoriesAPI, HostsAPI } from '../../../api'; import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -85,7 +85,7 @@ describe('<InventoryHostList />', () => {
results: mockHosts, results: mockHosts,
}, },
}); });
InventoriesAPI.readOptions.mockResolvedValue({ InventoriesAPI.readHostsOptions.mockResolvedValue({
data: { data: {
actions: { actions: {
GET: {}, GET: {},
@@ -93,6 +93,17 @@ describe('<InventoryHostList />', () => {
}, },
}, },
}); });
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 () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHostList />); wrapper = mountWithContexts(<InventoryHostList />);
}); });
@@ -265,8 +276,15 @@ describe('<InventoryHostList />', () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1); 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 () => { test('should hide Add button for users without ability to POST', async () => {
InventoriesAPI.readOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
data: { data: {
actions: { actions: {
GET: {}, GET: {},
@@ -283,7 +301,7 @@ describe('<InventoryHostList />', () => {
}); });
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 () => {
InventoriesAPI.readOptions.mockImplementation(() => InventoriesAPI.readHostsOptions.mockImplementation(() =>
Promise.reject(new Error()) Promise.reject(new Error())
); );
await act(async () => { await act(async () => {

View File

@@ -1,16 +1,23 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; 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 DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList from '../../../components/PaginatedDataList';
import SmartInventoryHostListItem from './SmartInventoryHostListItem'; import SmartInventoryHostListItem from './SmartInventoryHostListItem';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { Inventory } from '../../../types'; import { Inventory } from '../../../types';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
page: 1, page: 1,
@@ -20,24 +27,35 @@ const QS_CONFIG = getQSConfig('host', {
function SmartInventoryHostList({ i18n, inventory }) { function SmartInventoryHostList({ i18n, inventory }) {
const location = useLocation(); const location = useLocation();
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { const {
result: { hosts, count }, result: { hosts, count, moduleOptions, credentialTypeId, isAdHocDisabled },
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchHosts, request: fetchHosts,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); 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 { return {
hosts: data.results, hosts: hostResponse.data.results,
count: data.count, 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]), }, [location.search, inventory.id]),
{ {
hosts: [], hosts: [],
count: 0, count: 0,
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -50,66 +68,106 @@ function SmartInventoryHostList({ i18n, inventory }) {
}, [fetchHosts]); }, [fetchHosts]);
return ( return (
<PaginatedDataList <>
contentError={contentError} <PaginatedDataList
hasContentLoading={isLoading} contentError={contentError}
items={hosts} hasContentLoading={isLoading}
itemCount={count} items={hosts}
pluralizedItemName={i18n._(t`Hosts`)} itemCount={count}
qsConfig={QS_CONFIG} pluralizedItemName={i18n._(t`Hosts`)}
onRowClick={handleSelect} qsConfig={QS_CONFIG}
toolbarSearchColumns={[ onRowClick={handleSelect}
{ toolbarSearchColumns={[
name: i18n._(t`Name`), {
key: 'name', name: i18n._(t`Name`),
isDefault: true, key: 'name',
}, isDefault: true,
{ },
name: i18n._(t`Created by (username)`), {
key: 'created_by__username', name: i18n._(t`Created by (username)`),
}, key: 'created_by__username',
{ },
name: i18n._(t`Modified by (username)`), {
key: 'modified_by__username', name: i18n._(t`Modified by (username)`),
}, key: 'modified_by__username',
]} },
toolbarSortColumns={[ ]}
{ toolbarSortColumns={[
name: i18n._(t`Name`), {
key: 'name', name: i18n._(t`Name`),
}, key: 'name',
]} },
renderToolbar={props => ( ]}
<DataListToolbar renderToolbar={props => (
{...props} <DataListToolbar
showSelectAll {...props}
isAllSelected={isAllSelected} showSelectAll
onSelectAll={isSelected => setSelected(isSelected ? [...hosts] : [])} isAllSelected={isAllSelected}
qsConfig={QS_CONFIG} onSelectAll={isSelected =>
additionalControls={ setSelected(isSelected ? [...hosts] : [])
inventory?.summary_fields?.user_capabilities?.adhoc }
? [ qsConfig={QS_CONFIG}
<Button additionalControls={
aria-label={i18n._(t`Run commands`)} inventory?.summary_fields?.user_capabilities?.adhoc
isDisabled={selected.length === 0} ? [
> <Kebabified>
{i18n._(t`Run commands`)} {({ isKebabified }) =>
</Button>, isKebabified ? (
] <DropdownItem
: [] aria-label={i18n._(t`Run command`)}
} onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={count === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={count === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
]
: []
}
/>
)}
renderItem={host => (
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventory.id, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/> />
)} )}
renderItem={host => ( </>
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
); );
} }

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -12,125 +12,109 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api'); jest.mock('../../../api');
describe('<SmartInventoryHostList />', () => { describe('<SmartInventoryHostList />', () => {
describe('User has adhoc permissions', () => { let wrapper;
let wrapper; const clonedInventory = {
const clonedInventory = { ...mockInventory,
...mockInventory, summary_fields: {
summary_fields: { ...mockInventory.summary_fields,
...mockInventory.summary_fields, user_capabilities: {
user_capabilities: { ...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(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
CredentialTypesAPI.read.mockResolvedValue({
afterAll(() => { data: { count: 1, results: [{ id: 1, name: 'cred' }] },
jest.clearAllMocks();
wrapper.unmount();
}); });
await act(async () => {
test('initially renders successfully', () => { wrapper = mountWithContexts(
expect(wrapper.find('SmartInventoryHostList').length).toBe(1); <SmartInventoryHostList inventory={clonedInventory} />
});
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"]'
); );
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', () => { afterAll(() => {
act(() => { jest.clearAllMocks();
wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')( wrapper.unmount();
true });
);
}); test('initially renders successfully', () => {
wrapper.update(); expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
const runCommandsButton = wrapper.find( });
'button[aria-label="Run commands"]'
); test('should fetch hosts from api and render them in the list', () => {
expect(runCommandsButton.prop('disabled')).toEqual(false); 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 () => { test('should select and deselect all items', async () => {
act(() => { act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(true); 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);
});
}); });
wrapper.update();
test('should show content error when api throws an error', async () => { wrapper.find('DataListCheck').forEach(el => {
InventoriesAPI.readHosts.mockImplementation(() => expect(el.props().checked).toEqual(true);
Promise.reject(new Error()) });
); act(() => {
await act(async () => { wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
wrapper = mountWithContexts( });
<SmartInventoryHostList inventory={mockInventory} /> wrapper.update();
); wrapper.find('DataListCheck').forEach(el => {
}); expect(el.props().checked).toEqual(false);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
}); });
describe('User does not have adhoc permissions', () => { test('should show content error when api throws an error', async () => {
let wrapper; InventoriesAPI.readHosts.mockImplementation(() =>
const clonedInventory = { Promise.reject(new Error())
...mockInventory, );
summary_fields: { await act(async () => {
user_capabilities: { wrapper = mountWithContexts(
adhoc: false, <SmartInventoryHostList inventory={mockInventory} />
);
});
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(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
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(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
}); });
}); });