Uses existing kebabified workflow for run command

This commit is contained in:
Alex Corey
2020-10-08 18:31:22 -04:00
parent ef85a321bc
commit bebaf2d97e
16 changed files with 289 additions and 671 deletions

View File

@@ -1,26 +1,27 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useState, useContext } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory, 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 PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, DropdownItem } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../util/useRequest'; import useRequest, { useDismissableError } from '../../util/useRequest';
import { InventoriesAPI } from '../../api'; import { InventoriesAPI, CredentialTypesAPI } from '../../api';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard'; import AdHocCommandsWizard from './AdHocCommandsWizard';
import { KebabifiedContext } from '../../contexts/Kebabified';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
onClose,
adHocItems,
itemId,
i18n,
moduleOptions,
credentialTypeId,
}) {
const history = useHistory(); const history = useHistory();
const { id } = useParams();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const verbosityOptions = [ const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
@@ -28,26 +29,51 @@ function AdHocCommands({
{ 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)`) },
]; ];
useEffect(() => {
if (isKebabified) {
onKebabModalChange(isWizardOpen);
}
}, [isKebabified, isWizardOpen, onKebabModalChange]);
const {
result: { moduleOptions, credentialTypeId, isAdHocDisabled },
request: fetchData,
error: fetchError,
} = useRequest(
useCallback(async () => {
const [options, cred] = await Promise.all([
InventoriesAPI.readAdHocOptions(id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
moduleOptions: options.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !options.data.actions.POST,
};
}, [id]),
{ moduleOptions: [], isAdHocDisabled: true }
);
useEffect(() => {
fetchData();
}, [fetchData]);
const { const {
isloading: isLaunchLoading, isloading: isLaunchLoading,
error, error: launchError,
request: launchAdHocCommands, request: launchAdHocCommands,
} = useRequest( } = useRequest(
useCallback( useCallback(
async values => { async values => {
const { data } = await InventoriesAPI.launchAdHocCommands( const { data } = await InventoriesAPI.launchAdHocCommands(id, values);
itemId,
values
);
history.push(`/jobs/command/${data.id}/output`); history.push(`/jobs/command/${data.id}/output`);
}, },
[itemId, history] [id, history]
) )
); );
const { dismissError } = useDismissableError(error); const { error, dismissError } = useDismissableError(
launchError || fetchError
);
const handleSubmit = async values => { const handleSubmit = async values => {
const { credential, ...remainingValues } = values; const { credential, ...remainingValues } = values;
@@ -64,7 +90,7 @@ function AdHocCommands({
return <ContentLoading />; return <ContentLoading />;
} }
if (error) { if (error && isWizardOpen) {
return ( return (
<AlertModal <AlertModal
isOpen={error} isOpen={error}
@@ -72,31 +98,63 @@ function AdHocCommands({
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => { onClose={() => {
dismissError(); dismissError();
setIsWizardOpen(false);
}} }}
> >
<> {launchError ? (
{i18n._(t`Failed to launch job.`)} <>
<ErrorDetail error={error} /> {i18n._(t`Failed to launch job.`)}
</> <ErrorDetail error={error} />
</>
) : (
<ContentError error={error} />
)}
</AlertModal> </AlertModal>
); );
} }
return ( return (
<AdHocCommandsWizard // render buttons for drop down and for toolbar
adHocItems={adHocItems} // if modal is open render the modal
moduleOptions={moduleOptions} <>
verbosityOptions={verbosityOptions} {isKebabified ? (
credentialTypeId={credentialTypeId} <DropdownItem
onCloseWizard={onClose} key="cancel-job"
onLaunch={handleSubmit} isDisabled={isAdHocDisabled || !hasListItems}
onDismissError={() => dismissError()} component="button"
/> aria-label={i18n._(t`Run Command`)}
onClick={() => setIsWizardOpen(true)}
>
{i18n._(t`Run Command`)}
</DropdownItem>
) : (
<Button
variant="secondary"
aria-label={i18n._(t`Run Command`)}
onClick={() => setIsWizardOpen(true)}
isDisabled={isAdHocDisabled || !hasListItems}
>
{i18n._(t`Run Command`)}
</Button>
)}
{isWizardOpen && (
<AdHocCommandsWizard
adHocItems={adHocItems}
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
)}
</>
); );
} }
AdHocCommands.propTypes = { AdHocCommands.propTypes = {
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired, hasListItems: PropTypes.bool.isRequired,
}; };
export default withI18n()(AdHocCommands); export default withI18n()(AdHocCommands);

View File

@@ -10,7 +10,12 @@ import AdHocCommands from './AdHocCommands';
jest.mock('../../api/models/CredentialTypes'); jest.mock('../../api/models/CredentialTypes');
jest.mock('../../api/models/Inventories'); jest.mock('../../api/models/Inventories');
jest.mock('../../api/models/Credentials'); jest.mock('../../api/models/Credentials');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
}),
}));
const credentials = [ const credentials = [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
@@ -18,10 +23,7 @@ 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',
@@ -30,6 +32,26 @@ const adHocItems = [
]; ];
describe('<AdHocCommands />', () => { describe('<AdHocCommands />', () => {
beforeEach(() => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
});
let wrapper; let wrapper;
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
@@ -39,19 +61,45 @@ describe('<AdHocCommands />', () => {
test('mounts successfully', async () => { test('mounts successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands adHocItems={adHocItems} hasListItems />
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
); );
}); });
expect(wrapper.find('AdHocCommands').length).toBe(1); expect(wrapper.find('AdHocCommands').length).toBe(1);
}); });
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 adHocItems={adHocItems} hasListItems />
);
});
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
});
test('should submit properly', async () => { test('should submit properly', async () => {
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } }); InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
@@ -62,17 +110,13 @@ describe('<AdHocCommands />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands adHocItems={adHocItems} hasListItems />
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
); );
}); });
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
@@ -174,17 +218,13 @@ describe('<AdHocCommands />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands adHocItems={adHocItems} hasListItems />
css="margin-right: 20px"
onClose={() => {}}
credentialTypeId={1}
itemId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
); );
}); });
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
@@ -237,4 +277,69 @@ 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 />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run Command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
});
test('should disable run command button due to lack of list items', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems={false} />
);
});
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 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 adHocItems={adHocItems} hasListItems />
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
}); });

View File

@@ -134,8 +134,7 @@ const FormikApp = withFormik({
FormikApp.propTypes = { FormikApp.propTypes = {
onLaunch: PropTypes.func.isRequired, onLaunch: PropTypes.func.isRequired,
moduleOptions: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)) moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
.isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
onCloseWizard: PropTypes.func.isRequired, onCloseWizard: PropTypes.func.isRequired,
credentialTypeId: PropTypes.number.isRequired, credentialTypeId: PropTypes.number.isRequired,

View File

@@ -110,7 +110,6 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
label={i18n._(t`Arguments`)} label={i18n._(t`Arguments`)}
validated={isValid ? 'default' : 'error'} validated={isValid ? 'default' : 'error'}
onBlur={() => argumentsHelpers.setTouched(true)} onBlur={() => argumentsHelpers.setTouched(true)}
placeholder={i18n._(t`Enter arguments`)}
isRequired={ isRequired={
moduleNameField.value === 'command' || moduleNameField.value === 'command' ||
moduleNameField.value === 'shell' moduleNameField.value === 'shell'
@@ -317,8 +316,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
} }
AdHocDetailsStep.propTypes = { AdHocDetailsStep.propTypes = {
moduleOptions: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)) moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
.isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
}; };

View File

@@ -2,14 +2,8 @@ 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, CredentialTypesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI } from '../../../api';
import useRequest, { import useRequest, {
useDeleteItems, useDeleteItems,
@@ -22,7 +16,6 @@ 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 AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryGroupHostListItem from './InventoryGroupHostListItem'; import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddDropdown from '../shared/AddDropdown'; import AddDropdown from '../shared/AddDropdown';
@@ -35,7 +28,6 @@ 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();
@@ -47,9 +39,6 @@ function InventoryGroupHostList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -57,16 +46,9 @@ function InventoryGroupHostList({ i18n }) {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const [ const [response, actionsResponse] = await Promise.all([
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 {
@@ -79,9 +61,6 @@ 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]),
{ {
@@ -90,8 +69,6 @@ function InventoryGroupHostList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -230,40 +207,10 @@ function InventoryGroupHostList({ i18n }) {
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[ additionalControls={[
...(canAdd ? [addButton] : []), ...(canAdd ? [addButton] : []),
<Kebabified> <AdHocCommands
{({ isKebabified }) => adHocItems={selected}
isKebabified ? ( hasListItems={hostCount > 0}
<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}
@@ -301,16 +248,6 @@ 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, CredentialTypesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -35,17 +35,6 @@ 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 />);
}); });
@@ -107,29 +96,6 @@ 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('AddDropdown').length).toBe(1); expect(wrapper.find('AddDropdown').length).toBe(1);
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({

View File

@@ -2,17 +2,11 @@ import React, { useCallback, useState, useEffect } 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 { import { Button, Tooltip } from '@patternfly/react-core';
Button,
Tooltip,
DropdownItem,
ToolbarGroup,
ToolbarItem,
} from '@patternfly/react-core';
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, CredentialTypesAPI } from '../../../api'; import { InventoriesAPI, GroupsAPI } 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';
@@ -24,7 +18,6 @@ import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import { Kebabified } from '../../../contexts/Kebabified';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
page: 1, page: 1,
@@ -52,7 +45,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();
@@ -64,9 +57,6 @@ function InventoryGroupsList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -74,11 +64,9 @@ function InventoryGroupsList({ i18n }) {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const [response, groupOptions, adHocOptions, cred] = await Promise.all([ const [response, groupOptions] = 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 {
@@ -91,9 +79,6 @@ function InventoryGroupsList({ i18n }) {
searchableKeys: Object.keys( searchableKeys: Object.keys(
groupOptions.data.actions?.GET || {} groupOptions.data.actions?.GET || {}
).filter(key => groupOptions.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]),
{ {
@@ -161,29 +146,6 @@ function InventoryGroupsList({ i18n }) {
}; };
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const kebabedAdditionalControls = () => {
return (
<>
<DropdownItem
key="run command"
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={groupCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
<DropdownItem
variant="danger"
aria-label={i18n._(t`Delete`)}
key="delete"
onClick={toggleModal}
isDisabled={selected.length === 0 || selected.some(cannotDelete)}
>
{i18n._(t`Delete`)}
</DropdownItem>
</>
);
};
return ( return (
<> <>
@@ -253,57 +215,24 @@ function InventoryGroupsList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified> <AdHocCommands
{({ isKebabified }) => ( adHocItems={selected}
<> hasListItems={groupCount > 0}
{isKebabified ? ( />,
kebabedAdditionalControls() <Tooltip content={renderTooltip()} position="top" key="delete">
) : ( <div>
<ToolbarGroup> <Button
<ToolbarItem> variant="danger"
<Tooltip aria-label={i18n._(t`Delete`)}
content={i18n._( onClick={toggleModal}
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single group or a selection of multiple groups.` isDisabled={
)} selected.length === 0 || selected.some(cannotDelete)
position="top" }
key="adhoc" >
> {i18n._(t`Delete`)}
<Button </Button>
variant="secondary" </div>
aria-label={i18n._(t`Run command`)} </Tooltip>,
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={groupCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
<ToolbarItem>
<Tooltip
content={renderTooltip()}
position="top"
key="delete"
>
<div>
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}
onClick={toggleModal}
isDisabled={
selected.length === 0 ||
selected.some(cannotDelete)
}
>
{i18n._(t`Delete`)}
</Button>
</div>
</Tooltip>
</ToolbarItem>
</ToolbarGroup>
)}
</>
)}
</Kebabified>,
]} ]}
/> />
)} )}
@@ -316,16 +245,6 @@ 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, CredentialTypesAPI } from '../../../api'; import { InventoriesAPI, GroupsAPI } from '../../../api';
import InventoryGroupsList from './InventoryGroupsList'; import InventoryGroupsList from './InventoryGroupsList';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -71,17 +71,6 @@ 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'],
}); });
@@ -158,13 +147,6 @@ 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;
@@ -194,16 +176,6 @@ 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(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -230,21 +202,8 @@ describe('<InventoryGroupsList/> error handling', () => {
}); });
test('should show error modal when group is not successfully deleted from api', async () => { test('should show error modal when group is not successfully deleted from api', async () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<InventoryGroupsList />);
<Route path="/inventories/inventory/:id/groups">
<InventoryGroupsList />
</Route>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
@@ -281,27 +240,4 @@ 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,19 +2,13 @@ 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, CredentialTypesAPI } from '../../../api'; import { HostsAPI, InventoriesAPI } 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';
@@ -23,7 +17,6 @@ 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 AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostGroupItem from './InventoryHostGroupItem'; import InventoryHostGroupItem from './InventoryHostGroupItem';
@@ -35,7 +28,6 @@ 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();
@@ -46,9 +38,6 @@ function InventoryHostGroupsList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
credentialTypeId,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -62,13 +51,9 @@ function InventoryHostGroupsList({ i18n }) {
data: { count, results }, data: { count, results },
}, },
hostGroupOptions, 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 {
@@ -81,9 +66,6 @@ function InventoryHostGroupsList({ i18n }) {
searchableKeys: Object.keys( searchableKeys: Object.keys(
hostGroupOptions.data.actions?.GET || {} hostGroupOptions.data.actions?.GET || {}
).filter(key => hostGroupOptions.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
{ {
@@ -92,8 +74,6 @@ function InventoryHostGroupsList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -222,40 +202,10 @@ function InventoryHostGroupsList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified> <AdHocCommands
{({ isKebabified }) => adHocItems={selected}
isKebabified ? ( hasListItems={itemCount > 0}
<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}
@@ -288,16 +238,6 @@ 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, CredentialTypesAPI } from '../../../api'; import { HostsAPI, InventoriesAPI } from '../../../api';
import InventoryHostGroupsList from './InventoryHostGroupsList'; import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -80,17 +80,6 @@ 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'],
}); });
@@ -283,11 +272,4 @@ 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

@@ -2,14 +2,8 @@ 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, CredentialTypesAPI } from '../../../api'; import { InventoriesAPI, HostsAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest'; 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';
@@ -18,7 +12,6 @@ import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostItem from './InventoryHostItem'; import InventoryHostItem from './InventoryHostItem';
@@ -29,7 +22,6 @@ const QS_CONFIG = getQSConfig('host', {
}); });
function InventoryHostList({ i18n }) { function InventoryHostList({ i18n }) {
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const { id } = useParams(); const { id } = useParams();
const { search } = useLocation(); const { search } = useLocation();
@@ -41,9 +33,6 @@ function InventoryHostList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -51,11 +40,9 @@ function InventoryHostList({ i18n }) {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search); const params = parseQueryString(QS_CONFIG, search);
const [response, hostOptions, adHocOptions, cred] = await Promise.all([ const [response, hostOptions] = await Promise.all([
InventoriesAPI.readHosts(id, params), InventoriesAPI.readHosts(id, params),
InventoriesAPI.readHostsOptions(id), InventoriesAPI.readHostsOptions(id),
InventoriesAPI.readAdHocOptions(id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
@@ -68,9 +55,6 @@ function InventoryHostList({ i18n }) {
searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter( searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter(
key => hostOptions.data.actions?.GET[key].filterable 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]), }, [id, search]),
{ {
@@ -79,8 +63,6 @@ function InventoryHostList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -162,40 +144,10 @@ function InventoryHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified> <AdHocCommands
{({ isKebabified }) => adHocItems={selected}
isKebabified ? ( hasListItems={hostCount > 0}
<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={deleteHosts} onDelete={deleteHosts}
@@ -224,16 +176,6 @@ function InventoryHostList({ i18n }) {
) )
} }
/> />
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
itemId={parseInt(id, 10)}
/>
)}
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={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, CredentialTypesAPI } from '../../../api'; import { InventoriesAPI, HostsAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -93,17 +93,6 @@ 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 />);
}); });
@@ -276,13 +265,6 @@ 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.readHostsOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
data: { data: {

View File

@@ -1,12 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
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 { useParams, useLocation, useHistory } from 'react-router-dom'; import { useParams, useLocation, useHistory } from 'react-router-dom';
import { GroupsAPI, InventoriesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI } from '../../../api';
@@ -18,7 +12,6 @@ import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList from '../../../components/PaginatedDataList';
import InventoryGroupRelatedGroupListItem from './InventoryRelatedGroupListItem'; import InventoryGroupRelatedGroupListItem from './InventoryRelatedGroupListItem';
import AddDropdown from '../shared/AddDropdown'; import AddDropdown from '../shared/AddDropdown';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import AssociateModal from '../../../components/AssociateModal'; import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton'; import DisassociateButton from '../../../components/DisassociateButton';
@@ -157,56 +150,10 @@ function InventoryRelatedGroupList({ i18n }) {
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[ additionalControls={[
...(canAdd ? [addButton] : []), ...(canAdd ? [addButton] : []),
<Kebabified> <AdHocCommands
{({ isKebabified }) => adHocItems={selected}
isKebabified ? ( hasListItems={itemCount > 0}
<AdHocCommands />,
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<DropdownItem
key="run command"
onClick={openAdHocCommands}
isDisabled={itemCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommands>
) : (
<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"
>
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={itemCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommands>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={() => {}} onDisassociate={() => {}}

View File

@@ -1,22 +1,15 @@
import React, { useEffect, useCallback, useState } from 'react'; import React, { useEffect, useCallback } 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,
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, CredentialTypesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { Inventory } from '../../../types'; import { Inventory } from '../../../types';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
@@ -27,35 +20,27 @@ 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, moduleOptions, credentialTypeId, isAdHocDisabled }, result: { hosts, count },
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 [hostResponse, adHocOptions, cred] = await Promise.all([ const {
InventoriesAPI.readHosts(inventory.id, params), data: { results, count: hostCount },
InventoriesAPI.readAdHocOptions(inventory.id), } = await InventoriesAPI.readHosts(inventory.id, params);
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return { return {
hosts: hostResponse.data.results, hosts: results,
count: hostResponse.data.count, count: hostCount,
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,
} }
); );
@@ -110,38 +95,10 @@ function SmartInventoryHostList({ i18n, inventory }) {
additionalControls={ additionalControls={
inventory?.summary_fields?.user_capabilities?.adhoc inventory?.summary_fields?.user_capabilities?.adhoc
? [ ? [
<Kebabified> <AdHocCommands
{({ isKebabified }) => adHocItems={selected}
isKebabified ? ( hasListItems={count > 0}
<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>,
] ]
: [] : []
} }
@@ -157,16 +114,6 @@ function SmartInventoryHostList({ i18n, inventory }) {
/> />
)} )}
/> />
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventory.id, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
</> </>
); );
} }

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, CredentialTypesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -27,17 +27,6 @@ describe('<SmartInventoryHostList />', () => {
InventoriesAPI.readHosts.mockResolvedValue({ InventoriesAPI.readHosts.mockResolvedValue({
data: mockHosts, data: mockHosts,
}); });
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( wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} /> <SmartInventoryHostList inventory={clonedInventory} />
@@ -60,15 +49,6 @@ describe('<SmartInventoryHostList />', () => {
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); 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);
@@ -97,24 +77,4 @@ describe('<SmartInventoryHostList />', () => {
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); 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']] } },
},
},
});
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);
});
}); });

View File

@@ -48,7 +48,7 @@ function AddDropdown({ dropdownItems, i18n }) {
<div ref={element} key="add"> <div ref={element} key="add">
<Dropdown <Dropdown
isOpen={isOpen} isOpen={isOpen}
position={DropdownPosition.right} position={DropdownPosition.left}
toggle={ toggle={
<DropdownToggle <DropdownToggle
id="add" id="add"