Merge pull request #10576 from AlexSCorey/9399-revampAdHocCommandsWorkflow

Adds Ad Hoc Preview step and adds workflow similar to prompt on launch

SUMMARY
This closes #9399 it also introduce the same workflow that prompt on launch uses in the Ad Hoc Commands, where the user to go between steps as they wish.
ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

AWX VERSION
ADDITIONAL INFORMATION

Reviewed-by: Kersom <None>
Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
Reviewed-by: Sarah Akus <sakus@redhat.com>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-07-20 13:31:37 +00:00 committed by GitHub
commit 8183179850
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 421 additions and 223 deletions

View File

@ -184,14 +184,6 @@ describe('<AdHocCommands />', () => {
);
wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
expect(
wrapper
.find('WizardNavItem[content="Machine credential"]')
.prop('isDisabled')
).toBe(true);
await act(async () => {
wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
{},
@ -247,6 +239,12 @@ describe('<AdHocCommands />', () => {
wrapper.find('CheckboxListItem[label="Cred 4"]').prop('isSelected')
).toBe(true);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// fourth step
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
@ -353,13 +351,6 @@ describe('<AdHocCommands />', () => {
);
wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
expect(
wrapper
.find('WizardNavItem[content="Machine credential"]')
.prop('isDisabled')
).toBe(true);
await act(async () => {
wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
{},
@ -423,7 +414,13 @@ describe('<AdHocCommands />', () => {
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// fourth step of wizard
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
});

View File

@ -1,26 +1,10 @@
import React, { useState } from 'react';
import React from 'react';
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 PropTypes from 'prop-types';
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);
font-weight: var(--pf-global--FontWeight--bold);
`;
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px;
color: var(--pf-global--danger-color--100);
`;
import useAdHocLaunchSteps from './useAdHocLaunchSteps';
function AdHocCommandsWizard({
onLaunch,
@ -30,100 +14,44 @@ function AdHocCommandsWizard({
credentialTypeId,
organizationId,
}) {
const [currentStepId, setCurrentStepId] = useState(1);
const [enableLaunch, setEnableLaunch] = useState(false);
const { setFieldTouched, values } = useFormikContext();
const { values, errors, touched } = useFormikContext();
const enabledNextOnDetailsStep = () => {
if (!values.module_name) {
return false;
}
if (values.module_name === 'shell' || values.module_name === 'command') {
if (values.module_args) {
return true;
// eslint-disable-next-line no-else-return
} else {
return false;
}
}
return undefined; // makes the linter happy;
};
const hasDetailsStepError = errors.module_args && touched.module_args;
const steps = [
{
id: 1,
key: 1,
name: hasDetailsStepError ? (
<AlertText>
{t`Details`}
<Tooltip
position="right"
content={t`This step contains errors`}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon />
</Tooltip>
</AlertText>
) : (
t`Details`
),
component: (
<AdHocDetailsStep
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
/>
),
enableNext: enabledNextOnDetailsStep(),
nextButtonText: t`Next`,
},
{
id: 2,
key: 2,
name: t`Execution Environment`,
component: (
<AdHocExecutionEnvironmentStep organizationId={organizationId} />
),
// Removed this line when https://github.com/patternfly/patternfly-react/issues/5729 is fixed
stepNavItemProps: { style: { whiteSpace: 'nowrap' } },
enableNext: true,
nextButtonText: t`Next`,
canJumpTo: currentStepId >= 2,
},
{
id: 3,
key: 3,
name: t`Machine credential`,
component: (
<AdHocCredentialStep
credentialTypeId={credentialTypeId}
onEnableLaunch={() => setEnableLaunch(true)}
/>
),
enableNext: enableLaunch && Object.values(errors).length === 0,
nextButtonText: t`Launch`,
canJumpTo: currentStepId >= 2,
},
];
const currentStep = steps.find(step => step.id === currentStepId);
const { steps, validateStep, visitStep, visitAllSteps } = useAdHocLaunchSteps(
moduleOptions,
verbosityOptions,
organizationId,
credentialTypeId
);
return (
<Wizard
style={{ overflow: 'scroll' }}
isOpen
onNext={step => setCurrentStepId(step.id)}
onNext={(nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setFieldTouched);
} else {
visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
}
}}
onClose={() => onCloseWizard()}
onSave={() => {
onLaunch(values);
}}
onGoToStep={(nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setFieldTouched);
} else {
visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
}
}}
steps={steps}
title={t`Run command`}
nextButtonText={currentStep.nextButtonText || undefined}
backButtonText={t`Back`}
cancelButtonText={t`Cancel`}
nextButtonText={t`Next`}
/>
);
}

View File

@ -60,50 +60,29 @@ describe('<AdHocCommandsWizard/>', () => {
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
});
test('next and nav item should be disabled', async () => {
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
expect(
wrapper.find('WizardNavItem[content="Details"]').prop('isCurrent')
).toBe(true);
expect(
wrapper.find('WizardNavItem[content="Details"]').prop('isDisabled')
).toBe(false);
expect(
wrapper
.find('WizardNavItem[content="Machine credential"]')
.prop('isDisabled')
).toBe(true);
expect(
wrapper
.find('WizardNavItem[content="Machine credential"]')
.prop('isCurrent')
).toBe(false);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
});
test('launch button should be disabled', async () => {
waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
test('next button should become active, and should navigate to the next step', 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: 'foo', name: 'module_args' },
});
wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
});
wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false
);
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false
);
wrapper.update();
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false
);
wrapper.update();
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
wrapper.update();
expect(wrapper.find('AdHocPreviewStep').prop('hasErrors')).toBe(true);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
});
test('launch button should become active', async () => {
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
@ -184,7 +163,7 @@ describe('<AdHocCommandsWizard/>', () => {
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('td#check-action-item-1')
@ -197,13 +176,17 @@ describe('<AdHocCommandsWizard/>', () => {
expect(
wrapper.find('CheckboxListItem[label="Cred 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();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false
);
expect(onLaunch).toHaveBeenCalledWith({
become_enabled: '',

View File

@ -1,10 +1,10 @@
import React, { useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { useField } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core';
import { Form, FormGroup, Alert } from '@patternfly/react-core';
import { CredentialsAPI } from 'api';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
import useRequest from 'hooks/useRequest';
@ -15,13 +15,17 @@ import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import OptionsList from '../OptionsList';
const CredentialErrorAlert = styled(Alert)`
margin-bottom: 20px;
`;
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
page_size: 5,
order_by: 'name',
});
function AdHocCredentialStep({ credentialTypeId, onEnableLaunch }) {
function AdHocCredentialStep({ credentialTypeId }) {
const history = useHistory();
const {
error,
@ -72,10 +76,11 @@ function AdHocCredentialStep({ credentialTypeId, onEnableLaunch }) {
fetchCredentials();
}, [fetchCredentials]);
const [credentialField, credentialMeta, credentialHelpers] = useField({
const [field, meta, helpers] = useField({
name: 'credential',
validate: required(null),
});
if (error) {
return <ContentError error={error} />;
}
@ -83,68 +88,69 @@ function AdHocCredentialStep({ credentialTypeId, onEnableLaunch }) {
return <ContentLoading />;
}
return (
<Form>
<FormGroup
fieldId="credential"
label={t`Machine Credential`}
aria-label={t`Machine Credential`}
isRequired
validated={
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'
}
helperTextInvalid={credentialMeta.error}
labelIcon={
<Popover
content={t`Select the credential you want to use when accessing the remote hosts to run the command. Choose the credential containing the username and SSH key or password that Ansible will need to log into the remote hosts.`}
<>
{meta.touched && meta.error && (
<CredentialErrorAlert variant="danger" isInline title={meta.error} />
)}
<Form>
<FormGroup
fieldId="credential"
label={t`Machine Credential`}
aria-label={t`Machine Credential`}
isRequired
validated={!meta.touched || !meta.error ? 'default' : 'error'}
helperTextInvalid={meta.error}
labelIcon={
<Popover
content={t`Select the credential you want to use when accessing the remote hosts to run the command. Choose the credential containing the username and SSH key or password that Ansible will need to log into the remote hosts.`}
/>
}
>
<OptionsList
value={field.value || []}
options={credentials}
optionCount={credentialCount}
header={t`Machine Credential`}
readOnly
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="credential"
selectItem={value => {
helpers.setValue([value]);
}}
deselectItem={() => {
helpers.setValue([]);
}}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
/>
}
>
<OptionsList
value={credentialField.value || []}
options={credentials}
optionCount={credentialCount}
header={t`Machine Credential`}
readOnly
qsConfig={QS_CONFIG}
relatedSearchableKeys={relatedSearchableKeys}
searchableKeys={searchableKeys}
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="credential"
selectItem={value => {
credentialHelpers.setValue([value]);
onEnableLaunch();
}}
deselectItem={() => {
credentialHelpers.setValue([]);
}}
/>
</FormGroup>
</Form>
</FormGroup>
</Form>
</>
);
}
AdHocCredentialStep.propTypes = {
credentialTypeId: PropTypes.number.isRequired,
onEnableLaunch: PropTypes.func.isRequired,
};
export default AdHocCredentialStep;

View File

@ -1,6 +1,5 @@
/* eslint-disable react/no-unescaped-entities */
import React from 'react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { useField } from 'formik';
@ -41,12 +40,14 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
const argumentsRequired =
moduleNameField.value === 'command' || moduleNameField.value === 'shell';
const [, argumentsMeta, argumentsHelpers] = useField({
const [argumentsField, argumentsMeta, argumentsHelpers] = useField({
name: 'module_args',
validate: argumentsRequired && required(null),
});
const isValid = !argumentsMeta.error || !argumentsMeta.touched;
const isValid = argumentsRequired
? (!argumentsMeta.error || !argumentsMeta.touched) && argumentsField.value
: true;
return (
<Form>
@ -103,10 +104,7 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
label={t`Arguments`}
validated={isValid ? 'default' : 'error'}
onBlur={() => argumentsHelpers.setTouched(true)}
isRequired={
moduleNameField.value === 'command' ||
moduleNameField.value === 'shell'
}
isRequired={argumentsRequired}
tooltip={
moduleNameField.value ? (
<>
@ -249,7 +247,6 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
</FormColumnLayout>
<VariablesField
css="margin: 20px 0"
id="extra_vars"
name="extra_vars"
value={JSON.stringify(variablesField.value)}

View File

@ -0,0 +1,72 @@
import React from 'react';
import { t } from '@lingui/macro';
import { Tooltip } from '@patternfly/react-core';
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { toTitleCase } from '../../util/strings';
import { VariablesDetail } from '../CodeEditor';
import { jsonToYaml } from '../../util/yaml';
import { DetailList, Detail } from '../DetailList';
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px;
margin-top: -2px;
`;
const ErrorMessageWrapper = styled.div`
align-items: center;
color: var(--pf-global--danger-color--200);
display: flex;
font-weight: var(--pf-global--FontWeight--bold);
margin-bottom: 10px;
`;
function AdHocPreviewStep({ hasErrors, values }) {
const { credential, execution_environment, extra_vars } = values;
const items = Object.entries(values);
return (
<>
{hasErrors && (
<ErrorMessageWrapper>
{t`Some of the previous step(s) have errors`}
<Tooltip
position="right"
content={t`See errors on the left`}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon />
</Tooltip>
</ErrorMessageWrapper>
)}
<DetailList gutter="sm">
{items.map(
([key, value]) =>
key !== 'extra_vars' &&
key !== 'execution_environment' &&
key !== 'credential' && (
<Detail key={key} label={toTitleCase(key)} value={value} />
)
)}
{credential && (
<Detail label={t`Credential`} value={credential[0]?.name} />
)}
{execution_environment && (
<Detail
label={t`Execution Environment`}
value={execution_environment[0]?.name}
/>
)}
{extra_vars && (
<VariablesDetail
value={jsonToYaml(JSON.stringify(extra_vars))}
rows={4}
label={t`Variables`}
name="extra_vars"
/>
)}
</DetailList>
</>
);
}
export default AdHocPreviewStep;

View File

@ -0,0 +1,41 @@
import React from 'react';
import { useField } from 'formik';
import { t } from '@lingui/macro';
import StepName from '../LaunchPrompt/steps/StepName';
import AdHocCredentialStep from './AdHocCredentialStep';
const STEP_ID = 'credential';
export default function useAdHocExecutionEnvironmentStep(
visited,
credentialTypeId
) {
const [field, meta, helpers] = useField('credential');
const hasError =
Object.keys(visited).includes('credential') &&
!field.value.length &&
meta.touched;
return {
step: {
id: STEP_ID,
key: 3,
name: (
<StepName hasErrors={hasError} id="credential-step">
{t`Credential`}
</StepName>
),
component: <AdHocCredentialStep credentialTypeId={credentialTypeId} />,
enableNext: true,
nextButtonText: t`Next`,
},
hasError,
validate: () => {
if (!meta.value.length) {
helpers.setError('A credential must be selected');
}
},
setTouched: setFieldTouched => {
setFieldTouched('credential', true, false);
},
};
}

View File

@ -0,0 +1,70 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import StepName from '../LaunchPrompt/steps/StepName';
import AdHocDetailsStep from './AdHocDetailsStep';
const STEP_ID = 'details';
export default function useAdHocDetailsStep(
visited,
moduleOptions,
verbosityOptions
) {
const { values, touched, setFieldError } = useFormikContext();
const hasError = () => {
if (!Object.keys(visited).includes(STEP_ID)) {
return false;
}
if (!values.module_name && touched.module_name) {
return true;
}
if (values.module_name === 'shell' || values.module_name === 'command') {
if (values.module_args) {
return false;
// eslint-disable-next-line no-else-return
} else {
return true;
}
}
return false;
};
return {
step: {
id: STEP_ID,
key: 1,
name: (
<StepName hasErrors={hasError()} id="details-step">
{t`Details`}
</StepName>
),
component: (
<AdHocDetailsStep
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
/>
),
enableNext: true,
nextButtonText: t`Next`,
},
hasError: hasError(),
validate: () => {
if (Object.keys(touched).includes('module_name' || 'module_args')) {
if (!values.module_name) {
setFieldError('module_name', t`This field is must not be blank.`);
}
if (
values.module_name === ('command' || 'shell') &&
!values.module_args
) {
setFieldError('module_args', t`This field is must not be blank`);
}
}
},
setTouched: setFieldTouched => {
setFieldTouched('module_name', true, false);
setFieldTouched('module_args', true, false);
},
};
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import { t } from '@lingui/macro';
import StepName from '../LaunchPrompt/steps/StepName';
import AdHocExecutionEnvironmentStep from './AdHocExecutionEnvironmentStep';
const STEP_ID = 'executionEnvironment';
export default function useAdHocExecutionEnvironmentStep(organizationId) {
return {
step: {
id: STEP_ID,
key: 2,
stepNavItemProps: { style: { whiteSpace: 'nowrap' } },
name: (
<StepName hasErrors={false} id="executionEnvironment-step">
{t`Execution Environment`}
</StepName>
),
component: (
<AdHocExecutionEnvironmentStep organizationId={organizationId} />
),
enableNext: true,
nextButtonText: t`Next`,
},
hasError: false,
validate: () => {},
setTouched: () => {},
};
}

View File

@ -0,0 +1,48 @@
import { useState } from 'react';
import useAdHocDetailsStep from './useAdHocDetailsStep';
import useAdHocExecutionEnvironmentStep from './useAdHocExecutionEnvironmentStep';
import useAdHocCredentialStep from './useAdHocCredentialStep';
import useAdHocPreviewStep from './useAdHocPreviewStep';
export default function useAdHocLaunchSteps(
moduleOptions,
verbosityOptions,
organizationId,
credentialTypeId
) {
const [visited, setVisited] = useState({});
const steps = [
useAdHocDetailsStep(visited, moduleOptions, verbosityOptions),
useAdHocExecutionEnvironmentStep(organizationId),
useAdHocCredentialStep(visited, credentialTypeId),
];
const hasErrors = steps.some(step => step.hasError);
steps.push(useAdHocPreviewStep(hasErrors));
return {
steps: steps.map(s => s.step).filter(s => s != null),
validateStep: stepId =>
steps
.find(s => {
return s?.step.id === stepId;
})
.validate(),
visitStep: (prevStepId, setFieldTouched) => {
setVisited({
...visited,
[prevStepId]: true,
});
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
},
visitAllSteps: setFieldTouched => {
setVisited({
details: true,
executionEnvironment: true,
credential: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldTouched));
},
};
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import StepName from '../LaunchPrompt/steps/StepName';
import AdHocPreviewStep from './AdHocPreviewStep';
const STEP_ID = 'preview';
export default function useAdHocPreviewStep(hasErrors) {
const { values } = useFormikContext();
return {
step: {
id: STEP_ID,
key: 4,
name: (
<StepName hasErrors={false} id="preview-step">
{t`Preview`}
</StepName>
),
component: <AdHocPreviewStep hasErrors={hasErrors} values={values} />,
enableNext: !hasErrors,
nextButtonText: t`Launch`,
},
hasErrors: false,
validate: () => {},
setTouched: () => {},
};
}