Adds an execution environment step to the ad hoc commands

This commit is contained in:
Alex Corey 2021-04-20 16:26:39 -04:00
parent 1e7b7d1a30
commit e6bde23aea
16 changed files with 16785 additions and 33 deletions

View File

@ -12,10 +12,9 @@ import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard';
import { KebabifiedContext } from '../../contexts/Kebabified';
import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({ adHocItems, i18n, hasListItems }) {
function AdHocCommands({ adHocItems, i18n, hasListItems, onLaunchLoading }) {
const history = useHistory();
const { id } = useParams();
@ -76,19 +75,20 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
);
const handleSubmit = async values => {
const { credential, ...remainingValues } = values;
const { credential, execution_environment, ...remainingValues } = values;
const newCredential = credential[0].id;
const manipulatedValues = {
credential: newCredential,
execution_environment: execution_environment[0]?.id,
...remainingValues,
};
await launchAdHocCommands(manipulatedValues);
};
if (isLaunchLoading) {
return <ContentLoading />;
}
useEffect(() => onLaunchLoading(isLaunchLoading), [
isLaunchLoading,
onLaunchLoading,
]);
if (error && isWizardOpen) {
return (

View File

@ -4,12 +4,19 @@ import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { CredentialTypesAPI, InventoriesAPI, CredentialsAPI } from '../../api';
import {
CredentialTypesAPI,
InventoriesAPI,
CredentialsAPI,
ExecutionEnvironmentsAPI,
} from '../../api';
import AdHocCommands from './AdHocCommands';
jest.mock('../../api/models/CredentialTypes');
jest.mock('../../api/models/Inventories');
jest.mock('../../api/models/Credentials');
jest.mock('../../api/models/ExecutionEnvironments');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
@ -51,6 +58,15 @@ describe('<AdHocCommands />', () => {
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: [
{ id: 1, name: 'EE1 1', url: 'wwww.google.com' },
{ id: 2, name: 'EE2', url: 'wwww.google.com' },
],
count: 2,
},
});
});
let wrapper;
afterEach(() => {
@ -61,7 +77,11 @@ describe('<AdHocCommands />', () => {
test('mounts successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
expect(wrapper.find('AdHocCommands').length).toBe(1);
@ -86,9 +106,22 @@ describe('<AdHocCommands />', () => {
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: [
{ id: 1, name: 'EE1 1', url: 'wwww.google.com' },
{ id: 2, name: 'EE2', url: 'wwww.google.com' },
],
count: 2,
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
await act(async () =>
@ -108,9 +141,22 @@ describe('<AdHocCommands />', () => {
count: 5,
},
});
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: [
{ id: 1, name: 'EE1 1', url: 'wwww.google.com' },
{ id: 2, name: 'EE2', url: 'wwww.google.com' },
],
count: 2,
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
@ -147,8 +193,27 @@ describe('<AdHocCommands />', () => {
wrapper.find('Button[type="submit"]').prop('onClick')()
);
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
// second step of wizard
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-2"]')
.simulate('change', { target: { checked: true } });
});
wrapper.update();
expect(
wrapper.find('CheckboxListItem[label="EE2"]').prop('isSelected')
).toBe(true);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
// third step of wizard
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-4"]')
@ -176,6 +241,7 @@ describe('<AdHocCommands />', () => {
limit: 'Inventory 1 Org 0, Inventory 2 Org 0',
module_name: 'command',
verbosity: 1,
execution_environment: 2,
});
});
@ -202,13 +268,21 @@ describe('<AdHocCommands />', () => {
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
verbosity: {
choices: [[1], [2]],
},
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
data: {
results: [
{
id: 1,
},
],
},
});
CredentialsAPI.read.mockResolvedValue({
data: {
@ -216,9 +290,30 @@ describe('<AdHocCommands />', () => {
count: 5,
},
});
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: [
{
id: 1,
name: 'EE1 1',
url: 'wwww.google.com',
},
{
id: 2,
name: 'EE2',
url: 'wwww.google.com',
},
],
count: 2,
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
@ -240,7 +335,10 @@ describe('<AdHocCommands />', () => {
'command'
);
wrapper.find('input#module_args').simulate('change', {
target: { value: 'foo', name: 'module_args' },
target: {
value: 'foo',
name: 'module_args',
},
});
wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
});
@ -259,10 +357,36 @@ describe('<AdHocCommands />', () => {
// second step of wizard
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-2"]')
.simulate('change', {
target: {
checked: true,
},
});
});
wrapper.update();
expect(
wrapper.find('CheckboxListItem[label="EE2"]').prop('isSelected')
).toBe(true);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
// third step of wizard
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-4"]')
.simulate('change', { target: { checked: true } });
.simulate('change', {
target: {
checked: true,
},
});
});
wrapper.update();
@ -291,7 +415,11 @@ describe('<AdHocCommands />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
@ -312,7 +440,11 @@ describe('<AdHocCommands />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems={false} />
<AdHocCommands
adHocItems={adHocItems}
hasListItems={false}
onLaunchLoading={() => jest.fn()}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
@ -335,7 +467,11 @@ describe('<AdHocCommands />', () => {
);
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
<AdHocCommands
adHocItems={adHocItems}
hasListItems
onLaunchLoading={() => jest.fn()}
/>
);
});
await act(async () => wrapper.find('button').prop('onClick')());

View File

@ -10,6 +10,7 @@ import styled from 'styled-components';
import Wizard from '../Wizard';
import AdHocCredentialStep from './AdHocCredentialStep';
import AdHocDetailsStep from './AdHocDetailsStep';
import AdHocExecutionEnvironmentStep from './AdHocExecutionEnvironmentStep';
const AlertText = styled.div`
color: var(--pf-global--danger-color--200);
@ -81,6 +82,14 @@ function AdHocCommandsWizard({
{
id: 2,
key: 2,
name: t`Execution Environment`,
component: <AdHocExecutionEnvironmentStep />,
enableNext: true,
canJumpTo: currentStepId >= 2,
},
{
id: 3,
key: 3,
name: i18n._(t`Machine credential`),
component: (
<AdHocCredentialStep
@ -128,6 +137,7 @@ const FormikApp = withFormik({
module_name: '',
extra_vars: '---',
job_type: 'run',
execution_environment: '',
};
},
})(AdHocCommandsWizard);

View File

@ -4,12 +4,14 @@ import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { CredentialsAPI } from '../../api';
import { CredentialsAPI, ExecutionEnvironmentsAPI } from '../../api';
import AdHocCommandsWizard from './AdHocCommandsWizard';
jest.mock('../../api/models/CredentialTypes');
jest.mock('../../api/models/Inventories');
jest.mock('../../api/models/Credentials');
jest.mock('../../api/models/ExecutionEnvironments');
const verbosityOptions = [
{ value: '0', key: '0', label: '0 (Normal)' },
{ value: '1', key: '1', label: '1 (Verbose)' },
@ -97,6 +99,15 @@ describe('<AdHocCommandsWizard/>', () => {
wrapper.update();
});
test('launch button should become active', async () => {
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: [
{ id: 1, name: 'EE 1', url: '' },
{ id: 2, name: 'EE 2', url: '' },
],
count: 2,
},
});
CredentialsAPI.read.mockResolvedValue({
data: {
results: [
@ -127,10 +138,40 @@ describe('<AdHocCommandsWizard/>', () => {
);
wrapper.update();
// step 2
await waitForElement(wrapper, 'OptionsList', el => el.length > 0);
expect(wrapper.find('CheckboxListItem').length).toBe(2);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false
);
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-1"]')
.simulate('change', { target: { checked: true } });
});
wrapper.update();
expect(
wrapper.find('CheckboxListItem[label="EE 1"]').prop('isSelected')
).toBe(true);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// step 3
await waitForElement(wrapper, 'OptionsList', el => el.length > 0);
expect(wrapper.find('CheckboxListItem').length).toBe(2);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-1"]')
@ -150,8 +191,21 @@ describe('<AdHocCommandsWizard/>', () => {
wrapper.find('Button[type="submit"]').prop('onClick')()
);
expect(onLaunch).toHaveBeenCalled();
expect(onLaunch).toHaveBeenCalledWith({
become_enabled: '',
credential: [{ id: 1, name: 'Cred 1', url: '' }],
diff_mode: false,
execution_environment: [{ id: 1, name: 'EE 1', url: '' }],
extra_vars: '---',
forks: 0,
job_type: 'run',
limit: 'Inventory 1, Inventory 2, inventory 3',
module_args: 'foo',
module_name: 'command',
verbosity: 1,
});
});
test('should show error in navigation bar', async () => {
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
@ -201,6 +255,12 @@ describe('<AdHocCommandsWizard/>', () => {
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('ContentError').length).toBe(1);
});

View File

@ -0,0 +1,47 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { ExecutionEnvironmentsAPI } from '../../api';
import AdHocExecutionEnvironmentStep from './AdHocExecutionEnvironmentStep';
jest.mock('../../api/models/ExecutionEnvironments');
describe('<AdHocExecutionEnvironmentStep />', () => {
let wrapper;
beforeEach(async () => {
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: [
{ id: 1, name: 'EE1 1', url: 'wwww.google.com' },
{ id: 2, name: 'EE2', url: 'wwww.google.com' },
],
count: 2,
},
});
await act(async () => {
wrapper = mountWithContexts(
<Formik>
<AdHocExecutionEnvironmentStep />
</Formik>
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should mount properly', async () => {
await waitForElement(wrapper, 'OptionsList', el => el.length > 0);
});
test('should call api', async () => {
await waitForElement(wrapper, 'OptionsList', el => el.length > 0);
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalled();
expect(wrapper.find('CheckboxListItem').length).toBe(2);
});
});

View File

@ -0,0 +1,108 @@
import React, { useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core';
import { ExecutionEnvironmentsAPI } from '../../api';
import Popover from '../Popover';
import { parseQueryString, getQSConfig } from '../../util/qs';
import useRequest from '../../util/useRequest';
import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import OptionsList from '../OptionsList';
const QS_CONFIG = getQSConfig('execution_environemts', {
page: 1,
page_size: 5,
order_by: 'name',
});
function AdHocExecutionEnvironmentStep() {
const history = useHistory();
const [executionEnvironmentField, , executionEnvironmentHelpers] = useField(
'execution_environment'
);
const {
error,
isLoading,
request: fetchExecutionEnvironments,
result: { executionEnvironments, executionEnvironmentsCount },
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const {
data: { results, count },
} = await ExecutionEnvironmentsAPI.read(params);
return {
executionEnvironments: results,
executionEnvironmentsCount: count,
};
}, [history.location.search]),
{ executionEnvironments: [], executionEnvironmentsCount: 0 }
);
useEffect(() => {
fetchExecutionEnvironments();
}, [fetchExecutionEnvironments]);
if (error) {
return <ContentError error={error} />;
}
if (isLoading) {
return <ContentLoading />;
}
return (
<Form>
<FormGroup
fieldId="execution_enviroment"
label={t`Execution Environments`}
aria-label={t`Execution Environments`}
labelIcon={
<Popover
content={t`Select the Execution Environment you want this command to run inside`}
/>
}
>
<OptionsList
value={executionEnvironmentField.value || []}
options={executionEnvironments}
optionCount={executionEnvironmentsCount}
header={t`Execution Environments`}
qsConfig={QS_CONFIG}
searchColumns={[
{
name: t`Name`,
key: 'name',
isDefault: true,
},
{
name: t`Created By (Username)`,
key: 'created_by__username',
},
{
name: t`Modified By (Username)`,
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: t`Name`,
key: 'name',
},
]}
name="execution_environment"
selectItem={value => {
executionEnvironmentHelpers.setValue([value]);
}}
deselectItem={() => {
executionEnvironmentHelpers.setValue([]);
}}
/>
</FormGroup>
</Form>
);
}
export default AdHocExecutionEnvironmentStep;

View File

@ -92,11 +92,11 @@ describe('<InventoryDetail />', () => {
expectDetailToMatch(wrapper, 'Type', 'Inventory');
const org = wrapper.find('Detail[label="Organization"]');
expect(org.prop('value')).toMatchInlineSnapshot(`
<Link
<ForwardRef
to="/organizations/1/details"
>
The Organization
</Link>
</ForwardRef>
`);
const vars = wrapper.find('VariablesDetail');
expect(vars).toHaveLength(1);

View File

@ -28,6 +28,7 @@ const QS_CONFIG = getQSConfig('host', {
});
function InventoryGroupHostList({ i18n }) {
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const { id: inventoryId, groupId } = useParams();
const location = useLocation();
@ -172,7 +173,9 @@ function InventoryGroupHostList({ i18n }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDisassociateLoading}
hasContentLoading={
isLoading || isDisassociateLoading || isAdHocLaunchLoading
}
items={hosts}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
@ -215,6 +218,7 @@ function InventoryGroupHostList({ i18n }) {
<AdHocCommands
adHocItems={selected}
hasListItems={hostCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
<DisassociateButton
key="disassociate"

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -30,6 +30,7 @@ function cannotDelete(item) {
function InventoryGroupsList({ i18n }) {
const location = useLocation();
const { id: inventoryId } = useParams();
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const {
result: {
@ -107,7 +108,7 @@ function InventoryGroupsList({ i18n }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={isLoading || isAdHocLaunchLoading}
items={groups}
itemCount={groupCount}
qsConfig={QS_CONFIG}
@ -174,6 +175,7 @@ function InventoryGroupsList({ i18n }) {
<AdHocCommands
adHocItems={selected}
hasListItems={groupCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
<Tooltip content={renderTooltip()} position="top" key="delete">
<InventoryGroupsDeleteModal

View File

@ -28,6 +28,7 @@ const QS_CONFIG = getQSConfig('group', {
function InventoryHostGroupsList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const { hostId, id: invId } = useParams();
const { search } = useLocation();
@ -147,7 +148,9 @@ function InventoryHostGroupsList({ i18n }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDisassociateLoading}
hasContentLoading={
isLoading || isDisassociateLoading || isAdHocLaunchLoading
}
items={groups}
itemCount={itemCount}
qsConfig={QS_CONFIG}
@ -205,6 +208,7 @@ function InventoryHostGroupsList({ i18n }) {
<AdHocCommands
adHocItems={selected}
hasListItems={itemCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
<DisassociateButton
key="disassociate"

View File

@ -23,6 +23,7 @@ const QS_CONFIG = getQSConfig('host', {
function InventoryHostList({ i18n }) {
const [selected, setSelected] = useState([]);
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const { id } = useParams();
const { search } = useLocation();
@ -106,7 +107,7 @@ function InventoryHostList({ i18n }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
hasContentLoading={isLoading || isDeleteLoading || isAdHocLaunchLoading}
items={hosts}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
@ -152,6 +153,7 @@ function InventoryHostList({ i18n }) {
<AdHocCommands
adHocItems={selected}
hasListItems={hostCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
<ToolbarDeleteButton
key="delete"

View File

@ -26,6 +26,7 @@ const QS_CONFIG = getQSConfig('group', {
});
function InventoryRelatedGroupList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const [associateError, setAssociateError] = useState(null);
const [disassociateError, setDisassociateError] = useState(null);
const { id: inventoryId, groupId } = useParams();
@ -154,7 +155,7 @@ function InventoryRelatedGroupList({ i18n }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={isLoading || isAdHocLaunchLoading}
items={groups}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Related Groups`)}
@ -197,6 +198,7 @@ function InventoryRelatedGroupList({ i18n }) {
<AdHocCommands
adHocItems={selected}
hasListItems={itemCount > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
<DisassociateButton
key="disassociate"

View File

@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -20,7 +20,7 @@ const QS_CONFIG = getQSConfig('host', {
function SmartInventoryHostList({ i18n, inventory }) {
const location = useLocation();
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const {
result: { hosts, count },
error: contentError,
@ -56,7 +56,7 @@ function SmartInventoryHostList({ i18n, inventory }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={isLoading || isAdHocLaunchLoading}
items={hosts}
itemCount={count}
pluralizedItemName={i18n._(t`Hosts`)}
@ -98,6 +98,7 @@ function SmartInventoryHostList({ i18n, inventory }) {
<AdHocCommands
adHocItems={selected}
hasListItems={count > 0}
onLaunchLoading={setIsAdHocLaunchLoading}
/>,
]
: []