Merge pull request #8399 from AlexSCorey/5913-WFNODEPOL

Add WF Node Promptability

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-12-16 17:36:40 +00:00
committed by GitHub
54 changed files with 2844 additions and 1309 deletions

View File

@@ -55,6 +55,19 @@ class WorkflowJobTemplateNodes extends Base {
readCredentials(id) { readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`); return this.http.get(`${this.baseUrl}${id}/credentials/`);
} }
associateCredentials(id, credentialId) {
return this.http.post(`${this.baseUrl}${id}/credentials/`, {
id: credentialId,
});
}
disassociateCredentials(id, credentialId) {
return this.http.post(`${this.baseUrl}${id}/credentials/`, {
id: credentialId,
disassociate: true,
});
}
} }
export default WorkflowJobTemplateNodes; export default WorkflowJobTemplateNodes;

View File

@@ -44,6 +44,7 @@ class LaunchButton extends React.Component {
showLaunchPrompt: false, showLaunchPrompt: false,
launchConfig: null, launchConfig: null,
launchError: false, launchError: false,
surveyConfig: null,
}; };
this.handleLaunch = this.handleLaunch.bind(this); this.handleLaunch = this.handleLaunch.bind(this);
@@ -67,15 +68,28 @@ class LaunchButton extends React.Component {
resource.type === 'workflow_job_template' resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(resource.id) ? WorkflowJobTemplatesAPI.readLaunch(resource.id)
: JobTemplatesAPI.readLaunch(resource.id); : JobTemplatesAPI.readLaunch(resource.id);
const readSurvey =
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readSurvey(resource.id)
: JobTemplatesAPI.readSurvey(resource.id);
try { try {
const { data: launchConfig } = await readLaunch; const { data: launchConfig } = await readLaunch;
let surveyConfig = null;
if (launchConfig.survey_enabled) {
const { data } = await readSurvey;
surveyConfig = data;
}
if (canLaunchWithoutPrompt(launchConfig)) { if (canLaunchWithoutPrompt(launchConfig)) {
this.launchWithParams({}); this.launchWithParams({});
} else { } else {
this.setState({ this.setState({
showLaunchPrompt: true, showLaunchPrompt: true,
launchConfig, launchConfig,
surveyConfig,
}); });
} }
} catch (err) { } catch (err) {
@@ -151,7 +165,12 @@ class LaunchButton extends React.Component {
} }
render() { render() {
const { launchError, showLaunchPrompt, launchConfig } = this.state; const {
launchError,
showLaunchPrompt,
launchConfig,
surveyConfig,
} = this.state;
const { resource, i18n, children } = this.props; const { resource, i18n, children } = this.props;
return ( return (
<Fragment> <Fragment>
@@ -172,7 +191,8 @@ class LaunchButton extends React.Component {
)} )}
{showLaunchPrompt && ( {showLaunchPrompt && (
<LaunchPrompt <LaunchPrompt
config={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig}
resource={resource} resource={resource}
onLaunch={this.launchWithParams} onLaunch={this.launchWithParams}
onCancel={() => this.setState({ showLaunchPrompt: false })} onCancel={() => this.setState({ showLaunchPrompt: false })}

View File

@@ -6,12 +6,19 @@ import { Formik, useFormikContext } from 'formik';
import ContentError from '../ContentError'; import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import { useDismissableError } from '../../util/useRequest'; import { useDismissableError } from '../../util/useRequest';
import mergeExtraVars from './mergeExtraVars'; import mergeExtraVars from '../../util/prompt/mergeExtraVars';
import getSurveyValues from '../../util/prompt/getSurveyValues';
import useLaunchSteps from './useLaunchSteps'; import useLaunchSteps from './useLaunchSteps';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import getSurveyValues from './getSurveyValues';
function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) { function PromptModalForm({
launchConfig,
i18n,
onCancel,
onSubmit,
resource,
surveyConfig,
}) {
const { values, setTouched, validateForm } = useFormikContext(); const { values, setTouched, validateForm } = useFormikContext();
const { const {
@@ -20,7 +27,7 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
visitStep, visitStep,
visitAllSteps, visitAllSteps,
contentError, contentError,
} = useLaunchSteps(config, resource, i18n); } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
const handleSave = () => { const handleSave = () => {
const postValues = {}; const postValues = {};
@@ -39,7 +46,7 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
setValue('limit', values.limit); setValue('limit', values.limit);
setValue('job_tags', values.job_tags); setValue('job_tags', values.job_tags);
setValue('skip_tags', values.skip_tags); setValue('skip_tags', values.skip_tags);
const extraVars = config.ask_variables_on_launch const extraVars = launchConfig.ask_variables_on_launch
? values.extra_vars || '---' ? values.extra_vars || '---'
: resource.extra_vars; : resource.extra_vars;
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues)); setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
@@ -103,28 +110,22 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
); );
} }
function LaunchPrompt({ config, resource = {}, onLaunch, onCancel, i18n }) { function LaunchPrompt({
launchConfig,
i18n,
onCancel,
onLaunch,
resource = {},
surveyConfig,
}) {
return ( return (
<Formik <Formik initialValues={{}} onSubmit={values => onLaunch(values)}>
initialValues={{
verbosity: resource.verbosity || 0,
inventory: resource.summary_fields?.inventory || null,
credentials: resource.summary_fields?.credentials || null,
diff_mode: resource.diff_mode || false,
extra_vars: resource.extra_vars || '---',
job_type: resource.job_type || '',
job_tags: resource.job_tags || '',
skip_tags: resource.skip_tags || '',
scm_branch: resource.scm_branch || '',
limit: resource.limit || '',
}}
onSubmit={values => onLaunch(values)}
>
<PromptModalForm <PromptModalForm
onSubmit={values => onLaunch(values)} onSubmit={values => onLaunch(values)}
onCancel={onCancel} onCancel={onCancel}
i18n={i18n} i18n={i18n}
config={config} launchConfig={launchConfig}
surveyConfig={surveyConfig}
resource={resource} resource={resource}
/> />
</Formik> </Formik>

View File

@@ -76,7 +76,7 @@ describe('LaunchPrompt', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<LaunchPrompt <LaunchPrompt
config={{ launchConfig={{
...config, ...config,
ask_inventory_on_launch: true, ask_inventory_on_launch: true,
ask_credential_on_launch: true, ask_credential_on_launch: true,
@@ -86,6 +86,24 @@ describe('LaunchPrompt', () => {
resource={resource} resource={resource}
onLaunch={noop} onLaunch={noop}
onCancel={noop} onCancel={noop}
surveyConfig={{
name: '',
description: '',
spec: [
{
choices: '',
default: '',
max: 1024,
min: 0,
new_question: false,
question_description: '',
question_name: 'foo',
required: true,
type: 'text',
variable: 'foo',
},
],
}}
/> />
); );
}); });
@@ -94,10 +112,10 @@ describe('LaunchPrompt', () => {
expect(steps).toHaveLength(5); expect(steps).toHaveLength(5);
expect(steps[0].name.props.children).toEqual('Inventory'); expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name).toEqual('Credentials'); expect(steps[1].name.props.children).toEqual('Credentials');
expect(steps[2].name).toEqual('Other Prompts'); expect(steps[2].name.props.children).toEqual('Other prompts');
expect(steps[3].name.props.children).toEqual('Survey'); expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name).toEqual('Preview'); expect(steps[4].name.props.children).toEqual('Preview');
}); });
test('should add inventory step', async () => { test('should add inventory step', async () => {
@@ -105,7 +123,7 @@ describe('LaunchPrompt', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<LaunchPrompt <LaunchPrompt
config={{ launchConfig={{
...config, ...config,
ask_inventory_on_launch: true, ask_inventory_on_launch: true,
}} }}
@@ -129,7 +147,7 @@ describe('LaunchPrompt', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<LaunchPrompt <LaunchPrompt
config={{ launchConfig={{
...config, ...config,
ask_credential_on_launch: true, ask_credential_on_launch: true,
}} }}
@@ -143,7 +161,7 @@ describe('LaunchPrompt', () => {
const steps = wizard.prop('steps'); const steps = wizard.prop('steps');
expect(steps).toHaveLength(2); expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Credentials'); expect(steps[0].name.props.children).toEqual('Credentials');
expect(isElementOfType(steps[0].component, CredentialsStep)).toEqual(true); expect(isElementOfType(steps[0].component, CredentialsStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
}); });
@@ -153,7 +171,7 @@ describe('LaunchPrompt', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<LaunchPrompt <LaunchPrompt
config={{ launchConfig={{
...config, ...config,
ask_verbosity_on_launch: true, ask_verbosity_on_launch: true,
}} }}
@@ -167,7 +185,7 @@ describe('LaunchPrompt', () => {
const steps = wizard.prop('steps'); const steps = wizard.prop('steps');
expect(steps).toHaveLength(2); expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Other Prompts'); expect(steps[0].name.props.children).toEqual('Other prompts');
expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true); expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
}); });

View File

@@ -20,11 +20,11 @@ const FieldHeader = styled.div`
} }
`; `;
function OtherPromptsStep({ config, i18n }) { function OtherPromptsStep({ launchConfig, i18n }) {
return ( return (
<Form> <Form>
{config.ask_job_type_on_launch && <JobTypeField i18n={i18n} />} {launchConfig.ask_job_type_on_launch && <JobTypeField i18n={i18n} />}
{config.ask_limit_on_launch && ( {launchConfig.ask_limit_on_launch && (
<FormField <FormField
id="prompt-limit" id="prompt-limit"
name="limit" name="limit"
@@ -35,7 +35,7 @@ function OtherPromptsStep({ config, i18n }) {
information and examples on patterns.`)} information and examples on patterns.`)}
/> />
)} )}
{config.ask_scm_branch_on_launch && ( {launchConfig.ask_scm_branch_on_launch && (
<FormField <FormField
id="prompt-scm-branch" id="prompt-scm-branch"
name="scm_branch" name="scm_branch"
@@ -45,9 +45,11 @@ function OtherPromptsStep({ config, i18n }) {
)} )}
/> />
)} )}
{config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />} {launchConfig.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />}
{config.ask_diff_mode_on_launch && <ShowChangesToggle i18n={i18n} />} {launchConfig.ask_diff_mode_on_launch && (
{config.ask_tags_on_launch && ( <ShowChangesToggle i18n={i18n} />
)}
{launchConfig.ask_tags_on_launch && (
<TagField <TagField
id="prompt-job-tags" id="prompt-job-tags"
name="job_tags" name="job_tags"
@@ -59,7 +61,7 @@ function OtherPromptsStep({ config, i18n }) {
documentation for details on the usage of tags.`)} documentation for details on the usage of tags.`)}
/> />
)} )}
{config.ask_skip_tags_on_launch && ( {launchConfig.ask_skip_tags_on_launch && (
<TagField <TagField
id="prompt-skip-tags" id="prompt-skip-tags"
name="skip_tags" name="skip_tags"
@@ -71,7 +73,7 @@ function OtherPromptsStep({ config, i18n }) {
documentation for details on the usage of tags.`)} documentation for details on the usage of tags.`)}
/> />
)} )}
{config.ask_variables_on_launch && ( {launchConfig.ask_variables_on_launch && (
<VariablesField <VariablesField
id="prompt-variables" id="prompt-variables"
name="extra_vars" name="extra_vars"

View File

@@ -11,7 +11,7 @@ describe('OtherPromptsStep', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik initialValues={{ job_type: 'run' }}> <Formik initialValues={{ job_type: 'run' }}>
<OtherPromptsStep <OtherPromptsStep
config={{ launchConfig={{
ask_job_type_on_launch: true, ask_job_type_on_launch: true,
}} }}
/> />
@@ -34,7 +34,7 @@ describe('OtherPromptsStep', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik> <Formik>
<OtherPromptsStep <OtherPromptsStep
config={{ launchConfig={{
ask_limit_on_launch: true, ask_limit_on_launch: true,
}} }}
/> />
@@ -54,7 +54,7 @@ describe('OtherPromptsStep', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik> <Formik>
<OtherPromptsStep <OtherPromptsStep
config={{ launchConfig={{
ask_scm_branch_on_launch: true, ask_scm_branch_on_launch: true,
}} }}
/> />
@@ -74,7 +74,7 @@ describe('OtherPromptsStep', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik initialValues={{ verbosity: '' }}> <Formik initialValues={{ verbosity: '' }}>
<OtherPromptsStep <OtherPromptsStep
config={{ launchConfig={{
ask_verbosity_on_launch: true, ask_verbosity_on_launch: true,
}} }}
/> />
@@ -94,7 +94,7 @@ describe('OtherPromptsStep', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik initialValues={{ diff_mode: true }}> <Formik initialValues={{ diff_mode: true }}>
<OtherPromptsStep <OtherPromptsStep
config={{ launchConfig={{
ask_diff_mode_on_launch: true, ask_diff_mode_on_launch: true,
}} }}
/> />

View File

@@ -6,8 +6,10 @@ import { t } from '@lingui/macro';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import mergeExtraVars, { maskPasswords } from '../mergeExtraVars'; import mergeExtraVars, {
import getSurveyValues from '../getSurveyValues'; maskPasswords,
} from '../../../util/prompt/mergeExtraVars';
import getSurveyValues from '../../../util/prompt/getSurveyValues';
import PromptDetail from '../../PromptDetail'; import PromptDetail from '../../PromptDetail';
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
@@ -23,18 +25,25 @@ const ErrorMessageWrapper = styled.div`
margin-bottom: 10px; margin-bottom: 10px;
`; `;
function PreviewStep({ resource, config, survey, formErrors, i18n }) { function PreviewStep({
resource,
launchConfig,
surveyConfig,
formErrors,
i18n,
}) {
const { values } = useFormikContext(); const { values } = useFormikContext();
const surveyValues = getSurveyValues(values); const surveyValues = getSurveyValues(values);
const overrides = { ...values }; const overrides = {
...values,
};
if (config.ask_variables_on_launch || config.survey_enabled) { if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) {
const initialExtraVars = config.ask_variables_on_launch const initialExtraVars =
? values.extra_vars || '---' launchConfig.ask_variables_on_launch && (overrides.extra_vars || '---');
: resource.extra_vars; if (surveyConfig?.spec) {
if (survey && survey.spec) { const passwordFields = surveyConfig.spec
const passwordFields = survey.spec
.filter(q => q.type === 'password') .filter(q => q.type === 'password')
.map(q => q.variable); .map(q => q.variable);
const masked = maskPasswords(surveyValues, passwordFields); const masked = maskPasswords(surveyValues, passwordFields);
@@ -42,7 +51,9 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
mergeExtraVars(initialExtraVars, masked) mergeExtraVars(initialExtraVars, masked)
); );
} else { } else {
overrides.extra_vars = initialExtraVars; overrides.extra_vars = yaml.safeDump(
mergeExtraVars(initialExtraVars, {})
);
} }
} }
@@ -62,7 +73,7 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
)} )}
<PromptDetail <PromptDetail
resource={resource} resource={resource}
launchConfig={config} launchConfig={launchConfig}
overrides={overrides} overrides={overrides}
/> />
</Fragment> </Fragment>

View File

@@ -36,11 +36,11 @@ describe('PreviewStep', () => {
<Formik initialValues={{ limit: '4', survey_foo: 'abc' }}> <Formik initialValues={{ limit: '4', survey_foo: 'abc' }}>
<PreviewStep <PreviewStep
resource={resource} resource={resource}
config={{ launchConfig={{
ask_limit_on_launch: true, ask_limit_on_launch: true,
survey_enabled: true, survey_enabled: true,
}} }}
survey={survey} surveyConfig={survey}
formErrors={formErrors} formErrors={formErrors}
/> />
</Formik> </Formik>
@@ -64,7 +64,7 @@ describe('PreviewStep', () => {
<Formik initialValues={{ limit: '4' }}> <Formik initialValues={{ limit: '4' }}>
<PreviewStep <PreviewStep
resource={resource} resource={resource}
config={{ launchConfig={{
ask_limit_on_launch: true, ask_limit_on_launch: true,
}} }}
formErrors={formErrors} formErrors={formErrors}
@@ -80,7 +80,32 @@ describe('PreviewStep', () => {
limit: '4', limit: '4',
}); });
}); });
test('should handle extra vars with survey', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{ extra_vars: 'one: 1', survey_foo: 'abc' }}>
<PreviewStep
resource={resource}
launchConfig={{
ask_variables_on_launch: true,
survey_enabled: true,
}}
surveyConfig={survey}
formErrors={formErrors}
/>
</Formik>
);
});
const detail = wrapper.find('PromptDetail');
expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({
extra_vars: 'one: 1\nfoo: abc\n',
survey_foo: 'abc',
});
});
test('should handle extra vars without survey', async () => { test('should handle extra vars without survey', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
@@ -88,7 +113,7 @@ describe('PreviewStep', () => {
<Formik initialValues={{ extra_vars: 'one: 1' }}> <Formik initialValues={{ extra_vars: 'one: 1' }}>
<PreviewStep <PreviewStep
resource={resource} resource={resource}
config={{ launchConfig={{
ask_variables_on_launch: true, ask_variables_on_launch: true,
}} }}
formErrors={formErrors} formErrors={formErrors}
@@ -101,10 +126,9 @@ describe('PreviewStep', () => {
expect(detail).toHaveLength(1); expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({ expect(detail.prop('overrides')).toEqual({
extra_vars: 'one: 1', extra_vars: 'one: 1\n',
}); });
}); });
test('should remove survey with empty array value', async () => { test('should remove survey with empty array value', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
@@ -115,7 +139,7 @@ describe('PreviewStep', () => {
> >
<PreviewStep <PreviewStep
resource={resource} resource={resource}
config={{ launchConfig={{
ask_variables_on_launch: true, ask_variables_on_launch: true,
}} }}
formErrors={formErrors} formErrors={formErrors}
@@ -128,7 +152,7 @@ describe('PreviewStep', () => {
expect(detail).toHaveLength(1); expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({ expect(detail.prop('overrides')).toEqual({
extra_vars: 'one: 1', extra_vars: 'one: 1\n',
}); });
}); });
}); });

View File

@@ -14,13 +14,13 @@ const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px; margin-left: 10px;
`; `;
function StepName({ hasErrors, children, i18n }) { function StepName({ hasErrors, children, i18n, id }) {
if (!hasErrors) { if (!hasErrors) {
return children; return <div id={id}>{children}</div>;
} }
return ( return (
<> <>
<AlertText> <AlertText id={id}>
{children} {children}
<Tooltip <Tooltip
position="right" position="right"

View File

@@ -22,7 +22,7 @@ import {
} from '../../../util/validators'; } from '../../../util/validators';
import { Survey } from '../../../types'; import { Survey } from '../../../types';
function SurveyStep({ survey, i18n }) { function SurveyStep({ surveyConfig, i18n }) {
const fieldTypes = { const fieldTypes = {
text: TextField, text: TextField,
textarea: TextField, textarea: TextField,
@@ -34,7 +34,7 @@ function SurveyStep({ survey, i18n }) {
}; };
return ( return (
<Form> <Form>
{survey.spec.map(question => { {surveyConfig.spec.map(question => {
const Field = fieldTypes[question.type]; const Field = fieldTypes[question.type];
return ( return (
<Field key={question.variable} question={question} i18n={i18n} /> <Field key={question.variable} question={question} i18n={i18n} />
@@ -44,7 +44,7 @@ function SurveyStep({ survey, i18n }) {
); );
} }
SurveyStep.propTypes = { SurveyStep.propTypes = {
survey: Survey.isRequired, surveyConfig: Survey.isRequired,
}; };
function TextField({ question, i18n }) { function TextField({ question, i18n }) {
@@ -130,7 +130,8 @@ function MultiSelectField({ question, i18n }) {
<FormGroup <FormGroup
fieldId={id} fieldId={id}
helperTextInvalid={ helperTextInvalid={
meta.error || i18n._(t`Must select a value for this field.`) meta.error ||
i18n._(t`At least one value must be selected for this field.`)
} }
isRequired={question.required} isRequired={question.required}
validated={isValid ? 'default' : 'error'} validated={isValid ? 'default' : 'error'}

View File

@@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import CredentialsStep from './CredentialsStep'; import CredentialsStep from './CredentialsStep';
import StepName from './StepName';
const STEP_ID = 'credentials'; const STEP_ID = 'credentials';
export default function useCredentialsStep(config, i18n) { export default function useCredentialsStep(launchConfig, resource, i18n) {
return { return {
step: getStep(config, i18n), step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
validate: () => ({}),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: null, formError: null,
@@ -18,13 +21,29 @@ export default function useCredentialsStep(config, i18n) {
}; };
} }
function getStep(config, i18n) { function getStep(launchConfig, i18n) {
if (!config.ask_credential_on_launch) { if (!launchConfig.ask_credential_on_launch) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
name: i18n._(t`Credentials`), key: 4,
name: (
<StepName hasErrors={false} id="credentials-step">
{i18n._(t`Credentials`)}
</StepName>
),
component: <CredentialsStep i18n={i18n} />, component: <CredentialsStep i18n={i18n} />,
enableNext: true,
};
}
function getInitialValues(launchConfig, resource) {
if (!launchConfig.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || [],
}; };
} }

View File

@@ -6,14 +6,22 @@ import StepName from './StepName';
const STEP_ID = 'inventory'; const STEP_ID = 'inventory';
export default function useInventoryStep(config, visitedSteps, i18n) { export default function useInventoryStep(
launchConfig,
resource,
i18n,
visitedSteps
) {
const [, meta] = useField('inventory'); const [, meta] = useField('inventory');
const formError =
Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error);
return { return {
step: getStep(config, meta, i18n, visitedSteps), step: getStep(launchConfig, i18n, formError),
initialValues: getInitialValues(launchConfig, resource),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: !meta.value, formError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldsTouched => { setTouched: setFieldsTouched => {
setFieldsTouched({ setFieldsTouched({
inventory: true, inventory: true,
@@ -21,20 +29,14 @@ export default function useInventoryStep(config, visitedSteps, i18n) {
}, },
}; };
} }
function getStep(config, meta, i18n, visitedSteps) { function getStep(launchConfig, i18n, formError) {
if (!config.ask_inventory_on_launch) { if (!launchConfig.ask_inventory_on_launch) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
key: 3,
name: ( name: (
<StepName <StepName hasErrors={formError} id="inventory-step">
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
(!meta.value || meta.error)
}
>
{i18n._(t`Inventory`)} {i18n._(t`Inventory`)}
</StepName> </StepName>
), ),
@@ -42,3 +44,13 @@ function getStep(config, meta, i18n, visitedSteps) {
enableNext: true, enableNext: true,
}; };
} }
function getInitialValues(launchConfig, resource) {
if (!launchConfig.ask_inventory_on_launch) {
return {};
}
return {
inventory: resource?.summary_fields?.inventory || null,
};
}

View File

@@ -1,12 +1,25 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { jsonToYaml, parseVariableField } from '../../../util/yaml';
import OtherPromptsStep from './OtherPromptsStep'; import OtherPromptsStep from './OtherPromptsStep';
import StepName from './StepName';
const STEP_ID = 'other'; const STEP_ID = 'other';
export default function useOtherPrompt(config, i18n) { const getVariablesData = resource => {
if (resource?.extra_data) {
return jsonToYaml(JSON.stringify(resource.extra_data));
}
if (resource?.extra_vars && resource?.extra_vars !== '---') {
return jsonToYaml(JSON.stringify(parseVariableField(resource.extra_vars)));
}
return '---';
};
export default function useOtherPromptsStep(launchConfig, resource, i18n) {
return { return {
step: getStep(config, i18n), step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: null, formError: null,
@@ -24,26 +37,66 @@ export default function useOtherPrompt(config, i18n) {
}; };
} }
function getStep(config, i18n) { function getStep(launchConfig, i18n) {
if (!shouldShowPrompt(config)) { if (!shouldShowPrompt(launchConfig)) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
name: i18n._(t`Other Prompts`), key: 5,
component: <OtherPromptsStep config={config} i18n={i18n} />, name: (
<StepName hasErrors={false} id="other-prompts-step">
{i18n._(t`Other prompts`)}
</StepName>
),
component: <OtherPromptsStep launchConfig={launchConfig} i18n={i18n} />,
enableNext: true,
}; };
} }
function shouldShowPrompt(config) { function shouldShowPrompt(launchConfig) {
return ( return (
config.ask_job_type_on_launch || launchConfig.ask_job_type_on_launch ||
config.ask_limit_on_launch || launchConfig.ask_limit_on_launch ||
config.ask_verbosity_on_launch || launchConfig.ask_verbosity_on_launch ||
config.ask_tags_on_launch || launchConfig.ask_tags_on_launch ||
config.ask_skip_tags_on_launch || launchConfig.ask_skip_tags_on_launch ||
config.ask_variables_on_launch || launchConfig.ask_variables_on_launch ||
config.ask_scm_branch_on_launch || launchConfig.ask_scm_branch_on_launch ||
config.ask_diff_mode_on_launch launchConfig.ask_diff_mode_on_launch
); );
} }
function getInitialValues(launchConfig, resource) {
const initialValues = {};
if (!launchConfig) {
return initialValues;
}
if (launchConfig.ask_job_type_on_launch) {
initialValues.job_type = resource?.job_type || '';
}
if (launchConfig.ask_limit_on_launch) {
initialValues.limit = resource?.limit || '';
}
if (launchConfig.ask_verbosity_on_launch) {
initialValues.verbosity = resource?.verbosity || 0;
}
if (launchConfig.ask_tags_on_launch) {
initialValues.job_tags = resource?.job_tags || '';
}
if (launchConfig.ask_skip_tags_on_launch) {
initialValues.skip_tags = resource?.skip_tags || '';
}
if (launchConfig.ask_variables_on_launch) {
initialValues.extra_vars = getVariablesData(resource);
}
if (launchConfig.ask_scm_branch_on_launch) {
initialValues.scm_branch = resource?.scm_branch || '';
}
if (launchConfig.ask_diff_mode_on_launch) {
initialValues.diff_mode = resource?.diff_mode || false;
}
return initialValues;
}

View File

@@ -1,53 +1,41 @@
import React from 'react'; import React from 'react';
import { useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PreviewStep from './PreviewStep'; import PreviewStep from './PreviewStep';
import StepName from './StepName';
const STEP_ID = 'preview'; const STEP_ID = 'preview';
export default function usePreviewStep( export default function usePreviewStep(
config, launchConfig,
i18n,
resource, resource,
survey, surveyConfig,
hasErrors, hasErrors,
i18n showStep
) { ) {
const { values: formikValues, errors } = useFormikContext();
const formErrorsContent = [];
if (config.ask_inventory_on_launch && !formikValues.inventory) {
formErrorsContent.push({
inventory: true,
});
}
const hasSurveyError = Object.keys(errors).find(e => e.includes('survey'));
if (
config.survey_enabled &&
(config.variables_needed_to_start ||
config.variables_needed_to_start.length === 0) &&
hasSurveyError
) {
formErrorsContent.push({
survey: true,
});
}
return { return {
step: { step: showStep
? {
id: STEP_ID, id: STEP_ID,
name: i18n._(t`Preview`), name: (
<StepName hasErrors={false} id="preview-step">
{i18n._(t`Preview`)}
</StepName>
),
component: ( component: (
<PreviewStep <PreviewStep
config={config} launchConfig={launchConfig}
resource={resource} resource={resource}
survey={survey} surveyConfig={surveyConfig}
formErrors={hasErrors} formErrors={hasErrors}
/> />
), ),
enableNext: !hasErrors, enableNext: !hasErrors,
nextButtonText: i18n._(t`Launch`), nextButtonText: i18n._(t`Launch`),
}, }
: null,
initialValues: {}, initialValues: {},
validate: () => ({}),
isReady: true, isReady: true,
error: null, error: null,
setTouched: () => {}, setTouched: () => {},

View File

@@ -1,39 +1,25 @@
import React, { useEffect, useCallback } from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import useRequest from '../../../util/useRequest';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
import SurveyStep from './SurveyStep'; import SurveyStep from './SurveyStep';
import StepName from './StepName'; import StepName from './StepName';
const STEP_ID = 'survey'; const STEP_ID = 'survey';
export default function useSurveyStep(config, visitedSteps, i18n) { export default function useSurveyStep(
launchConfig,
surveyConfig,
resource,
i18n,
visitedSteps
) {
const { values } = useFormikContext(); const { values } = useFormikContext();
const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
useCallback(async () => {
if (!config.survey_enabled) {
return {};
}
const { data } = config?.workflow_job_template_data
? await WorkflowJobTemplatesAPI.readSurvey(
config?.workflow_job_template_data?.id
)
: await JobTemplatesAPI.readSurvey(config?.job_template_data?.id);
return data;
}, [config])
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
const validate = () => {
if (!config.survey_enabled || !survey || !survey.spec) {
return {};
}
const errors = {}; const errors = {};
survey.spec.forEach(question => { const validate = () => {
if (!launchConfig.survey_enabled || !surveyConfig?.spec) {
return {};
}
surveyConfig.spec.forEach(question => {
const errMessage = validateField( const errMessage = validateField(
question, question,
values[`survey_${question.variable}`], values[`survey_${question.variable}`],
@@ -47,18 +33,19 @@ export default function useSurveyStep(config, visitedSteps, i18n) {
}; };
const formError = Object.keys(validate()).length > 0; const formError = Object.keys(validate()).length > 0;
return { return {
step: getStep(config, survey, formError, i18n, visitedSteps), step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps),
initialValues: getInitialValues(launchConfig, surveyConfig, resource),
validate,
surveyConfig,
isReady: true,
contentError: null,
formError, formError,
initialValues: getInitialValues(config, survey),
survey,
isReady: !isLoading && !!survey,
contentError: error,
setTouched: setFieldsTouched => { setTouched: setFieldsTouched => {
if (!survey || !survey.spec) { if (!surveyConfig?.spec) {
return; return;
} }
const fields = {}; const fields = {};
survey.spec.forEach(question => { surveyConfig.spec.forEach(question => {
fields[`survey_${question.variable}`] = true; fields[`survey_${question.variable}`] = true;
}); });
setFieldsTouched(fields); setFieldsTouched(fields);
@@ -84,49 +71,65 @@ function validateField(question, value, i18n) {
); );
} }
} }
if ( if (question.required && !value && value !== 0) {
question.required &&
((!value && value !== 0) || (Array.isArray(value) && value.length === 0))
) {
return i18n._(t`This field must not be blank`); return i18n._(t`This field must not be blank`);
} }
return null; return null;
} }
function getStep(config, survey, hasErrors, i18n, visitedSteps) { function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) {
if (!config.survey_enabled) { if (!launchConfig.survey_enabled) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
key: 6,
name: ( name: (
<StepName <StepName
hasErrors={Object.keys(visitedSteps).includes(STEP_ID) && hasErrors} hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
Object.keys(validate()).length
}
id="survey-step"
> >
{i18n._(t`Survey`)} {i18n._(t`Survey`)}
</StepName> </StepName>
), ),
component: <SurveyStep survey={survey} i18n={i18n} />, component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true, enableNext: true,
}; };
} }
function getInitialValues(config, survey) {
if (!config.survey_enabled || !survey) { function getInitialValues(launchConfig, surveyConfig, resource) {
if (!launchConfig.survey_enabled || !surveyConfig) {
return {}; return {};
} }
const surveyValues = {};
survey.spec.forEach(question => { const values = {};
if (surveyConfig?.spec) {
surveyConfig.spec.forEach(question => {
if (question.type === 'multiselect') { if (question.type === 'multiselect') {
if (question.default === '') { values[`survey_${question.variable}`] = question.default
surveyValues[`survey_${question.variable}`] = []; ? question.default.split('\n')
: [];
} else if (question.type === 'multiplechoice') {
values[`survey_${question.variable}`] =
question.default || question.choices.split('\n')[0];
} else { } else {
surveyValues[`survey_${question.variable}`] = question.default.split( values[`survey_${question.variable}`] = question.default || '';
'\n'
);
} }
if (resource?.extra_data) {
Object.entries(resource.extra_data).forEach(([key, value]) => {
if (key === question.variable) {
if (question.type === 'multiselect') {
values[`survey_${question.variable}`] = value;
} else { } else {
surveyValues[`survey_${question.variable}`] = question.default; values[`survey_${question.variable}`] = value;
}
} }
}); });
return surveyValues; }
});
}
return values;
} }

View File

@@ -6,42 +6,48 @@ import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep'; import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep'; import usePreviewStep from './steps/usePreviewStep';
export default function useLaunchSteps(config, resource, i18n) { export default function useLaunchSteps(
launchConfig,
surveyConfig,
resource,
i18n
) {
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false);
const steps = [ const steps = [
useInventoryStep(config, visited, i18n), useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(config, i18n), useCredentialsStep(launchConfig, resource, i18n),
useOtherPromptsStep(config, i18n), useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(config, visited, i18n), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
]; ];
const { resetForm, values: formikValues } = useFormikContext(); const { resetForm } = useFormikContext();
const hasErrors = steps.some(step => step.formError); const hasErrors = steps.some(step => step.formError);
const surveyStepIndex = steps.findIndex(step => step.survey);
steps.push( steps.push(
usePreviewStep( usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
config,
resource,
steps[surveyStepIndex]?.survey,
hasErrors,
i18n
)
); );
const pfSteps = steps.map(s => s.step).filter(s => s != null); const pfSteps = steps.map(s => s.step).filter(s => s != null);
const isReady = !steps.some(s => !s.isReady); const stepsAreReady = !steps.some(s => !s.isReady);
useEffect(() => { useEffect(() => {
if (surveyStepIndex > -1 && isReady) { if (stepsAreReady) {
const initialValues = steps.reduce((acc, cur) => {
return {
...acc,
...cur.initialValues,
};
}, {});
resetForm({ resetForm({
values: { values: {
...formikValues, ...initialValues,
...steps[surveyStepIndex].initialValues,
}, },
}); });
setIsReady(true);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady]); }, [stepsAreReady]);
const stepWithError = steps.find(s => s.contentError); const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null; const contentError = stepWithError ? stepWithError.contentError : null;

View File

@@ -53,6 +53,7 @@ function buildResourceLink(resource) {
function hasPromptData(launchData) { function hasPromptData(launchData) {
return ( return (
launchData.survey_enabled ||
launchData.ask_credential_on_launch || launchData.ask_credential_on_launch ||
launchData.ask_diff_mode_on_launch || launchData.ask_diff_mode_on_launch ||
launchData.ask_inventory_on_launch || launchData.ask_inventory_on_launch ||
@@ -66,14 +67,15 @@ function hasPromptData(launchData) {
); );
} }
function omitOverrides(resource, overrides) { function omitOverrides(resource, overrides, defaultConfig) {
const clonedResource = { const clonedResource = {
...resource, ...resource,
summary_fields: { ...resource.summary_fields }, summary_fields: { ...resource.summary_fields },
...defaultConfig,
}; };
Object.keys(overrides).forEach(keyToOmit => { Object.keys(overrides).forEach(keyToOmit => {
delete clonedResource[keyToOmit]; delete clonedResource[keyToOmit];
delete clonedResource.summary_fields[keyToOmit]; delete clonedResource?.summary_fields[keyToOmit];
}); });
return clonedResource; return clonedResource;
} }
@@ -87,7 +89,8 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
4: i18n._(t`4 (Connection Debug)`), 4: i18n._(t`4 (Connection Debug)`),
}; };
const details = omitOverrides(resource, overrides); const details = omitOverrides(resource, overrides, launchConfig.defaults);
details.type = overrides?.nodeType || details.type;
const hasOverrides = Object.keys(overrides).length > 0; const hasOverrides = Object.keys(overrides).length > 0;
return ( return (
@@ -136,13 +139,13 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
<Divider css="margin-top: var(--pf-global--spacer--lg)" /> <Divider css="margin-top: var(--pf-global--spacer--lg)" />
<PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader> <PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader>
<DetailList aria-label="Prompt Overrides"> <DetailList aria-label="Prompt Overrides">
{overrides?.job_type && ( {launchConfig.ask_job_type_on_launch && (
<Detail <Detail
label={i18n._(t`Job Type`)} label={i18n._(t`Job Type`)}
value={toTitleCase(overrides.job_type)} value={toTitleCase(overrides.job_type)}
/> />
)} )}
{overrides?.credentials && ( {launchConfig.ask_credential_on_launch && (
<Detail <Detail
fullWidth fullWidth
label={i18n._(t`Credentials`)} label={i18n._(t`Credentials`)}
@@ -163,37 +166,43 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
} }
/> />
)} )}
{overrides?.inventory && ( {launchConfig.ask_inventory_on_launch && (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}
value={overrides.inventory?.name} value={overrides.inventory?.name}
/> />
)} )}
{overrides?.scm_branch && ( {launchConfig.ask_scm_branch_on_launch && (
<Detail <Detail
label={i18n._(t`Source Control Branch`)} label={i18n._(t`Source Control Branch`)}
value={overrides.scm_branch} value={overrides.scm_branch}
/> />
)} )}
{overrides?.limit && ( {launchConfig.ask_limit_on_launch && (
<Detail label={i18n._(t`Limit`)} value={overrides.limit} /> <Detail label={i18n._(t`Limit`)} value={overrides.limit} />
)} )}
{overrides?.verbosity && ( {Object.prototype.hasOwnProperty.call(overrides, 'verbosity') &&
launchConfig.ask_verbosity_on_launch ? (
<Detail <Detail
label={i18n._(t`Verbosity`)} label={i18n._(t`Verbosity`)}
value={VERBOSITY[overrides.verbosity]} value={VERBOSITY[overrides.verbosity]}
/> />
)} ) : null}
{overrides?.job_tags && ( {launchConfig.ask_tags_on_launch && (
<Detail <Detail
fullWidth fullWidth
label={i18n._(t`Job Tags`)} label={i18n._(t`Job Tags`)}
value={ value={
<ChipGroup <ChipGroup
numChips={5} numChips={5}
totalChips={overrides.job_tags.split(',').length} totalChips={
!overrides.job_tags || overrides.job_tags === ''
? 0
: overrides.job_tags.split(',').length
}
> >
{overrides.job_tags.split(',').map(jobTag => ( {overrides.job_tags.length > 0 &&
overrides.job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly> <Chip key={jobTag} isReadOnly>
{jobTag} {jobTag}
</Chip> </Chip>
@@ -202,16 +211,21 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
} }
/> />
)} )}
{overrides?.skip_tags && ( {launchConfig.ask_skip_tags_on_launch && (
<Detail <Detail
fullWidth fullWidth
label={i18n._(t`Skip Tags`)} label={i18n._(t`Skip Tags`)}
value={ value={
<ChipGroup <ChipGroup
numChips={5} numChips={5}
totalChips={overrides.skip_tags.split(',').length} totalChips={
!overrides.skip_tags || overrides.skip_tags === ''
? 0
: overrides.skip_tags.split(',').length
}
> >
{overrides.skip_tags.split(',').map(skipTag => ( {overrides.skip_tags.length > 0 &&
overrides.skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly> <Chip key={skipTag} isReadOnly>
{skipTag} {skipTag}
</Chip> </Chip>
@@ -220,7 +234,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
} }
/> />
)} )}
{overrides?.diff_mode && ( {launchConfig.ask_diff_mode_on_launch && (
<Detail <Detail
label={i18n._(t`Show Changes`)} label={i18n._(t`Show Changes`)}
value={ value={
@@ -228,7 +242,8 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
} }
/> />
)} )}
{overrides?.extra_vars && ( {(launchConfig.survey_enabled ||
launchConfig.ask_variables_on_launch) && (
<VariablesDetail <VariablesDetail
label={i18n._(t`Variables`)} label={i18n._(t`Variables`)}
rows={4} rows={4}

View File

@@ -18,7 +18,7 @@ const mockPromptLaunch = {
defaults: { defaults: {
extra_vars: '---foo: bar', extra_vars: '---foo: bar',
diff_mode: false, diff_mode: false,
limit: 3, limit: 'localhost',
job_tags: 'T_100,T_200', job_tags: 'T_100,T_200',
skip_tags: 'S_100,S_200', skip_tags: 'S_100,S_200',
job_type: 'run', job_type: 'run',
@@ -74,7 +74,7 @@ describe('PromptDetail', () => {
assertDetail('Job Type', 'Run'); assertDetail('Job Type', 'Run');
assertDetail('Inventory', 'Demo Inventory'); assertDetail('Inventory', 'Demo Inventory');
assertDetail('Source Control Branch', 'Foo branch'); assertDetail('Source Control Branch', 'Foo branch');
assertDetail('Limit', 'alpha:beta'); assertDetail('Limit', 'localhost');
assertDetail('Verbosity', '3 (Debug)'); assertDetail('Verbosity', '3 (Debug)');
assertDetail('Show Changes', 'Off'); assertDetail('Show Changes', 'Off');
expect(wrapper.find('VariablesDetail').prop('value')).toEqual( expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
@@ -151,6 +151,14 @@ describe('PromptDetail', () => {
inventory: { inventory: {
name: 'Override inventory', name: 'Override inventory',
}, },
credentials: mockPromptLaunch.defaults.credentials,
job_tags: 'foo,bar',
skip_tags: 'baz,boo',
limit: 'otherlimit',
verbosity: 0,
job_type: 'check',
scm_branch: 'Bar branch',
diff_mode: true,
}; };
beforeAll(() => { beforeAll(() => {
@@ -180,12 +188,12 @@ describe('PromptDetail', () => {
assertDetail('Name', 'Mock JT'); assertDetail('Name', 'Mock JT');
assertDetail('Description', 'Mock JT Description'); assertDetail('Description', 'Mock JT Description');
assertDetail('Type', 'Job Template'); assertDetail('Type', 'Job Template');
assertDetail('Job Type', 'Run'); assertDetail('Job Type', 'Check');
assertDetail('Inventory', 'Override inventory'); assertDetail('Inventory', 'Override inventory');
assertDetail('Source Control Branch', 'Foo branch'); assertDetail('Source Control Branch', 'Bar branch');
assertDetail('Limit', 'alpha:beta'); assertDetail('Limit', 'otherlimit');
assertDetail('Verbosity', '3 (Debug)'); assertDetail('Verbosity', '0 (Normal)');
assertDetail('Show Changes', 'Off'); assertDetail('Show Changes', 'On');
expect(wrapper.find('VariablesDetail').prop('value')).toEqual( expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
'---one: two\nbar: baz' '---one: two\nbar: baz'
); );
@@ -204,12 +212,12 @@ describe('PromptDetail', () => {
expect( expect(
wrapper wrapper
.find('Detail[label="Job Tags"]') .find('Detail[label="Job Tags"]')
.containsAnyMatchingElements([<span>T_100</span>, <span>T_200</span>]) .containsAnyMatchingElements([<span>foo</span>, <span>bar</span>])
).toEqual(true); ).toEqual(true);
expect( expect(
wrapper wrapper
.find('Detail[label="Skip Tags"]') .find('Detail[label="Skip Tags"]')
.containsAllMatchingElements([<span>S_100</span>, <span>S_200</span>]) .containsAllMatchingElements([<span>baz</span>, <span>boo</span>])
).toEqual(true); ).toEqual(true);
}); });
}); });

View File

@@ -72,7 +72,7 @@ function PromptJobTemplateDetail({ i18n, resource }) {
? 'smart_inventory' ? 'smart_inventory'
: 'inventory'; : 'inventory';
const recentJobs = summary_fields.recent_jobs.map(job => ({ const recentJobs = summary_fields?.recent_jobs?.map(job => ({
...job, ...job,
type: 'job', type: 'job',
})); }));
@@ -133,10 +133,12 @@ function PromptJobTemplateDetail({ i18n, resource }) {
<Detail label={i18n._(t`Forks`)} value={forks || '0'} /> <Detail label={i18n._(t`Forks`)} value={forks || '0'} />
<Detail label={i18n._(t`Limit`)} value={limit} /> <Detail label={i18n._(t`Limit`)} value={limit} />
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} /> <Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} />
{typeof diff_mode === 'boolean' && (
<Detail <Detail
label={i18n._(t`Show Changes`)} label={i18n._(t`Show Changes`)}
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)} value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
/> />
)}
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} /> <Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
<Detail label={i18n._(t`Host Config Key`)} value={host_config_key} /> <Detail label={i18n._(t`Host Config Key`)} value={host_config_key} />
{related?.callback && ( {related?.callback && (
@@ -149,7 +151,7 @@ function PromptJobTemplateDetail({ i18n, resource }) {
label={i18n._(t`Webhook Service`)} label={i18n._(t`Webhook Service`)}
value={toTitleCase(webhook_service)} value={toTitleCase(webhook_service)}
/> />
{related.webhook_receiver && ( {related?.webhook_receiver && (
<Detail <Detail
label={i18n._(t`Webhook URL`)} label={i18n._(t`Webhook URL`)}
value={`${window.location.origin}${related.webhook_receiver}`} value={`${window.location.origin}${related.webhook_receiver}`}

View File

@@ -40,14 +40,14 @@ function PromptWFJobTemplateDetail({ i18n, resource }) {
? 'smart_inventory' ? 'smart_inventory'
: 'inventory'; : 'inventory';
const recentJobs = summary_fields.recent_jobs.map(job => ({ const recentJobs = summary_fields?.recent_jobs?.map(job => ({
...job, ...job,
type: 'job', type: 'job',
})); }));
return ( return (
<> <>
{summary_fields.recent_jobs?.length > 0 && ( {summary_fields?.recent_jobs?.length > 0 && (
<Detail <Detail
value={<Sparkline jobs={recentJobs} />} value={<Sparkline jobs={recentJobs} />}
label={i18n._(t`Activity`)} label={i18n._(t`Activity`)}
@@ -84,7 +84,7 @@ function PromptWFJobTemplateDetail({ i18n, resource }) {
value={toTitleCase(webhook_service)} value={toTitleCase(webhook_service)}
/> />
<Detail label={i18n._(t`Webhook Key`)} value={webhook_key} /> <Detail label={i18n._(t`Webhook Key`)} value={webhook_key} />
{related.webhook_receiver && ( {related?.webhook_receiver && (
<Detail <Detail
label={i18n._(t`Webhook URL`)} label={i18n._(t`Webhook URL`)}
value={`${window.location.origin}${related.webhook_receiver}`} value={`${window.location.origin}${related.webhook_receiver}`}

View File

@@ -16,6 +16,7 @@ const Link = styled(props => <_Link {...props} />)`
const Wrapper = styled.div` const Wrapper = styled.div`
display: inline-flex; display: inline-flex;
flex-wrap: wrap;
`; `;
/* eslint-enable react/jsx-pascal-case */ /* eslint-enable react/jsx-pascal-case */

View File

@@ -34,9 +34,12 @@ const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
function WorkflowNodeHelp({ node, i18n }) { function WorkflowNodeHelp({ node, i18n }) {
let nodeType; let nodeType;
const job = node?.originalNodeObject?.summary_fields?.job; const job = node?.originalNodeObject?.summary_fields?.job;
if (node.unifiedJobTemplate || job) { const unifiedJobTemplate =
const type = node.unifiedJobTemplate node?.fullUnifiedJobTemplate ||
? node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type node?.originalNodeObject?.summary_fields?.unified_job_template;
if (unifiedJobTemplate || job) {
const type = unifiedJobTemplate
? unifiedJobTemplate.unified_job_type || unifiedJobTemplate.type
: job.type; : job.type;
switch (type) { switch (type) {
case 'job_template': case 'job_template':
@@ -113,7 +116,7 @@ function WorkflowNodeHelp({ node, i18n }) {
return ( return (
<> <>
{!node.unifiedJobTemplate && (!job || job.type !== 'workflow_approval') && ( {!unifiedJobTemplate && (!job || job.type !== 'workflow_approval') && (
<> <>
<ResourceDeleted job={job}> <ResourceDeleted job={job}>
<StyledExclamationTriangleIcon /> <StyledExclamationTriangleIcon />
@@ -149,12 +152,12 @@ function WorkflowNodeHelp({ node, i18n }) {
)} )}
</GridDL> </GridDL>
)} )}
{node.unifiedJobTemplate && !job && ( {unifiedJobTemplate && !job && (
<GridDL> <GridDL>
<dt> <dt>
<b>{i18n._(t`Name`)}</b> <b>{i18n._(t`Name`)}</b>
</dt> </dt>
<dd id="workflow-node-help-name">{node.unifiedJobTemplate.name}</dd> <dd id="workflow-node-help-name">{unifiedJobTemplate.name}</dd>
<dt> <dt>
<b>{i18n._(t`Type`)}</b> <b>{i18n._(t`Type`)}</b>
</dt> </dt>

View File

@@ -19,16 +19,27 @@ const CenteredPauseIcon = styled(PauseIcon)`
`; `;
function WorkflowNodeTypeLetter({ node }) { function WorkflowNodeTypeLetter({ node }) {
if (
!node?.fullUnifiedJobTemplate &&
!node?.originalNodeObject?.summary_fields?.unified_job_template
) {
return null;
}
const unifiedJobTemplate =
node?.fullUnifiedJobTemplate ||
node?.originalNodeObject?.summary_fields?.unified_job_template;
let nodeTypeLetter; let nodeTypeLetter;
if ( if (
(node.unifiedJobTemplate && unifiedJobTemplate.type ||
(node.unifiedJobTemplate.type || unifiedJobTemplate.unified_job_type ||
node.unifiedJobTemplate.unified_job_type)) || node?.job?.type
(node.job && node.job.type)
) { ) {
const ujtType = node.unifiedJobTemplate const ujtType =
? node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type unifiedJobTemplate.type ||
: node.job.type; unifiedJobTemplate.unified_job_type ||
node.job.type;
switch (ujtType) { switch (ujtType) {
case 'job_template': case 'job_template':
case 'job': case 'job':

View File

@@ -8,7 +8,7 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { type: 'job_template' } }} node={{ fullUnifiedJobTemplate: { type: 'job_template' } }}
/> />
</svg> </svg>
); );
@@ -19,7 +19,7 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { unified_job_type: 'job' } }} node={{ fullUnifiedJobTemplate: { unified_job_type: 'job' } }}
/> />
</svg> </svg>
); );
@@ -30,7 +30,7 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { type: 'project' } }} node={{ fullUnifiedJobTemplate: { type: 'project' } }}
/> />
</svg> </svg>
); );
@@ -41,7 +41,9 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { unified_job_type: 'project_update' } }} node={{
fullUnifiedJobTemplate: { unified_job_type: 'project_update' },
}}
/> />
</svg> </svg>
); );
@@ -52,7 +54,7 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { type: 'inventory_source' } }} node={{ fullUnifiedJobTemplate: { type: 'inventory_source' } }}
/> />
</svg> </svg>
); );
@@ -64,7 +66,7 @@ describe('WorkflowNodeTypeLetter', () => {
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ node={{
unifiedJobTemplate: { unified_job_type: 'inventory_update' }, fullUnifiedJobTemplate: { unified_job_type: 'inventory_update' },
}} }}
/> />
</svg> </svg>
@@ -76,7 +78,7 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { type: 'workflow_job_template' } }} node={{ fullUnifiedJobTemplate: { type: 'workflow_job_template' } }}
/> />
</svg> </svg>
); );
@@ -87,7 +89,9 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { unified_job_type: 'workflow_job' } }} node={{
fullUnifiedJobTemplate: { unified_job_type: 'workflow_job' },
}}
/> />
</svg> </svg>
); );
@@ -98,7 +102,9 @@ describe('WorkflowNodeTypeLetter', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ unifiedJobTemplate: { type: 'workflow_approval_template' } }} node={{
fullUnifiedJobTemplate: { type: 'workflow_approval_template' },
}}
/> />
</svg> </svg>
); );
@@ -110,7 +116,7 @@ describe('WorkflowNodeTypeLetter', () => {
<svg> <svg>
<WorkflowNodeTypeLetter <WorkflowNodeTypeLetter
node={{ node={{
unifiedJobTemplate: { unified_job_type: 'workflow_approval' }, fullUnifiedJobTemplate: { unified_job_type: 'workflow_approval' },
}} }}
/> />
</svg> </svg>

View File

@@ -55,27 +55,60 @@ export default function visualizerReducer(state, action) {
case 'SELECT_SOURCE_FOR_LINKING': case 'SELECT_SOURCE_FOR_LINKING':
return selectSourceForLinking(state, action.node); return selectSourceForLinking(state, action.node);
case 'SET_ADD_LINK_TARGET_NODE': case 'SET_ADD_LINK_TARGET_NODE':
return { ...state, addLinkTargetNode: action.value }; return {
...state,
addLinkTargetNode: action.value,
};
case 'SET_CONTENT_ERROR': case 'SET_CONTENT_ERROR':
return { ...state, contentError: action.value }; return {
...state,
contentError: action.value,
};
case 'SET_IS_LOADING': case 'SET_IS_LOADING':
return { ...state, isLoading: action.value }; return {
...state,
isLoading: action.value,
};
case 'SET_LINK_TO_DELETE': case 'SET_LINK_TO_DELETE':
return { ...state, linkToDelete: action.value }; return {
...state,
linkToDelete: action.value,
};
case 'SET_LINK_TO_EDIT': case 'SET_LINK_TO_EDIT':
return { ...state, linkToEdit: action.value }; return {
...state,
linkToEdit: action.value,
};
case 'SET_NODES': case 'SET_NODES':
return { ...state, nodes: action.value }; return {
...state,
nodes: action.value,
};
case 'SET_NODE_POSITIONS': case 'SET_NODE_POSITIONS':
return { ...state, nodePositions: action.value }; return {
...state,
nodePositions: action.value,
};
case 'SET_NODE_TO_DELETE': case 'SET_NODE_TO_DELETE':
return { ...state, nodeToDelete: action.value }; return {
...state,
nodeToDelete: action.value,
};
case 'SET_NODE_TO_EDIT': case 'SET_NODE_TO_EDIT':
return { ...state, nodeToEdit: action.value }; return {
...state,
nodeToEdit: action.value,
};
case 'SET_NODE_TO_VIEW': case 'SET_NODE_TO_VIEW':
return { ...state, nodeToView: action.value }; return {
...state,
nodeToView: action.value,
};
case 'SET_SHOW_DELETE_ALL_NODES_MODAL': case 'SET_SHOW_DELETE_ALL_NODES_MODAL':
return { ...state, showDeleteAllNodesModal: action.value }; return {
...state,
showDeleteAllNodesModal: action.value,
};
case 'START_ADD_NODE': case 'START_ADD_NODE':
return { return {
...state, ...state,
@@ -113,8 +146,12 @@ function createLink(state, linkType) {
}); });
newLinks.push({ newLinks.push({
source: { id: addLinkSourceNode.id }, source: {
target: { id: addLinkTargetNode.id }, id: addLinkSourceNode.id,
},
target: {
id: addLinkTargetNode.id,
},
linkType, linkType,
}); });
@@ -143,8 +180,9 @@ function createNode(state, node) {
newNodes.push({ newNodes.push({
id: nextNodeId, id: nextNodeId,
unifiedJobTemplate: node.nodeResource, fullUnifiedJobTemplate: node.nodeResource,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
promptValues: node.promptValues,
}); });
// Ensures that root nodes appear to always run // Ensures that root nodes appear to always run
@@ -154,8 +192,12 @@ function createNode(state, node) {
} }
newLinks.push({ newLinks.push({
source: { id: addNodeSource }, source: {
target: { id: nextNodeId }, id: addNodeSource,
},
target: {
id: nextNodeId,
},
linkType: node.linkType, linkType: node.linkType,
}); });
@@ -165,7 +207,9 @@ function createNode(state, node) {
linkToCompare.source.id === addNodeSource && linkToCompare.source.id === addNodeSource &&
linkToCompare.target.id === addNodeTarget linkToCompare.target.id === addNodeTarget
) { ) {
linkToCompare.source = { id: nextNodeId }; linkToCompare.source = {
id: nextNodeId,
};
} }
}); });
} }
@@ -268,15 +312,23 @@ function addLinksFromParentsToChildren(
// doesn't have any other parents // doesn't have any other parents
if (linkParentMapping[child.id].length === 1) { if (linkParentMapping[child.id].length === 1) {
newLinks.push({ newLinks.push({
source: { id: parentId }, source: {
target: { id: child.id }, id: parentId,
},
target: {
id: child.id,
},
linkType: 'always', linkType: 'always',
}); });
} }
} else if (!linkParentMapping[child.id].includes(parentId)) { } else if (!linkParentMapping[child.id].includes(parentId)) {
newLinks.push({ newLinks.push({
source: { id: parentId }, source: {
target: { id: child.id }, id: parentId,
},
target: {
id: child.id,
},
linkType: child.linkType, linkType: child.linkType,
}); });
} }
@@ -302,7 +354,10 @@ function removeLinksFromDeletedNode(
if (link.source.id === nodeId || link.target.id === nodeId) { if (link.source.id === nodeId || link.target.id === nodeId) {
if (link.source.id === nodeId) { if (link.source.id === nodeId) {
children.push({ id: link.target.id, linkType: link.linkType }); children.push({
id: link.target.id,
linkType: link.linkType,
});
} else if (link.target.id === nodeId) { } else if (link.target.id === nodeId) {
parents.push(link.source.id); parents.push(link.source.id);
} }
@@ -352,7 +407,7 @@ function generateNodes(workflowNodes, i18n) {
const arrayOfNodesForChart = [ const arrayOfNodesForChart = [
{ {
id: 1, id: 1,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
name: i18n._(t`START`), name: i18n._(t`START`),
}, },
}, },
@@ -365,8 +420,14 @@ function generateNodes(workflowNodes, i18n) {
originalNodeObject: node, originalNodeObject: node,
}; };
if (node.summary_fields.unified_job_template) { if (
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; node.summary_fields?.unified_job_template?.unified_job_type ===
'workflow_approval'
) {
nodeObj.fullUnifiedJobTemplate = {
...node.summary_fields.unified_job_template,
type: 'workflow_approval_template',
};
} }
arrayOfNodesForChart.push(nodeObj); arrayOfNodesForChart.push(nodeObj);
@@ -596,11 +657,19 @@ function updateLink(state, linkType) {
function updateNode(state, editedNode) { function updateNode(state, editedNode) {
const { nodeToEdit, nodes } = state; const { nodeToEdit, nodes } = state;
const { nodeResource, launchConfig, promptValues } = editedNode;
const newNodes = [...nodes]; const newNodes = [...nodes];
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id); const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
matchingNode.unifiedJobTemplate = editedNode.nodeResource; matchingNode.fullUnifiedJobTemplate = nodeResource;
matchingNode.isEdited = true; matchingNode.isEdited = true;
matchingNode.launchConfig = launchConfig;
if (promptValues) {
matchingNode.promptValues = promptValues;
} else {
delete matchingNode.promptValues;
}
return { return {
...state, ...state,
@@ -615,7 +684,15 @@ function refreshNode(state, refreshedNode) {
const newNodes = [...nodes]; const newNodes = [...nodes];
const matchingNode = newNodes.find(node => node.id === nodeToView.id); const matchingNode = newNodes.find(node => node.id === nodeToView.id);
matchingNode.unifiedJobTemplate = refreshedNode.nodeResource;
if (refreshedNode.fullUnifiedJobTemplate) {
matchingNode.fullUnifiedJobTemplate = refreshedNode.fullUnifiedJobTemplate;
}
if (refreshedNode.originalNodeCredentials) {
matchingNode.originalNodeCredentials =
refreshedNode.originalNodeCredentials;
}
return { return {
...state, ...state,

View File

@@ -197,7 +197,7 @@ describe('Workflow reducer', () => {
{ {
id: 2, id: 2,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 7000, id: 7000,
name: 'Foo JT', name: 'Foo JT',
}, },
@@ -281,7 +281,7 @@ describe('Workflow reducer', () => {
{ {
id: 3, id: 3,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 7000, id: 7000,
name: 'Foo JT', name: 'Foo JT',
}, },
@@ -869,10 +869,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 2, workflowMakerNodeId: 2,
}, },
unifiedJobTemplate: {
id: 1,
name: 'JT 1',
},
}, },
target: { target: {
id: 4, id: 4,
@@ -889,10 +885,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 4, workflowMakerNodeId: 4,
}, },
unifiedJobTemplate: {
id: 3,
name: 'JT 3',
},
}, },
}, },
{ {
@@ -912,10 +904,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 2, workflowMakerNodeId: 2,
}, },
unifiedJobTemplate: {
id: 1,
name: 'JT 1',
},
}, },
target: { target: {
id: 3, id: 3,
@@ -932,10 +920,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 3, workflowMakerNodeId: 3,
}, },
unifiedJobTemplate: {
id: 2,
name: 'JT 2',
},
}, },
}, },
{ {
@@ -955,10 +939,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 5, workflowMakerNodeId: 5,
}, },
unifiedJobTemplate: {
id: 4,
name: 'JT 4',
},
}, },
target: { target: {
id: 3, id: 3,
@@ -975,17 +955,13 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 3, workflowMakerNodeId: 3,
}, },
unifiedJobTemplate: {
id: 2,
name: 'JT 2',
},
}, },
}, },
{ {
linkType: 'always', linkType: 'always',
source: { source: {
id: 1, id: 1,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
name: undefined, name: undefined,
}, },
}, },
@@ -1004,17 +980,13 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 2, workflowMakerNodeId: 2,
}, },
unifiedJobTemplate: {
id: 1,
name: 'JT 1',
},
}, },
}, },
{ {
linkType: 'always', linkType: 'always',
source: { source: {
id: 1, id: 1,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
name: undefined, name: undefined,
}, },
}, },
@@ -1033,10 +1005,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 5, workflowMakerNodeId: 5,
}, },
unifiedJobTemplate: {
id: 4,
name: 'JT 4',
},
}, },
}, },
], ],
@@ -1044,7 +1012,7 @@ describe('Workflow reducer', () => {
nodes: [ nodes: [
{ {
id: 1, id: 1,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
name: undefined, name: undefined,
}, },
}, },
@@ -1063,10 +1031,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 2, workflowMakerNodeId: 2,
}, },
unifiedJobTemplate: {
id: 1,
name: 'JT 1',
},
}, },
{ {
id: 3, id: 3,
@@ -1083,10 +1047,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 3, workflowMakerNodeId: 3,
}, },
unifiedJobTemplate: {
id: 2,
name: 'JT 2',
},
}, },
{ {
id: 4, id: 4,
@@ -1103,10 +1063,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 4, workflowMakerNodeId: 4,
}, },
unifiedJobTemplate: {
id: 3,
name: 'JT 3',
},
}, },
{ {
id: 5, id: 5,
@@ -1123,10 +1079,6 @@ describe('Workflow reducer', () => {
}, },
workflowMakerNodeId: 5, workflowMakerNodeId: 5,
}, },
unifiedJobTemplate: {
id: 4,
name: 'JT 4',
},
}, },
], ],
}); });
@@ -1718,7 +1670,7 @@ describe('Workflow reducer', () => {
id: 2, id: 2,
isEdited: false, isEdited: false,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 703, id: 703,
name: 'Test JT', name: 'Test JT',
type: 'job_template', type: 'job_template',
@@ -1729,7 +1681,7 @@ describe('Workflow reducer', () => {
id: 2, id: 2,
isEdited: false, isEdited: false,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 703, id: 703,
name: 'Test JT', name: 'Test JT',
type: 'job_template', type: 'job_template',
@@ -1757,7 +1709,7 @@ describe('Workflow reducer', () => {
id: 2, id: 2,
isEdited: true, isEdited: true,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 704, id: 704,
name: 'Other JT', name: 'Other JT',
type: 'job_template', type: 'job_template',

View File

@@ -65,6 +65,10 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) {
const history = useHistory(); const history = useHistory();
const { nodePositions } = useContext(WorkflowStateContext); const { nodePositions } = useContext(WorkflowStateContext);
const job = node?.originalNodeObject?.summary_fields?.job; const job = node?.originalNodeObject?.summary_fields?.job;
const jobName =
node?.originalNodeObject?.summary_fields?.unified_job_template?.name ||
node?.unifiedJobTemplate?.name;
let borderColor = '#93969A'; let borderColor = '#93969A';
if (job) { if (job) {
@@ -110,19 +114,15 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) {
{job ? ( {job ? (
<> <>
<JobTopLine> <JobTopLine>
{job.status && <StatusIcon status={job.status} />} {job.status !== 'pending' && <StatusIcon status={job.status} />}
<p>{job.name || node.unifiedJobTemplate.name}</p> <p>{jobName}</p>
</JobTopLine> </JobTopLine>
{!!job?.elapsed && ( {!!job?.elapsed && (
<Elapsed>{secondsToHHMMSS(job.elapsed)}</Elapsed> <Elapsed>{secondsToHHMMSS(job.elapsed)}</Elapsed>
)} )}
</> </>
) : ( ) : (
<NodeDefaultLabel> <NodeDefaultLabel>{jobName || i18n._(t`DELETED`)}</NodeDefaultLabel>
{node.unifiedJobTemplate
? node.unifiedJobTemplate.name
: i18n._(t`DELETED`)}
</NodeDefaultLabel>
)} )}
</NodeContents> </NodeContents>
</foreignObject> </foreignObject>

View File

@@ -14,6 +14,9 @@ const nodeWithJT = {
status: 'successful', status: 'successful',
type: 'job', type: 'job',
}, },
unified_job_template: {
name: 'Automation JT',
},
}, },
unifiedJobTemplate: { unifiedJobTemplate: {
id: 77, id: 77,
@@ -34,6 +37,9 @@ const nodeWithoutJT = {
status: 'successful', status: 'successful',
type: 'job', type: 'job',
}, },
unified_job_template: {
name: 'Automation JT 2',
},
}, },
}, },
}; };
@@ -80,7 +86,7 @@ describe('WorkflowOutputNode', () => {
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true); expect(wrapper.text('p')).toContain('Automation JT');
expect(wrapper.find('WorkflowOutputNode Elapsed').text()).toBe('00:00:07'); expect(wrapper.find('WorkflowOutputNode Elapsed').text()).toBe('00:00:07');
}); });
test('node contents displayed correctly when Job Template deleted', () => { test('node contents displayed correctly when Job Template deleted', () => {
@@ -95,7 +101,7 @@ describe('WorkflowOutputNode', () => {
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper.contains(<p>Automation JT 2</p>)).toEqual(true); expect(wrapper.contains(<p>Automation JT 2</p>)).toBe(true);
expect(wrapper.find('WorkflowOutputNode Elapsed').text()).toBe('00:00:07'); expect(wrapper.find('WorkflowOutputNode Elapsed').text()).toBe('00:00:07');
}); });
test('node contents displayed correctly when Job deleted', () => { test('node contents displayed correctly when Job deleted', () => {

View File

@@ -104,7 +104,9 @@ function SurveyPreviewModal({
isReadOnly isReadOnly
variant={SelectVariant.typeaheadMulti} variant={SelectVariant.typeaheadMulti}
isOpen={false} isOpen={false}
selections={q.default.length > 0 && q.default.split('\n')} selections={
q.default.length > 0 ? q.default.split('\n') : []
}
onToggle={() => {}} onToggle={() => {}}
aria-label={i18n._(t`Multi-Select`)} aria-label={i18n._(t`Multi-Select`)}
id={`survey-preview-multiSelect-${q.variable}`} id={`survey-preview-multiSelect-${q.variable}`}

View File

@@ -6,18 +6,56 @@ import {
WorkflowStateContext, WorkflowStateContext,
} from '../../../../../contexts/Workflow'; } from '../../../../../contexts/Workflow';
import NodeModal from './NodeModal'; import NodeModal from './NodeModal';
import { getAddedAndRemoved } from '../../../../../util/lists';
function NodeAddModal({ i18n }) { function NodeAddModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext); const dispatch = useContext(WorkflowDispatchContext);
const { addNodeSource } = useContext(WorkflowStateContext); const { addNodeSource } = useContext(WorkflowStateContext);
const addNode = (resource, linkType) => { const addNode = (values, config) => {
const {
approvalName,
approvalDescription,
timeoutMinutes,
timeoutSeconds,
linkType,
} = values;
if (values) {
const { added, removed } = getAddedAndRemoved(
config?.defaults?.credentials,
values?.credentials
);
values.addedCredentials = added;
values.removedCredentials = removed;
}
const node = {
linkType,
};
delete values.linkType;
if (values.nodeType === 'workflow_approval_template') {
node.nodeResource = {
description: approvalDescription,
name: approvalName,
timeout: Number(timeoutMinutes) * 60 + Number(timeoutSeconds),
type: 'workflow_approval_template',
};
} else {
node.nodeResource = values.nodeResource;
if (
values?.nodeType === 'job_template' ||
values?.nodeType === 'workflow_job_template'
) {
node.promptValues = values;
}
}
dispatch({ dispatch({
type: 'CREATE_NODE', type: 'CREATE_NODE',
node: { node,
linkType,
nodeResource: resource,
},
}); });
}; };

View File

@@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import {
mountWithContexts,
waitForElement,
} from '../../../../../../testUtils/enzymeHelpers';
import { import {
WorkflowDispatchContext, WorkflowDispatchContext,
WorkflowStateContext, WorkflowStateContext,
@@ -13,6 +16,9 @@ const nodeResource = {
id: 448, id: 448,
type: 'job_template', type: 'job_template',
name: 'Test JT', name: 'Test JT',
summary_fields: {
credentials: [],
},
}; };
const workflowContext = { const workflowContext = {
@@ -20,23 +26,37 @@ const workflowContext = {
}; };
describe('NodeAddModal', () => { describe('NodeAddModal', () => {
test('Node modal confirmation dispatches as expected', () => { test('Node modal confirmation dispatches as expected', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}> <WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}> <WorkflowStateContext.Provider value={workflowContext}>
<NodeAddModal /> <NodeAddModal onSave={() => {}} askLinkType title="Add Node" />
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
); );
act(() => { waitForElement(
wrapper.find('NodeModal').prop('onSave')(nodeResource, 'success'); wrapper,
'WizardNavItem[content="ContentLoading"]',
el => el.length === 0
);
await act(async () => {
wrapper.find('NodeModal').prop('onSave')(
{ linkType: 'success', nodeResource },
{}
);
}); });
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
type: 'CREATE_NODE',
node: { node: {
linkType: 'success', linkType: 'success',
nodeResource, nodeResource: {
id: 448,
name: 'Test JT',
summary_fields: { credentials: [] },
type: 'job_template',
}, },
},
type: 'CREATE_NODE',
}); });
}); });
}); });

View File

@@ -7,12 +7,45 @@ import NodeModal from './NodeModal';
function NodeEditModal({ i18n }) { function NodeEditModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext); const dispatch = useContext(WorkflowDispatchContext);
const updateNode = resource => { const updateNode = (values, config) => {
const {
approvalName,
approvalDescription,
credentials,
linkType,
nodeResource,
nodeType,
timeoutMinutes,
timeoutSeconds,
...rest
} = values;
let node;
if (values.nodeType === 'workflow_approval_template') {
node = {
nodeResource: {
description: approvalDescription,
name: approvalName,
timeout: Number(timeoutMinutes) * 60 + Number(timeoutSeconds),
type: 'workflow_approval_template',
},
};
} else {
node = {
nodeResource,
};
if (nodeType === 'job_template' || nodeType === 'workflow_job_template') {
node.promptValues = {
...rest,
credentials,
};
node.launchConfig = config;
}
}
dispatch({ dispatch({
type: 'UPDATE_NODE', type: 'UPDATE_NODE',
node: { node,
nodeResource: resource,
},
}); });
}; };

View File

@@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import {
mountWithContexts,
waitForElement,
} from '../../../../../../testUtils/enzymeHelpers';
import { import {
WorkflowDispatchContext, WorkflowDispatchContext,
WorkflowStateContext, WorkflowStateContext,
@@ -9,10 +12,17 @@ import NodeEditModal from './NodeEditModal';
const dispatch = jest.fn(); const dispatch = jest.fn();
const nodeResource = { jest.mock('../../../../../api/models/InventorySources');
jest.mock('../../../../../api/models/JobTemplates');
jest.mock('../../../../../api/models/Projects');
jest.mock('../../../../../api/models/WorkflowJobTemplates');
const values = {
inventory: undefined,
nodeResource: {
id: 448, id: 448,
type: 'job_template',
name: 'Test JT', name: 'Test JT',
type: 'job_template',
},
}; };
const workflowContext = { const workflowContext = {
@@ -22,27 +32,40 @@ const workflowContext = {
id: 30, id: 30,
name: 'Foo JT', name: 'Foo JT',
type: 'job_template', type: 'job_template',
unified_job_type: 'job',
},
originalNodeObject: {
summary_fields: { unified_job_template: { id: 1, name: 'Job Template' } },
}, },
}, },
}; };
describe('NodeEditModal', () => { describe('NodeEditModal', () => {
test('Node modal confirmation dispatches as expected', () => { test('Node modal confirmation dispatches as expected', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}> <WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}> <WorkflowStateContext.Provider value={workflowContext}>
<NodeEditModal /> <NodeEditModal
onSave={() => {}}
askLinkType={false}
title="Edit Node"
/>
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
); );
act(() => { waitForElement(
wrapper.find('NodeModal').prop('onSave')(nodeResource); wrapper,
'WizardNavItem[content="ContentLoading"]',
el => el.length === 0
);
await act(async () => {
wrapper.find('NodeModal').prop('onSave')(values, {});
}); });
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
type: 'UPDATE_NODE',
node: { node: {
nodeResource, nodeResource: { id: 448, name: 'Test JT', type: 'job_template' },
}, },
type: 'UPDATE_NODE',
}); });
}); });
}); });

View File

@@ -1,86 +1,51 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React, { useContext, useState } from 'react'; import React, { useContext, useState, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, useFormikContext } from 'formik';
import yaml from 'js-yaml';
import { bool, node, func } from 'prop-types'; import { bool, node, func } from 'prop-types';
import { import {
Button, Button,
WizardContextConsumer, WizardContextConsumer,
WizardFooter, WizardFooter,
Form,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import ContentError from '../../../../../components/ContentError';
import ContentLoading from '../../../../../components/ContentLoading';
import useRequest, {
useDismissableError,
} from '../../../../../util/useRequest';
import mergeExtraVars from '../../../../../util/prompt/mergeExtraVars';
import getSurveyValues from '../../../../../util/prompt/getSurveyValues';
import { parseVariableField } from '../../../../../util/yaml';
import { import {
WorkflowDispatchContext, WorkflowDispatchContext,
WorkflowStateContext, WorkflowStateContext,
} from '../../../../../contexts/Workflow'; } from '../../../../../contexts/Workflow';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../../../api';
import Wizard from '../../../../../components/Wizard'; import Wizard from '../../../../../components/Wizard';
import { NodeTypeStep } from './NodeTypeStep'; import useWorkflowNodeSteps from './useWorkflowNodeSteps';
import RunStep from './RunStep'; import AlertModal from '../../../../../components/AlertModal';
import NodeNextButton from './NodeNextButton'; import NodeNextButton from './NodeNextButton';
function NodeModal({ askLinkType, i18n, onSave, title }) { function NodeModalForm({
askLinkType,
i18n,
onSave,
title,
credentialError,
launchConfig,
surveyConfig,
isLaunchLoading,
}) {
const history = useHistory(); const history = useHistory();
const dispatch = useContext(WorkflowDispatchContext); const dispatch = useContext(WorkflowDispatchContext);
const { nodeToEdit } = useContext(WorkflowStateContext); const { values, setTouched, validateForm } = useFormikContext();
let defaultApprovalDescription = '';
let defaultApprovalName = '';
let defaultApprovalTimeout = 0;
let defaultNodeResource = null;
let defaultNodeType = 'job_template';
if (nodeToEdit && nodeToEdit.unifiedJobTemplate) {
if (
nodeToEdit &&
nodeToEdit.unifiedJobTemplate &&
(nodeToEdit.unifiedJobTemplate.type ||
nodeToEdit.unifiedJobTemplate.unified_job_type)
) {
const ujtType =
nodeToEdit.unifiedJobTemplate.type ||
nodeToEdit.unifiedJobTemplate.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
defaultNodeType = 'job_template';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'project':
case 'project_update':
defaultNodeType = 'project_sync';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'inventory_source':
case 'inventory_update':
defaultNodeType = 'inventory_source_sync';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'workflow_job_template':
case 'workflow_job':
defaultNodeType = 'workflow_job_template';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'workflow_approval_template':
case 'workflow_approval':
defaultNodeType = 'approval';
defaultApprovalName = nodeToEdit.unifiedJobTemplate.name;
defaultApprovalDescription =
nodeToEdit.unifiedJobTemplate.description;
defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout;
break;
default:
}
}
}
const [approvalDescription, setApprovalDescription] = useState(
defaultApprovalDescription
);
const [approvalName, setApprovalName] = useState(defaultApprovalName);
const [approvalTimeout, setApprovalTimeout] = useState(
defaultApprovalTimeout
);
const [linkType, setLinkType] = useState('success');
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
const [nodeType, setNodeType] = useState(defaultNodeType);
const [triggerNext, setTriggerNext] = useState(0); const [triggerNext, setTriggerNext] = useState(0);
const clearQueryParams = () => { const clearQueryParams = () => {
@@ -93,20 +58,48 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
history.replace(`${history.location.pathname}?${otherParts.join('&')}`); history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
}; };
const {
steps: promptSteps,
visitStep,
visitAllSteps,
contentError,
} = useWorkflowNodeSteps(
launchConfig,
surveyConfig,
i18n,
values.nodeResource,
askLinkType
);
const handleSaveNode = () => { const handleSaveNode = () => {
clearQueryParams(); clearQueryParams();
if (values.nodeType !== 'workflow_approval_template') {
const resource = delete values.approvalName;
nodeType === 'approval' delete values.approvalDescription;
? { delete values.timeoutMinutes;
description: approvalDescription, delete values.timeoutSeconds;
name: approvalName,
timeout: approvalTimeout,
type: 'workflow_approval_template',
} }
: nodeResource;
onSave(resource, askLinkType ? linkType : null); if (
['job_template', 'workflow_job_template'].includes(values.nodeType) &&
(launchConfig.ask_variables_on_launch || launchConfig.survey_enabled)
) {
let extraVars;
const surveyValues = getSurveyValues(values);
const initialExtraVars =
launchConfig.ask_variables_on_launch && (values.extra_vars || '---');
if (surveyConfig?.spec) {
extraVars = yaml.safeDump(
mergeExtraVars(initialExtraVars, surveyValues)
);
} else {
extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {}));
}
values.extra_data = extraVars && parseVariableField(extraVars);
delete values.extra_vars;
}
onSave(values, launchConfig);
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -114,53 +107,15 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
dispatch({ type: 'CANCEL_NODE_MODAL' }); dispatch({ type: 'CANCEL_NODE_MODAL' });
}; };
const handleNodeTypeChange = newNodeType => { const { error, dismissError } = useDismissableError(
setNodeType(newNodeType); contentError || credentialError
setNodeResource(null); );
setApprovalName('');
setApprovalDescription('');
setApprovalTimeout(0);
};
const steps = [ const nextButtonText = activeStep =>
...(askLinkType activeStep.id === promptSteps[promptSteps?.length - 1]?.id ||
? [ activeStep.name === 'Preview'
{ ? i18n._(t`Save`)
name: i18n._(t`Run Type`), : i18n._(t`Next`);
key: 'run_type',
component: (
<RunStep linkType={linkType} onUpdateLinkType={setLinkType} />
),
enableNext: linkType !== null,
},
]
: []),
{
name: i18n._(t`Node Type`),
key: 'node_resource',
enableNext:
(nodeType !== 'approval' && nodeResource !== null) ||
(nodeType === 'approval' && approvalName !== ''),
component: (
<NodeTypeStep
description={approvalDescription}
name={approvalName}
nodeResource={nodeResource}
nodeType={nodeType}
onUpdateDescription={setApprovalDescription}
onUpdateName={setApprovalName}
onUpdateNodeResource={setNodeResource}
onUpdateNodeType={handleNodeTypeChange}
onUpdateTimeout={setApprovalTimeout}
timeout={approvalTimeout}
/>
),
},
];
steps.forEach((step, n) => {
step.id = n + 1;
});
const CustomFooter = ( const CustomFooter = (
<WizardFooter> <WizardFooter>
@@ -168,24 +123,28 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
{({ activeStep, onNext, onBack }) => ( {({ activeStep, onNext, onBack }) => (
<> <>
<NodeNextButton <NodeNextButton
isDisabled={isLaunchLoading}
triggerNext={triggerNext} triggerNext={triggerNext}
activeStep={activeStep} activeStep={activeStep}
aria-label={nextButtonText(activeStep)}
onNext={onNext} onNext={onNext}
onClick={() => setTriggerNext(triggerNext + 1)} onClick={() => setTriggerNext(triggerNext + 1)}
buttonText={ buttonText={nextButtonText(activeStep)}
activeStep.key === 'node_resource'
? i18n._(t`Save`)
: i18n._(t`Next`)
}
/> />
{activeStep && activeStep.id !== 1 && ( {activeStep && activeStep.id !== promptSteps[0]?.id && (
<Button id="back-node-modal" variant="secondary" onClick={onBack}> <Button
id="back-node-modal"
variant="secondary"
aria-label={i18n._(t`Back`)}
onClick={onBack}
>
{i18n._(t`Back`)} {i18n._(t`Back`)}
</Button> </Button>
)} )}
<Button <Button
id="cancel-node-modal" id="cancel-node-modal"
variant="link" variant="link"
aria-label={i18n._(t`Cancel`)}
onClick={handleCancel} onClick={handleCancel}
> >
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
@@ -196,21 +155,206 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
</WizardFooter> </WizardFooter>
); );
const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title; if (error) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
if (error && !isLaunchLoading) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
return ( return (
<Wizard <Wizard
footer={CustomFooter} footer={CustomFooter}
isOpen isOpen={!error}
onClose={handleCancel} onClose={handleCancel}
onSave={handleSaveNode} onSave={() => {
steps={steps} handleSaveNode();
}}
onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
steps={promptSteps}
css="overflow: scroll" css="overflow: scroll"
title={wizardTitle} title={title}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
/> />
); );
} }
const NodeModalInner = ({ i18n, title, ...rest }) => {
const { values } = useFormikContext();
const wizardTitle = values.nodeResource
? `${title} | ${values.nodeResource.name}`
: title;
const {
request: readLaunchConfigs,
error: launchConfigError,
result: { launchConfig, surveyConfig },
isLoading,
} = useRequest(
useCallback(async () => {
const readLaunch = (type, id) =>
type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(id)
: JobTemplatesAPI.readLaunch(id);
if (
!values.nodeResource ||
!['job_template', 'workflow_job_template'].includes(values?.nodeType) ||
!['job_template', 'workflow_job_template'].includes(
values.nodeResource?.type
)
) {
return {
launchConfig: {},
surveyConfig: {},
};
}
const { data: launch } = await readLaunch(
values.nodeType,
values?.nodeResource?.id
);
let survey = {};
if (launch.survey_enabled) {
const { data } = launch?.workflow_job_template_data
? await WorkflowJobTemplatesAPI.readSurvey(
launch?.workflow_job_template_data?.id
)
: await JobTemplatesAPI.readSurvey(launch?.job_template_data?.id);
survey = data;
}
return {
launchConfig: launch,
surveyConfig: survey,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [values.nodeResource, values.nodeType]),
{}
);
useEffect(() => {
readLaunchConfigs();
}, [readLaunchConfigs, values.nodeResource]);
const { error, dismissError } = useDismissableError(launchConfigError);
if (error) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
if (!launchConfig || !surveyConfig) {
return (
<Wizard
isOpen
steps={[
{
name: i18n._(t`Loading`),
component: <ContentLoading />,
},
]}
title={wizardTitle}
footer={<></>}
/>
);
}
return (
<NodeModalForm
{...rest}
launchConfig={launchConfig}
surveyConfig={surveyConfig}
isLaunchLoading={isLoading}
title={wizardTitle}
i18n={i18n}
/>
);
};
const NodeModal = ({ onSave, i18n, askLinkType, title }) => {
const { nodeToEdit } = useContext(WorkflowStateContext);
const onSaveForm = (values, config) => {
onSave(values, config);
};
return (
<Formik
initialValues={{
approvalName: '',
approvalDescription: '',
timeoutMinutes: 0,
timeoutSeconds: 0,
linkType: 'success',
nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null,
nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template',
}}
onSave={() => onSaveForm}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<NodeModalInner
onSave={onSaveForm}
i18n={i18n}
title={title}
askLinkType={askLinkType}
/>
</Form>
)}
</Formik>
);
};
NodeModal.propTypes = { NodeModal.propTypes = {
askLinkType: bool.isRequired, askLinkType: bool.isRequired,
onSave: func.isRequired, onSave: func.isRequired,

View File

@@ -16,6 +16,8 @@ import {
} from '../../../../../api'; } from '../../../../../api';
import NodeModal from './NodeModal'; import NodeModal from './NodeModal';
jest.mock('../../../../../api/models/Credentials');
jest.mock('../../../../../api/models/Inventories');
jest.mock('../../../../../api/models/InventorySources'); jest.mock('../../../../../api/models/InventorySources');
jest.mock('../../../../../api/models/JobTemplates'); jest.mock('../../../../../api/models/JobTemplates');
jest.mock('../../../../../api/models/Projects'); jest.mock('../../../../../api/models/Projects');
@@ -25,19 +27,81 @@ let wrapper;
const dispatch = jest.fn(); const dispatch = jest.fn();
const onSave = jest.fn(); const onSave = jest.fn();
const jtLaunchConfig = {
can_start_without_user_input: false,
passwords_needed_to_start: [],
ask_scm_branch_on_launch: false,
ask_variables_on_launch: true,
ask_tags_on_launch: true,
ask_diff_mode_on_launch: true,
ask_skip_tags_on_launch: true,
ask_job_type_on_launch: true,
ask_limit_on_launch: false,
ask_verbosity_on_launch: true,
ask_inventory_on_launch: true,
ask_credential_on_launch: true,
survey_enabled: true,
variables_needed_to_start: ['a'],
credential_needed_to_start: false,
inventory_needed_to_start: false,
job_template_data: {
name: 'A User-2 has admin permission',
id: 25,
description: '',
},
defaults: {
extra_vars: '---',
diff_mode: false,
limit: '',
job_tags: '',
skip_tags: '',
job_type: 'run',
verbosity: 0,
inventory: {
name: ' Inventory 1 Org 0',
id: 1,
},
credentials: [
{
id: 2,
name: ' Credential 2 User 1',
credential_type: 1,
passwords_needed: [],
},
{
id: 8,
name: 'vault cred',
credential_type: 3,
passwords_needed: [],
vault_id: '',
},
],
scm_branch: '',
},
};
const mockJobTemplate = {
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
summary_fields: {
inventory: {
name: 'Foo Inv',
id: 1,
},
recent_jobs: [],
},
related: { webhook_receiver: '' },
inventory: 1,
};
describe('NodeModal', () => { describe('NodeModal', () => {
beforeAll(() => { beforeAll(() => {
JobTemplatesAPI.read.mockResolvedValue({ JobTemplatesAPI.read.mockResolvedValue({
data: { data: {
count: 1, count: 1,
results: [ results: [mockJobTemplate],
{
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
},
],
}, },
}); });
JobTemplatesAPI.readOptions.mockResolvedValue({ JobTemplatesAPI.readOptions.mockResolvedValue({
@@ -49,6 +113,24 @@ describe('NodeModal', () => {
related_search_fields: [], related_search_fields: [],
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValue({ data: jtLaunchConfig });
JobTemplatesAPI.readSurvey.mockResolvedValue({
data: {
name: '',
description: '',
spec: [
{
question_name: 'Foo',
required: true,
variable: 'bar',
type: 'text',
default: 'answer',
},
],
type: 'text',
variable: 'bar',
},
});
ProjectsAPI.read.mockResolvedValue({ ProjectsAPI.read.mockResolvedValue({
data: { data: {
count: 1, count: 1,
@@ -116,6 +198,33 @@ describe('NodeModal', () => {
}, },
}); });
}); });
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
can_start_without_user_input: false,
defaults: {
extra_vars: '---',
inventory: {
name: null,
id: null,
},
limit: '',
scm_branch: '',
},
survey_enabled: false,
variables_needed_to_start: [],
node_templates_missing: [],
node_prompts_rejected: [272, 273],
workflow_job_template_data: {
name: 'jt',
id: 53,
description: '',
},
ask_variables_on_launch: false,
},
});
afterAll(() => { afterAll(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -137,8 +246,9 @@ describe('NodeModal', () => {
await waitForElement(wrapper, 'PFWizard'); await waitForElement(wrapper, 'PFWizard');
}); });
afterAll(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
onSave.mockClear();
}); });
test('Can successfully create a new job template node', async () => { test('Can successfully create a new job template node', async () => {
@@ -149,18 +259,58 @@ describe('NodeModal', () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
wrapper.update(); wrapper.update();
await act(async () => {
wrapper.find('Radio').simulate('click'); wrapper.find('Radio').simulate('click');
});
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
wrapper.update();
expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1);
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25);
wrapper.update();
expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next');
act(() => {
wrapper.find('StepName#preview-step').simulate('click');
});
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
wrapper.update();
expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1);
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25);
wrapper.update();
expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Save');
act(() => {
wrapper.find('NodeNextButton').prop('onClick')();
});
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
id: 1, linkType: 'always',
name: 'Test Job Template', nodeType: 'job_template',
type: 'job_template', inventory: { name: 'Foo Inv', id: 1 },
url: '/api/v2/job_templates/1', credentials: [],
job_type: '',
verbosity: '0',
job_tags: '',
skip_tags: '',
diff_mode: false,
survey_bar: 'answer',
nodeResource: mockJobTemplate,
extra_data: { bar: 'answer' },
}, },
'always' jtLaunchConfig
); );
}); });
@@ -173,21 +323,29 @@ describe('NodeModal', () => {
}); });
wrapper.update(); wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(null, 'project_sync'); wrapper.find('AnsibleSelect').prop('onChange')(null, 'project');
}); });
wrapper.update(); wrapper.update();
await act(async () => {
wrapper.find('Radio').simulate('click'); wrapper.find('Radio').simulate('click');
});
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
linkType: 'failure',
nodeResource: {
id: 1, id: 1,
name: 'Test Project', name: 'Test Project',
type: 'project', type: 'project',
url: '/api/v2/projects/1', url: '/api/v2/projects/1',
}, },
'failure' nodeType: 'project',
verbosity: undefined,
},
{}
); );
}); });
@@ -202,22 +360,30 @@ describe('NodeModal', () => {
await act(async () => { await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')( wrapper.find('AnsibleSelect').prop('onChange')(
null, null,
'inventory_source_sync' 'inventory_source'
); );
}); });
wrapper.update(); wrapper.update();
await act(async () => {
wrapper.find('Radio').simulate('click'); wrapper.find('Radio').simulate('click');
});
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
linkType: 'failure',
nodeResource: {
id: 1, id: 1,
name: 'Test Inventory Source', name: 'Test Inventory Source',
type: 'inventory_source', type: 'inventory_source',
url: '/api/v2/inventory_sources/1', url: '/api/v2/inventory_sources/1',
}, },
'failure' nodeType: 'inventory_source',
verbosity: undefined,
},
{}
); );
}); });
@@ -233,18 +399,47 @@ describe('NodeModal', () => {
); );
}); });
wrapper.update(); wrapper.update();
wrapper.find('Radio').simulate('click'); await act(async () => wrapper.find('Radio').simulate('click'));
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
linkType: 'success',
nodeResource: {
id: 1, id: 1,
name: 'Test Workflow Job Template', name: 'Test Workflow Job Template',
type: 'workflow_job_template', type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1', url: '/api/v2/workflow_job_templates/1',
}, },
'success' nodeType: 'workflow_job_template',
verbosity: undefined,
},
{
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_variables_on_launch: false,
can_start_without_user_input: false,
defaults: {
extra_vars: '---',
inventory: { id: null, name: null },
limit: '',
scm_branch: '',
},
node_prompts_rejected: [272, 273],
node_templates_missing: [],
survey_enabled: false,
variables_needed_to_start: [],
workflow_job_template_data: { description: '', id: 53, name: 'jt' },
}
); );
}); });
@@ -257,26 +452,26 @@ describe('NodeModal', () => {
}); });
wrapper.update(); wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval'); wrapper.find('AnsibleSelect').prop('onChange')(
null,
'workflow_approval_template'
);
}); });
wrapper.update(); wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('input#approval-name').simulate('change', { wrapper.find('input#approval-name').simulate('change', {
target: { value: 'Test Approval', name: 'name' }, target: { value: 'Test Approval', name: 'approvalName' },
}); });
wrapper.find('input#approval-description').simulate('change', { wrapper.find('input#approval-description').simulate('change', {
target: { value: 'Test Approval Description', name: 'description' }, target: {
value: 'Test Approval Description',
name: 'approvalDescription',
},
}); });
wrapper.find('input#approval-timeout-minutes').simulate('change', { wrapper.find('input#approval-timeout-minutes').simulate('change', {
target: { value: 5, name: 'timeoutMinutes' }, target: { value: 5, name: 'timeoutMinutes' },
}); });
});
// Updating the minutes and seconds is split to avoid a race condition.
// They both update the same state variable in the parent so triggering
// them syncronously creates flakey test results.
await act(async () => {
wrapper.find('input#approval-timeout-seconds').simulate('change', { wrapper.find('input#approval-timeout-seconds').simulate('change', {
target: { value: 30, name: 'timeoutSeconds' }, target: { value: 30, name: 'timeoutSeconds' },
}); });
@@ -301,12 +496,15 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
description: 'Test Approval Description', approvalDescription: 'Test Approval Description',
name: 'Test Approval', approvalName: 'Test Approval',
timeout: 330, linkType: 'always',
type: 'workflow_approval_template', nodeResource: null,
nodeType: 'workflow_approval_template',
timeoutMinutes: 5,
timeoutSeconds: 30,
}, },
'always' {}
); );
}); });
@@ -318,22 +516,24 @@ describe('NodeModal', () => {
}); });
}); });
describe('Edit existing node', () => { describe('Edit existing node', () => {
let newWrapper;
afterEach(() => { afterEach(() => {
wrapper.unmount(); newWrapper.unmount();
jest.clearAllMocks();
}); });
test('Can successfully change project sync node to workflow approval node', async () => { test('Can successfully change project sync node to workflow approval node', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( newWrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}> <WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider <WorkflowStateContext.Provider
value={{ value={{
nodeToEdit: { nodeToEdit: {
id: 2, id: 2,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 1, id: 1,
name: 'Test Project', name: 'Test Project',
unified_job_type: 'project_update', type: 'project',
}, },
}, },
}} }}
@@ -347,74 +547,78 @@ describe('NodeModal', () => {
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
); );
}); });
await waitForElement(wrapper, 'PFWizard'); await waitForElement(newWrapper, 'PFWizard');
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync'); newWrapper.update();
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe('project');
await act(async () => { await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval'); newWrapper.find('AnsibleSelect').prop('onChange')(
null,
'workflow_approval_template'
);
}); });
wrapper.update(); newWrapper.update();
await act(async () => { await act(async () => {
wrapper.find('input#approval-name').simulate('change', { newWrapper.find('input#approval-name').simulate('change', {
target: { value: 'Test Approval', name: 'name' }, target: { value: 'Test Approval', name: 'approvalName' },
}); });
wrapper.find('input#approval-description').simulate('change', { newWrapper.find('input#approval-description').simulate('change', {
target: { value: 'Test Approval Description', name: 'description' }, target: {
value: 'Test Approval Description',
name: 'approvalDescription',
},
}); });
wrapper.find('input#approval-timeout-minutes').simulate('change', { newWrapper.find('input#approval-timeout-minutes').simulate('change', {
target: { value: 5, name: 'timeoutMinutes' }, target: { value: 5, name: 'timeoutMinutes' },
}); });
}); newWrapper.find('input#approval-timeout-seconds').simulate('change', {
// Updating the minutes and seconds is split to avoid a race condition.
// They both update the same state variable in the parent so triggering
// them syncronously creates flakey test results.
await act(async () => {
wrapper.find('input#approval-timeout-seconds').simulate('change', {
target: { value: 30, name: 'timeoutSeconds' }, target: { value: 30, name: 'timeoutSeconds' },
}); });
}); });
wrapper.update(); newWrapper.update();
expect(wrapper.find('input#approval-name').prop('value')).toBe( expect(newWrapper.find('input#approval-name').prop('value')).toBe(
'Test Approval' 'Test Approval'
); );
expect(wrapper.find('input#approval-description').prop('value')).toBe( expect(newWrapper.find('input#approval-description').prop('value')).toBe(
'Test Approval Description' 'Test Approval Description'
); );
expect(wrapper.find('input#approval-timeout-minutes').prop('value')).toBe( expect(
5 newWrapper.find('input#approval-timeout-minutes').prop('value')
); ).toBe(5);
expect(wrapper.find('input#approval-timeout-seconds').prop('value')).toBe( expect(
30 newWrapper.find('input#approval-timeout-seconds').prop('value')
); ).toBe(30);
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); newWrapper.find('button#next-node-modal').simulate('click');
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
description: 'Test Approval Description', approvalDescription: 'Test Approval Description',
name: 'Test Approval', approvalName: 'Test Approval',
timeout: 330, linkType: 'success',
type: 'workflow_approval_template', nodeResource: null,
nodeType: 'workflow_approval_template',
timeoutMinutes: 5,
timeoutSeconds: 30,
}, },
null {}
); );
}); });
test('Can successfully change approval node to workflow job template node', async () => { test('Can successfully change approval node to workflow job template node', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( newWrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}> <WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider <WorkflowStateContext.Provider
value={{ value={{
nodeToEdit: { nodeToEdit: {
id: 2, id: 2,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 1, id: 1,
name: 'Test Approval', name: 'Test Approval',
description: 'Test Approval Description', description: 'Test Approval Description',
unified_job_type: 'workflow_approval', type: 'workflow_approval_template',
timeout: 0, timeout: 0,
}, },
}, },
@@ -429,27 +633,56 @@ describe('NodeModal', () => {
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
); );
}); });
await waitForElement(wrapper, 'PFWizard'); await waitForElement(newWrapper, 'PFWizard');
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval'); expect(newWrapper.find('AnsibleSelect').prop('value')).toBe(
'workflow_approval_template'
);
await act(async () => { await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')( newWrapper.find('AnsibleSelect').prop('onChange')(
null, null,
'workflow_job_template' 'workflow_job_template'
); );
}); });
wrapper.update(); newWrapper.update();
wrapper.find('Radio').simulate('click'); await act(async () => newWrapper.find('Radio').simulate('click'));
newWrapper.update();
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); newWrapper.find('button#next-node-modal').simulate('click');
});
newWrapper.update();
await act(async () => {
newWrapper.find('button#next-node-modal').simulate('click');
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
linkType: 'success',
nodeResource: {
id: 1, id: 1,
name: 'Test Workflow Job Template', name: 'Test Workflow Job Template',
type: 'workflow_job_template', type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1', url: '/api/v2/workflow_job_templates/1',
}, },
null nodeType: 'workflow_job_template',
},
{
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_variables_on_launch: false,
can_start_without_user_input: false,
defaults: {
extra_vars: '---',
inventory: { id: null, name: null },
limit: '',
scm_branch: '',
},
node_prompts_rejected: [272, 273],
node_templates_missing: [],
survey_enabled: false,
variables_needed_to_start: [],
workflow_job_template_data: { description: '', id: 53, name: 'jt' },
}
); );
}); });
}); });

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { func, number, shape, string } from 'prop-types'; import { func, oneOfType, number, shape, string } from 'prop-types';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
function NodeNextButton({ function NodeNextButton({
@@ -8,6 +8,7 @@ function NodeNextButton({
onClick, onClick,
onNext, onNext,
triggerNext, triggerNext,
isDisabled,
}) { }) {
useEffect(() => { useEffect(() => {
if (!triggerNext) { if (!triggerNext) {
@@ -22,7 +23,7 @@ function NodeNextButton({
variant="primary" variant="primary"
type="submit" type="submit"
onClick={() => onClick(activeStep)} onClick={() => onClick(activeStep)}
isDisabled={!activeStep.enableNext} isDisabled={isDisabled || !activeStep.enableNext}
> >
{buttonText} {buttonText}
</Button> </Button>
@@ -34,7 +35,7 @@ NodeNextButton.propTypes = {
buttonText: string.isRequired, buttonText: string.isRequired,
onClick: func.isRequired, onClick: func.isRequired,
onNext: func.isRequired, onNext: func.isRequired,
triggerNext: number.isRequired, triggerNext: oneOfType([string, number]).isRequired,
}; };
export default NodeNextButton; export default NodeNextButton;

View File

@@ -2,16 +2,18 @@ import 'styled-components/macro';
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import { func, number, shape, string } from 'prop-types';
import styled from 'styled-components'; import styled from 'styled-components';
import { Formik, Field } from 'formik'; import { useField } from 'formik';
import { Form, FormGroup, TextInput } from '@patternfly/react-core'; import { Form, FormGroup, TextInput } from '@patternfly/react-core';
import { required } from '../../../../../../util/validators';
import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; import { FormFullWidthLayout } from '../../../../../../components/FormLayout';
import AnsibleSelect from '../../../../../../components/AnsibleSelect'; import AnsibleSelect from '../../../../../../components/AnsibleSelect';
import InventorySourcesList from './InventorySourcesList'; import InventorySourcesList from './InventorySourcesList';
import JobTemplatesList from './JobTemplatesList'; import JobTemplatesList from './JobTemplatesList';
import ProjectsList from './ProjectsList'; import ProjectsList from './ProjectsList';
import WorkflowJobTemplatesList from './WorkflowJobTemplatesList'; import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
import FormField from '../../../../../../components/FormField';
const TimeoutInput = styled(TextInput)` const TimeoutInput = styled(TextInput)`
width: 200px; width: 200px;
@@ -25,19 +27,19 @@ const TimeoutLabel = styled.p`
margin-left: 10px; margin-left: 10px;
`; `;
function NodeTypeStep({ function NodeTypeStep({ i18n }) {
description, const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType');
i18n, const [nodeResourceField, , nodeResourceHelpers] = useField('nodeResource');
name, const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName');
nodeResource, const [, , approvalDescriptionHelpers] = useField('approvalDescription');
nodeType, const [timeoutMinutesField, , timeoutMinutesHelpers] = useField(
timeout, 'timeoutMinutes'
onUpdateDescription, );
onUpdateName, const [timeoutSecondsField, , timeoutSecondsHelpers] = useField(
onUpdateNodeResource, 'timeoutSeconds'
onUpdateNodeType, );
onUpdateTimeout,
}) { const isValid = !approvalNameMeta.touched || !approvalNameMeta.error;
return ( return (
<> <>
<div css="display: flex; align-items: center; margin-bottom: 20px;"> <div css="display: flex; align-items: center; margin-bottom: 20px;">
@@ -48,14 +50,14 @@ function NodeTypeStep({
label={i18n._(t`Select a Node Type`)} label={i18n._(t`Select a Node Type`)}
data={[ data={[
{ {
key: 'approval', key: 'workflow_approval_template',
value: 'approval', value: 'workflow_approval_template',
label: i18n._(t`Approval`), label: i18n._(t`Approval`),
isDisabled: false, isDisabled: false,
}, },
{ {
key: 'inventory_source_sync', key: 'inventory_source',
value: 'inventory_source_sync', value: 'inventory_source',
label: i18n._(t`Inventory Source Sync`), label: i18n._(t`Inventory Source Sync`),
isDisabled: false, isDisabled: false,
}, },
@@ -66,8 +68,8 @@ function NodeTypeStep({
isDisabled: false, isDisabled: false,
}, },
{ {
key: 'project_sync', key: 'project',
value: 'project_sync', value: 'project',
label: i18n._(t`Project Sync`), label: i18n._(t`Project Sync`),
isDisabled: false, isDisabled: false,
}, },
@@ -78,189 +80,98 @@ function NodeTypeStep({
isDisabled: false, isDisabled: false,
}, },
]} ]}
value={nodeType} value={nodeTypeField.value}
onChange={(e, val) => { onChange={(e, val) => {
onUpdateNodeType(val); nodeTypeHelpers.setValue(val);
nodeResourceHelpers.setValue(null);
approvalNameHelpers.setValue('');
approvalDescriptionHelpers.setValue('');
timeoutMinutesHelpers.setValue(0);
timeoutSecondsHelpers.setValue(0);
}} }}
/> />
</div> </div>
</div> </div>
{nodeType === 'job_template' && ( {nodeTypeField.value === 'job_template' && (
<JobTemplatesList <JobTemplatesList
nodeResource={nodeResource} nodeResource={nodeResourceField.value}
onUpdateNodeResource={onUpdateNodeResource} onUpdateNodeResource={nodeResourceHelpers.setValue}
/> />
)} )}
{nodeType === 'project_sync' && ( {nodeTypeField.value === 'project' && (
<ProjectsList <ProjectsList
nodeResource={nodeResource} nodeResource={nodeResourceField.value}
onUpdateNodeResource={onUpdateNodeResource} onUpdateNodeResource={nodeResourceHelpers.setValue}
/> />
)} )}
{nodeType === 'inventory_source_sync' && ( {nodeTypeField.value === 'inventory_source' && (
<InventorySourcesList <InventorySourcesList
nodeResource={nodeResource} nodeResource={nodeResourceField.value}
onUpdateNodeResource={onUpdateNodeResource} onUpdateNodeResource={nodeResourceHelpers.setValue}
/> />
)} )}
{nodeType === 'workflow_job_template' && ( {nodeTypeField.value === 'workflow_job_template' && (
<WorkflowJobTemplatesList <WorkflowJobTemplatesList
nodeResource={nodeResource} nodeResource={nodeResourceField.value}
onUpdateNodeResource={onUpdateNodeResource} onUpdateNodeResource={nodeResourceHelpers.setValue}
/> />
)} )}
{nodeType === 'approval' && ( {nodeTypeField.value === 'workflow_approval_template' && (
<Formik
initialValues={{
name: name || '',
description: description || '',
timeoutMinutes: Math.floor(timeout / 60),
timeoutSeconds: timeout - Math.floor(timeout / 60) * 60,
}}
>
{() => (
<Form css="margin-top: 20px;"> <Form css="margin-top: 20px;">
<FormFullWidthLayout> <FormFullWidthLayout>
<Field name="name"> <FormField
{({ field, form }) => { name="approvalName"
const isValid =
form &&
(!form.touched[field.name] || !form.errors[field.name]);
return (
<FormGroup
fieldId="approval-name"
isRequired
validated={isValid ? 'default' : 'error'}
label={i18n._(t`Name`)}
>
<TextInput
autoFocus
id="approval-name" id="approval-name"
isRequired isRequired
validate={required(null, i18n)}
validated={isValid ? 'default' : 'error'} validated={isValid ? 'default' : 'error'}
type="text" label={i18n._(t`Name`)}
{...field}
onChange={(value, evt) => {
onUpdateName(evt.target.value);
field.onChange(evt);
}}
/> />
</FormGroup> <FormField
); name="approvalDescription"
}}
</Field>
<Field name="description">
{({ field }) => (
<FormGroup
fieldId="approval-description"
label={i18n._(t`Description`)}
>
<TextInput
id="approval-description" id="approval-description"
type="text" label={i18n._(t`Description`)}
{...field}
onChange={(value, evt) => {
onUpdateDescription(evt.target.value);
field.onChange(evt);
}}
/> />
</FormGroup>
)}
</Field>
<FormGroup <FormGroup
label={i18n._(t`Timeout`)} label={i18n._(t`Timeout`)}
fieldId="approval-timeout" fieldId="approval-timeout"
name="timeout"
> >
<div css="display: flex;align-items: center;"> <div css="display: flex;align-items: center;">
<Field name="timeoutMinutes">
{({ field, form }) => (
<>
<TimeoutInput <TimeoutInput
{...timeoutMinutesField}
aria-label={i18n._(t`Timeout minutes`)}
id="approval-timeout-minutes" id="approval-timeout-minutes"
type="number"
min="0" min="0"
step="1" onChange={(value, event) => {
{...field} timeoutMinutesField.onChange(event);
onChange={(value, evt) => {
if (
!evt.target.value ||
evt.target.value === ''
) {
evt.target.value = 0;
}
onUpdateTimeout(
Number(evt.target.value) * 60 +
Number(form.values.timeoutSeconds)
);
field.onChange(evt);
}} }}
step="1"
type="number"
/> />
<TimeoutLabel> <TimeoutLabel>
<Trans>min</Trans> <Trans>min</Trans>
</TimeoutLabel> </TimeoutLabel>
</>
)}
</Field>
<Field name="timeoutSeconds">
{({ field, form }) => (
<>
<TimeoutInput <TimeoutInput
{...timeoutSecondsField}
aria-label={i18n._(t`Timeout seconds`)}
id="approval-timeout-seconds" id="approval-timeout-seconds"
type="number"
min="0" min="0"
step="1" onChange={(value, event) => {
{...field} timeoutSecondsField.onChange(event);
onChange={(value, evt) => {
if (
!evt.target.value ||
evt.target.value === ''
) {
evt.target.value = 0;
}
onUpdateTimeout(
Number(evt.target.value) +
Number(form.values.timeoutMinutes) * 60
);
field.onChange(evt);
}} }}
step="1"
type="number"
/> />
<TimeoutLabel> <TimeoutLabel>
<Trans>sec</Trans> <Trans>sec</Trans>
</TimeoutLabel> </TimeoutLabel>
</>
)}
</Field>
</div> </div>
</FormGroup> </FormGroup>
</FormFullWidthLayout> </FormFullWidthLayout>
</Form> </Form>
)} )}
</Formik>
)}
</> </>
); );
} }
NodeTypeStep.propTypes = {
description: string,
name: string,
nodeResource: shape(),
nodeType: string,
timeout: number,
onUpdateDescription: func.isRequired,
onUpdateName: func.isRequired,
onUpdateNodeResource: func.isRequired,
onUpdateNodeType: func.isRequired,
onUpdateTimeout: func.isRequired,
};
NodeTypeStep.defaultProps = {
description: '',
name: '',
nodeResource: null,
nodeType: 'job_template',
timeout: 0,
};
export default withI18n()(NodeTypeStep); export default withI18n()(NodeTypeStep);

View File

@@ -1,5 +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 { Formik } from 'formik';
import { mountWithContexts } from '../../../../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../../../../testUtils/enzymeHelpers';
import { import {
InventorySourcesAPI, InventorySourcesAPI,
@@ -7,6 +8,7 @@ import {
ProjectsAPI, ProjectsAPI,
WorkflowJobTemplatesAPI, WorkflowJobTemplatesAPI,
} from '../../../../../../api'; } from '../../../../../../api';
import NodeTypeStep from './NodeTypeStep'; import NodeTypeStep from './NodeTypeStep';
jest.mock('../../../../../../api/models/InventorySources'); jest.mock('../../../../../../api/models/InventorySources');
@@ -14,12 +16,6 @@ jest.mock('../../../../../../api/models/JobTemplates');
jest.mock('../../../../../../api/models/Projects'); jest.mock('../../../../../../api/models/Projects');
jest.mock('../../../../../../api/models/WorkflowJobTemplates'); jest.mock('../../../../../../api/models/WorkflowJobTemplates');
const onUpdateDescription = jest.fn();
const onUpdateName = jest.fn();
const onUpdateNodeResource = jest.fn();
const onUpdateNodeType = jest.fn();
const onUpdateTimeout = jest.fn();
describe('NodeTypeStep', () => { describe('NodeTypeStep', () => {
beforeAll(() => { beforeAll(() => {
JobTemplatesAPI.read.mockResolvedValue({ JobTemplatesAPI.read.mockResolvedValue({
@@ -118,90 +114,50 @@ describe('NodeTypeStep', () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<NodeTypeStep <Formik initialValues={{ nodeType: 'job_template' }}>
onUpdateDescription={onUpdateDescription} <NodeTypeStep />
onUpdateName={onUpdateName} </Formik>
onUpdateNodeResource={onUpdateNodeResource}
onUpdateNodeType={onUpdateNodeType}
onUpdateTimeout={onUpdateTimeout}
/>
); );
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template'); expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template');
expect(wrapper.find('JobTemplatesList').length).toBe(1); expect(wrapper.find('JobTemplatesList').length).toBe(1);
wrapper.find('Radio').simulate('click');
expect(onUpdateNodeResource).toHaveBeenCalledWith({
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
}); });
}); test('It shows the project list when node type is project', async () => {
test('It shows the project list when node type is project sync', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<NodeTypeStep <Formik initialValues={{ nodeType: 'project' }}>
nodeType="project_sync" <NodeTypeStep />
onUpdateDescription={onUpdateDescription} </Formik>
onUpdateName={onUpdateName}
onUpdateNodeResource={onUpdateNodeResource}
onUpdateNodeType={onUpdateNodeType}
onUpdateTimeout={onUpdateTimeout}
/>
); );
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync'); expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project');
expect(wrapper.find('ProjectsList').length).toBe(1); expect(wrapper.find('ProjectsList').length).toBe(1);
wrapper.find('Radio').simulate('click');
expect(onUpdateNodeResource).toHaveBeenCalledWith({
id: 1,
name: 'Test Project',
type: 'project',
url: '/api/v2/projects/1',
}); });
}); test('It shows the inventory source list when node type is inventory source', async () => {
test('It shows the inventory source list when node type is inventory source sync', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<NodeTypeStep <Formik initialValues={{ nodeType: 'inventory_source' }}>
nodeType="inventory_source_sync" <NodeTypeStep />
onUpdateDescription={onUpdateDescription} </Formik>
onUpdateName={onUpdateName}
onUpdateNodeResource={onUpdateNodeResource}
onUpdateNodeType={onUpdateNodeType}
onUpdateTimeout={onUpdateTimeout}
/>
); );
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('AnsibleSelect').prop('value')).toBe( expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
'inventory_source_sync' 'inventory_source'
); );
expect(wrapper.find('InventorySourcesList').length).toBe(1); expect(wrapper.find('InventorySourcesList').length).toBe(1);
wrapper.find('Radio').simulate('click');
expect(onUpdateNodeResource).toHaveBeenCalledWith({
id: 1,
name: 'Test Inventory Source',
type: 'inventory_source',
url: '/api/v2/inventory_sources/1',
});
}); });
test('It shows the workflow job template list when node type is workflow job template', async () => { test('It shows the workflow job template list when node type is workflow job template', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<NodeTypeStep <Formik initialValues={{ nodeType: 'workflow_job_template' }}>
nodeType="workflow_job_template" <NodeTypeStep />
onUpdateDescription={onUpdateDescription} </Formik>
onUpdateName={onUpdateName}
onUpdateNodeResource={onUpdateNodeResource}
onUpdateNodeType={onUpdateNodeType}
onUpdateTimeout={onUpdateTimeout}
/>
); );
}); });
wrapper.update(); wrapper.update();
@@ -209,67 +165,60 @@ describe('NodeTypeStep', () => {
'workflow_job_template' 'workflow_job_template'
); );
expect(wrapper.find('WorkflowJobTemplatesList').length).toBe(1); expect(wrapper.find('WorkflowJobTemplatesList').length).toBe(1);
wrapper.find('Radio').simulate('click');
expect(onUpdateNodeResource).toHaveBeenCalledWith({
id: 1,
name: 'Test Workflow Job Template',
type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1',
});
}); });
test('It shows the approval form fields when node type is approval', async () => { test('It shows the approval form fields when node type is approval', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<NodeTypeStep <Formik
nodeType="approval" initialValues={{
onUpdateDescription={onUpdateDescription} nodeType: 'workflow_approval_template',
onUpdateName={onUpdateName} approvalName: '',
onUpdateNodeResource={onUpdateNodeResource} approvalDescription: '',
onUpdateNodeType={onUpdateNodeType} timeoutMinutes: 0,
onUpdateTimeout={onUpdateTimeout} timeoutSeconds: 0,
/> }}
>
<NodeTypeStep />
</Formik>
); );
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval'); expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
expect(wrapper.find('input#approval-name').length).toBe(1); 'workflow_approval_template'
expect(wrapper.find('input#approval-description').length).toBe(1); );
expect(wrapper.find('input#approval-timeout-minutes').length).toBe(1); expect(wrapper.find('FormField[label="Name"]').length).toBe(1);
expect(wrapper.find('input#approval-timeout-seconds').length).toBe(1); expect(wrapper.find('FormField[label="Description"]').length).toBe(1);
expect(wrapper.find('input[name="timeoutMinutes"]').length).toBe(1);
expect(wrapper.find('input[name="timeoutSeconds"]').length).toBe(1);
await act(async () => { await act(async () => {
wrapper.find('input#approval-name').simulate('change', { wrapper.find('input#approval-name').simulate('change', {
target: { value: 'Test Approval', name: 'name' }, target: { value: 'Test Approval', name: 'approvalName' },
}); });
});
expect(onUpdateName).toHaveBeenCalledWith('Test Approval');
await act(async () => {
wrapper.find('input#approval-description').simulate('change', { wrapper.find('input#approval-description').simulate('change', {
target: { value: 'Test Approval Description', name: 'description' }, target: {
value: 'Test Approval Description',
name: 'approvalDescription',
},
}); });
}); wrapper.find('input[name="timeoutMinutes"]').simulate('change', {
expect(onUpdateDescription).toHaveBeenCalledWith(
'Test Approval Description'
);
await act(async () => {
wrapper.find('input#approval-timeout-minutes').simulate('change', {
target: { value: 5, name: 'timeoutMinutes' }, target: { value: 5, name: 'timeoutMinutes' },
}); });
}); wrapper.find('input[name="timeoutSeconds"]').simulate('change', {
expect(onUpdateTimeout).toHaveBeenCalledWith(300);
await act(async () => {
wrapper.find('input#approval-timeout-seconds').simulate('change', {
target: { value: 30, name: 'timeoutSeconds' }, target: { value: 30, name: 'timeoutSeconds' },
}); });
}); });
expect(onUpdateTimeout).toHaveBeenCalledWith(330); wrapper.update();
expect(wrapper.find('input#approval-name').prop('value')).toBe(
'Test Approval'
);
expect(wrapper.find('input#approval-description').prop('value')).toBe(
'Test Approval Description'
);
expect(wrapper.find('input[name="timeoutMinutes"]').prop('value')).toBe(5);
expect(wrapper.find('input[name="timeoutSeconds"]').prop('value')).toBe(30);
}); });
}); });

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import NodeTypeStep from './NodeTypeStep';
import StepName from '../../../../../../components/LaunchPrompt/steps/StepName';
const STEP_ID = 'nodeType';
export default function useNodeTypeStep(i18n) {
const [, meta] = useField('nodeType');
const [approvalNameField] = useField('approvalName');
const [nodeTypeField, ,] = useField('nodeType');
const [nodeResourceField] = useField('nodeResource');
return {
step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField),
initialValues: getInitialValues(),
isReady: true,
contentError: null,
formError: meta.error,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
},
};
}
function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {
const isEnabled = () => {
if (
(nodeTypeField.value !== 'workflow_approval_template' &&
nodeResourceField.value === null) ||
(nodeTypeField.value === 'workflow_approval_template' &&
approvalNameField.value === undefined)
) {
return false;
}
return true;
};
return {
id: STEP_ID,
name: (
<StepName hasErrors={false} id="node-type-step">
{i18n._(t`Node type`)}
</StepName>
),
component: <NodeTypeStep i18n={i18n} />,
enableNext: isEnabled(),
};
}
function getInitialValues() {
return {
approvalName: '',
approvalDescription: '',
timeoutMinutes: 0,
timeoutSeconds: 0,
nodeType: 'job_template',
};
}

View File

@@ -11,41 +11,27 @@ import ContentError from '../../../../../components/ContentError';
import ContentLoading from '../../../../../components/ContentLoading'; import ContentLoading from '../../../../../components/ContentLoading';
import PromptDetail from '../../../../../components/PromptDetail'; import PromptDetail from '../../../../../components/PromptDetail';
import useRequest from '../../../../../util/useRequest'; import useRequest from '../../../../../util/useRequest';
import { import { jsonToYaml } from '../../../../../util/yaml';
InventorySourcesAPI, import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../../../api';
JobTemplatesAPI, import getNodeType from '../../shared/WorkflowJobTemplateVisualizerUtils';
ProjectsAPI,
WorkflowJobTemplatesAPI,
} from '../../../../../api';
function getNodeType(node) {
const ujtType = node.type || node.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
return ['job_template', JobTemplatesAPI];
case 'project':
case 'project_update':
return ['project_sync', ProjectsAPI];
case 'inventory_source':
case 'inventory_update':
return ['inventory_source_sync', InventorySourcesAPI];
case 'workflow_job_template':
case 'workflow_job':
return ['workflow_job_template', WorkflowJobTemplatesAPI];
case 'workflow_approval_template':
case 'workflow_approval':
return ['approval', null];
default:
return null;
}
}
function NodeViewModal({ i18n, readOnly }) { function NodeViewModal({ i18n, readOnly }) {
const dispatch = useContext(WorkflowDispatchContext); const dispatch = useContext(WorkflowDispatchContext);
const { nodeToView } = useContext(WorkflowStateContext); const { nodeToView } = useContext(WorkflowStateContext);
const { unifiedJobTemplate } = nodeToView; const {
const [nodeType, nodeAPI] = getNodeType(unifiedJobTemplate); fullUnifiedJobTemplate,
originalNodeCredentials,
originalNodeObject,
promptValues,
} = nodeToView;
const [nodeType, nodeAPI] = getNodeType(
fullUnifiedJobTemplate ||
originalNodeObject?.summary_fields?.unified_job_template
);
const id =
fullUnifiedJobTemplate?.id ||
originalNodeObject?.summary_fields?.unified_job_template.id;
const { const {
result: launchConfig, result: launchConfig,
@@ -56,39 +42,44 @@ function NodeViewModal({ i18n, readOnly }) {
useCallback(async () => { useCallback(async () => {
const readLaunch = const readLaunch =
nodeType === 'workflow_job_template' nodeType === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(unifiedJobTemplate.id) ? WorkflowJobTemplatesAPI.readLaunch(id)
: JobTemplatesAPI.readLaunch(unifiedJobTemplate.id); : JobTemplatesAPI.readLaunch(id);
const { data } = await readLaunch; const { data } = await readLaunch;
return data; return data;
}, [nodeType, unifiedJobTemplate.id]), }, [nodeType, id]),
{} {}
); );
const { const {
result: nodeDetail, result: relatedData,
isLoading: isNodeDetailLoading, isLoading: isRelatedDataLoading,
error: nodeDetailError, error: relatedDataError,
request: fetchNodeDetail, request: fetchRelatedData,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
let { data } = await nodeAPI?.readDetail(unifiedJobTemplate.id); const related = {};
if (
if (data?.type === 'job_template') { nodeType === 'job_template' &&
!fullUnifiedJobTemplate.instance_groups
) {
const { const {
data: { results = [] }, data: { results = [] },
} = await JobTemplatesAPI.readInstanceGroups(data.id); } = await JobTemplatesAPI.readInstanceGroups(fullUnifiedJobTemplate.id);
data = Object.assign(data, { instance_groups: results }); related.instance_groups = results;
} }
if (data?.related?.webhook_receiver) { if (
fullUnifiedJobTemplate?.related?.webhook_receiver &&
!fullUnifiedJobTemplate.webhook_key
) {
const { const {
data: { webhook_key }, data: { webhook_key },
} = await nodeAPI?.readWebhookKey(data.id); } = await nodeAPI?.readWebhookKey(fullUnifiedJobTemplate.id);
data = Object.assign(data, { webhook_key }); related.webhook_key = webhook_key;
} }
return data; return related;
}, [nodeAPI, unifiedJobTemplate.id]), }, [nodeAPI, fullUnifiedJobTemplate, nodeType]),
null null
); );
@@ -97,21 +88,27 @@ function NodeViewModal({ i18n, readOnly }) {
fetchLaunchConfig(); fetchLaunchConfig();
} }
if (unifiedJobTemplate.unified_job_type && nodeType !== 'approval') { if (
fetchNodeDetail(); fullUnifiedJobTemplate &&
((nodeType === 'job_template' &&
!fullUnifiedJobTemplate.instance_groups) ||
(fullUnifiedJobTemplate?.related?.webhook_receiver &&
!fullUnifiedJobTemplate.webhook_key))
) {
fetchRelatedData();
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
if (nodeDetail) { if (relatedData) {
dispatch({ dispatch({
type: 'REFRESH_NODE', type: 'REFRESH_NODE',
node: { node: {
nodeResource: nodeDetail, fullUnifiedJobTemplate: { ...fullUnifiedJobTemplate, ...relatedData },
}, },
}); });
} }
}, [nodeDetail]); // eslint-disable-line react-hooks/exhaustive-deps }, [relatedData]); // eslint-disable-line react-hooks/exhaustive-deps
const handleEdit = () => { const handleEdit = () => {
dispatch({ type: 'SET_NODE_TO_VIEW', value: null }); dispatch({ type: 'SET_NODE_TO_VIEW', value: null });
@@ -119,13 +116,74 @@ function NodeViewModal({ i18n, readOnly }) {
}; };
let Content; let Content;
if (isLaunchConfigLoading || isNodeDetailLoading) { if (isLaunchConfigLoading || isRelatedDataLoading) {
Content = <ContentLoading />; Content = <ContentLoading />;
} else if (launchConfigError || nodeDetailError) { } else if (launchConfigError || relatedDataError) {
Content = <ContentError error={launchConfigError || nodeDetailError} />; Content = <ContentError error={launchConfigError || relatedDataError} />;
} else { } else if (!fullUnifiedJobTemplate) {
Content = ( Content = (
<PromptDetail launchConfig={launchConfig} resource={unifiedJobTemplate} /> <p>
{i18n._(t`The resource associated with this node has been deleted.`)}
&nbsp;&nbsp;
{!readOnly
? i18n._(t`Click the Edit button below to reconfigure the node.`)
: ''}
</p>
);
} else {
let overrides = {};
if (promptValues) {
overrides = promptValues;
if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) {
overrides.extra_vars = jsonToYaml(
JSON.stringify(promptValues.extra_data)
);
}
} else if (
fullUnifiedJobTemplate.id === originalNodeObject?.unified_job_template
) {
if (launchConfig.ask_inventory_on_launch) {
overrides.inventory = originalNodeObject.summary_fields.inventory;
}
if (launchConfig.ask_scm_branch_on_launch) {
overrides.scm_branch = originalNodeObject.scm_branch;
}
if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) {
overrides.extra_vars = jsonToYaml(
JSON.stringify(originalNodeObject.extra_data)
);
}
if (launchConfig.ask_tags_on_launch) {
overrides.job_tags = originalNodeObject.job_tags;
}
if (launchConfig.ask_diff_mode_on_launch) {
overrides.diff_mode = originalNodeObject.diff_mode;
}
if (launchConfig.ask_skip_tags_on_launch) {
overrides.skip_tags = originalNodeObject.skip_tags;
}
if (launchConfig.ask_job_type_on_launch) {
overrides.job_type = originalNodeObject.job_type;
}
if (launchConfig.ask_limit_on_launch) {
overrides.limit = originalNodeObject.limit;
}
if (launchConfig.ask_verbosity_on_launch) {
overrides.verbosity = originalNodeObject.verbosity.toString();
}
if (launchConfig.ask_credential_on_launch) {
overrides.credentials = originalNodeCredentials || [];
}
}
Content = (
<PromptDetail
launchConfig={launchConfig}
resource={fullUnifiedJobTemplate}
overrides={overrides}
/>
); );
} }
@@ -133,7 +191,7 @@ function NodeViewModal({ i18n, readOnly }) {
<Modal <Modal
variant="large" variant="large"
isOpen isOpen
title={unifiedJobTemplate.name} title={fullUnifiedJobTemplate?.name || i18n._(t`Resource deleted`)}
aria-label={i18n._(t`Workflow node view modal`)} aria-label={i18n._(t`Workflow node view modal`)}
onClose={() => dispatch({ type: 'SET_NODE_TO_VIEW', value: null })} onClose={() => dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
actions={ actions={

View File

@@ -53,13 +53,16 @@ describe('NodeViewModal', () => {
let wrapper; let wrapper;
const workflowContext = { const workflowContext = {
nodeToView: { nodeToView: {
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 1, id: 1,
name: 'Mock Node', name: 'Mock Node',
description: '', description: '',
unified_job_type: 'workflow_job', unified_job_type: 'workflow_job',
created: '2019-08-08T19:24:05.344276Z', created: '2019-08-08T19:24:05.344276Z',
modified: '2019-08-08T19:24:18.162949Z', modified: '2019-08-08T19:24:18.162949Z',
related: {
webhook_receiver: '/api/v2/workflow_job_templates/2/github/',
},
}, },
}, },
}; };
@@ -88,7 +91,6 @@ describe('NodeViewModal', () => {
test('should fetch workflow template launch data', () => { test('should fetch workflow template launch data', () => {
expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readDetail).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled(); expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled();
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
expect(WorkflowJobTemplatesAPI.readWebhookKey).toHaveBeenCalledWith(1); expect(WorkflowJobTemplatesAPI.readWebhookKey).toHaveBeenCalledWith(1);
@@ -118,7 +120,7 @@ describe('NodeViewModal', () => {
describe('Job template node', () => { describe('Job template node', () => {
const workflowContext = { const workflowContext = {
nodeToView: { nodeToView: {
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 1, id: 1,
name: 'Mock Node', name: 'Mock Node',
description: '', description: '',
@@ -145,7 +147,6 @@ describe('NodeViewModal', () => {
expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readWebhookKey).not.toHaveBeenCalledWith(); expect(JobTemplatesAPI.readWebhookKey).not.toHaveBeenCalledWith();
expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
expect(JobTemplatesAPI.readDetail).toHaveBeenCalledWith(1);
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalledTimes(1); expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
wrapper.unmount(); wrapper.unmount();
jest.clearAllMocks(); jest.clearAllMocks();
@@ -207,7 +208,7 @@ describe('NodeViewModal', () => {
describe('Project node', () => { describe('Project node', () => {
const workflowContext = { const workflowContext = {
nodeToView: { nodeToView: {
unifiedJobTemplate: { fullUnifiedJobTemplate: {
id: 1, id: 1,
name: 'Mock Node', name: 'Mock Node',
description: '', description: '',
@@ -237,4 +238,71 @@ describe('NodeViewModal', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
}); });
describe('Inventory Source node', () => {
const workflowContext = {
nodeToView: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
type: 'inventory_source',
created: '2019-08-08T19:24:05.344276Z',
modified: '2019-08-08T19:24:18.162949Z',
},
},
};
test('should not fetch launch data', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}>
<NodeViewModal />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
waitForLoaded(wrapper);
expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled();
wrapper.unmount();
jest.clearAllMocks();
});
});
describe('Approval node', () => {
const workflowContext = {
nodeToView: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
type: 'workflow_approval_template',
timeout: 0,
},
},
};
test('should not fetch launch data', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}>
<NodeViewModal />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
waitForLoaded(wrapper);
expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled();
wrapper.unmount();
jest.clearAllMocks();
});
});
}); });

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { useField } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { func, string } from 'prop-types';
import { Title } from '@patternfly/react-core'; import { Title } from '@patternfly/react-core';
import SelectableCard from '../../../../../components/SelectableCard'; import SelectableCard from '../../../../../components/SelectableCard';
@@ -16,7 +16,8 @@ const Grid = styled.div`
width: 100%; width: 100%;
`; `;
function RunStep({ i18n, linkType, onUpdateLinkType }) { function RunStep({ i18n }) {
const [field, , helpers] = useField('linkType');
return ( return (
<> <>
<Title headingLevel="h1" size="xl"> <Title headingLevel="h1" size="xl">
@@ -30,39 +31,33 @@ function RunStep({ i18n, linkType, onUpdateLinkType }) {
<Grid> <Grid>
<SelectableCard <SelectableCard
id="link-type-success" id="link-type-success"
isSelected={linkType === 'success'} isSelected={field.value === 'success'}
label={i18n._(t`On Success`)} label={i18n._(t`On Success`)}
description={i18n._( description={i18n._(
t`Execute when the parent node results in a successful state.` t`Execute when the parent node results in a successful state.`
)} )}
onClick={() => onUpdateLinkType('success')} onClick={() => helpers.setValue('success')}
/> />
<SelectableCard <SelectableCard
id="link-type-failure" id="link-type-failure"
isSelected={linkType === 'failure'} isSelected={field.value === 'failure'}
label={i18n._(t`On Failure`)} label={i18n._(t`On Failure`)}
description={i18n._( description={i18n._(
t`Execute when the parent node results in a failure state.` t`Execute when the parent node results in a failure state.`
)} )}
onClick={() => onUpdateLinkType('failure')} onClick={() => helpers.setValue('failure')}
/> />
<SelectableCard <SelectableCard
id="link-type-always" id="link-type-always"
isSelected={linkType === 'always'} isSelected={field.value === 'always'}
label={i18n._(t`Always`)} label={i18n._(t`Always`)}
description={i18n._( description={i18n._(
t`Execute regardless of the parent node's final state.` t`Execute regardless of the parent node's final state.`
)} )}
onClick={() => onUpdateLinkType('always')} onClick={() => helpers.setValue('always')}
/> />
</Grid> </Grid>
</> </>
); );
} }
RunStep.propTypes = {
linkType: string.isRequired,
onUpdateLinkType: func.isRequired,
};
export default withI18n()(RunStep); export default withI18n()(RunStep);

View File

@@ -1,15 +1,18 @@
import React from 'react'; import React from 'react';
import { Formik } from 'formik';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers';
import RunStep from './RunStep'; import RunStep from './RunStep';
let wrapper; let wrapper;
const linkType = 'always';
const onUpdateLinkType = jest.fn();
describe('RunStep', () => { describe('RunStep', () => {
beforeAll(() => { beforeAll(() => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<RunStep linkType={linkType} onUpdateLinkType={onUpdateLinkType} /> <Formik initialValues={{ linkType: 'success' }}>
<RunStep />
</Formik>
); );
}); });
@@ -18,23 +21,20 @@ describe('RunStep', () => {
}); });
test('Default selected card matches default link type when present', () => { test('Default selected card matches default link type when present', () => {
expect(wrapper.find('#link-type-success').props().isSelected).toBe(false); expect(wrapper.find('#link-type-success').props().isSelected).toBe(true);
expect(wrapper.find('#link-type-failure').props().isSelected).toBe(false); expect(wrapper.find('#link-type-failure').props().isSelected).toBe(false);
expect(wrapper.find('#link-type-always').props().isSelected).toBe(false);
});
test('Clicking success card makes expected callback', async () => {
await act(async () => wrapper.find('#link-type-always').simulate('click'));
wrapper.update();
expect(wrapper.find('#link-type-always').props().isSelected).toBe(true); expect(wrapper.find('#link-type-always').props().isSelected).toBe(true);
}); });
test('Clicking success card makes expected callback', () => { test('Clicking failure card makes expected callback', async () => {
wrapper.find('#link-type-success').simulate('click'); await act(async () => wrapper.find('#link-type-failure').simulate('click'));
expect(onUpdateLinkType).toHaveBeenCalledWith('success'); wrapper.update();
}); expect(wrapper.find('#link-type-failure').props().isSelected).toBe(true);
test('Clicking failure card makes expected callback', () => {
wrapper.find('#link-type-failure').simulate('click');
expect(onUpdateLinkType).toHaveBeenCalledWith('failure');
});
test('Clicking always card makes expected callback', () => {
wrapper.find('#link-type-always').simulate('click');
expect(onUpdateLinkType).toHaveBeenCalledWith('always');
}); });
}); });

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import RunStep from './RunStep';
import StepName from '../../../../../components/LaunchPrompt/steps/StepName';
const STEP_ID = 'runType';
export default function useRunTypeStep(i18n, askLinkType) {
const [, meta] = useField('linkType');
return {
step: getStep(askLinkType, meta, i18n),
initialValues: askLinkType ? { linkType: 'success' } : {},
isReady: true,
contentError: null,
formError: meta.error,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
},
};
}
function getStep(askLinkType, meta, i18n) {
if (!askLinkType) {
return null;
}
return {
id: STEP_ID,
name: (
<StepName hasErrors={false} id="run-type-step">
{i18n._(t`Run type`)}
</StepName>
),
component: <RunStep />,
enableNext: meta.value !== '',
};
}

View File

@@ -0,0 +1,270 @@
import { useContext, useState, useEffect } from 'react';
import { useFormikContext } from 'formik';
import useInventoryStep from '../../../../../components/LaunchPrompt/steps/useInventoryStep';
import useCredentialsStep from '../../../../../components/LaunchPrompt/steps/useCredentialsStep';
import useOtherPromptsStep from '../../../../../components/LaunchPrompt/steps/useOtherPromptsStep';
import useSurveyStep from '../../../../../components/LaunchPrompt/steps/useSurveyStep';
import usePreviewStep from '../../../../../components/LaunchPrompt/steps/usePreviewStep';
import { WorkflowStateContext } from '../../../../../contexts/Workflow';
import { jsonToYaml } from '../../../../../util/yaml';
import useNodeTypeStep from './NodeTypeStep/useNodeTypeStep';
import useRunTypeStep from './useRunTypeStep';
function showPreviewStep(nodeType, launchConfig) {
if (
!['workflow_job_template', 'job_template'].includes(nodeType) ||
Object.keys(launchConfig).length === 0
) {
return false;
}
return (
!launchConfig.can_start_without_user_input ||
launchConfig.ask_inventory_on_launch ||
launchConfig.ask_variables_on_launch ||
launchConfig.ask_limit_on_launch ||
launchConfig.ask_scm_branch_on_launch ||
launchConfig.survey_enabled ||
(launchConfig.variables_needed_to_start &&
launchConfig.variables_needed_to_start.length > 0)
);
}
const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => {
const initialValues = {
nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null,
nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template',
};
if (
nodeToEdit?.fullUnifiedJobTemplate?.type === 'workflow_approval_template'
) {
const timeout = nodeToEdit.fullUnifiedJobTemplate.timeout || 0;
initialValues.approvalName = nodeToEdit.fullUnifiedJobTemplate.name || '';
initialValues.approvalDescription =
nodeToEdit.fullUnifiedJobTemplate.description || '';
initialValues.timeoutMinutes = Math.floor(timeout / 60);
initialValues.timeoutSeconds = timeout - Math.floor(timeout / 60) * 60;
return initialValues;
}
if (!launchConfig || launchConfig === {}) {
return initialValues;
}
if (launchConfig.ask_inventory_on_launch) {
// We also need to handle the case where the UJT has been deleted.
if (nodeToEdit?.promptValues) {
initialValues.inventory = nodeToEdit?.promptValues?.inventory;
} else if (nodeToEdit?.originalNodeObject?.summary_fields?.inventory) {
initialValues.inventory =
nodeToEdit?.originalNodeObject?.summary_fields?.inventory;
} else {
initialValues.inventory = null;
}
}
if (launchConfig.ask_credential_on_launch) {
if (nodeToEdit?.promptValues?.credentials) {
initialValues.credentials = nodeToEdit?.promptValues?.credentials;
} else if (nodeToEdit?.originalNodeCredentials) {
const defaultCredsWithoutOverrides = [];
const credentialHasScheduleOverride = templateDefaultCred => {
let credentialHasOverride = false;
nodeToEdit.originalNodeCredentials.forEach(scheduleCred => {
if (
templateDefaultCred.credential_type === scheduleCred.credential_type
) {
if (
(!templateDefaultCred.vault_id &&
!scheduleCred.inputs.vault_id) ||
(templateDefaultCred.vault_id &&
scheduleCred.inputs.vault_id &&
templateDefaultCred.vault_id === scheduleCred.inputs.vault_id)
) {
credentialHasOverride = true;
}
}
});
return credentialHasOverride;
};
if (nodeToEdit?.fullUnifiedJobTemplate?.summary_fields?.credentials) {
nodeToEdit.fullUnifiedJobTemplate.summary_fields.credentials.forEach(
defaultCred => {
if (!credentialHasScheduleOverride(defaultCred)) {
defaultCredsWithoutOverrides.push(defaultCred);
}
}
);
}
initialValues.credentials = nodeToEdit.originalNodeCredentials.concat(
defaultCredsWithoutOverrides
);
} else {
initialValues.credentials = [];
}
}
const sourceOfValues =
nodeToEdit?.promptValues || nodeToEdit.originalNodeObject;
if (launchConfig.ask_job_type_on_launch) {
initialValues.job_type = sourceOfValues?.job_type || '';
}
if (launchConfig.ask_limit_on_launch) {
initialValues.limit = sourceOfValues?.limit || '';
}
if (launchConfig.ask_verbosity_on_launch) {
initialValues.verbosity = sourceOfValues?.verbosity || 0;
}
if (launchConfig.ask_tags_on_launch) {
initialValues.job_tags = sourceOfValues?.job_tags || '';
}
if (launchConfig.ask_skip_tags_on_launch) {
initialValues.skip_tags = sourceOfValues?.skip_tags || '';
}
if (launchConfig.ask_scm_branch_on_launch) {
initialValues.scm_branch = sourceOfValues?.scm_branch || '';
}
if (launchConfig.ask_diff_mode_on_launch) {
initialValues.diff_mode = sourceOfValues?.diff_mode || false;
}
if (launchConfig.ask_variables_on_launch) {
const newExtraData = { ...sourceOfValues.extra_data };
if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
if (
Object.prototype.hasOwnProperty.call(newExtraData, question.variable)
) {
delete newExtraData[question.variable];
}
});
}
initialValues.extra_vars = jsonToYaml(JSON.stringify(newExtraData));
}
if (surveyConfig?.spec) {
surveyConfig.spec.forEach(question => {
if (question.type === 'multiselect') {
initialValues[`survey_${question.variable}`] = question.default.split(
'\n'
);
} else {
initialValues[`survey_${question.variable}`] = question.default;
}
if (sourceOfValues?.extra_data) {
Object.entries(sourceOfValues?.extra_data).forEach(([key, value]) => {
if (key === question.variable) {
if (question.type === 'multiselect') {
initialValues[`survey_${question.variable}`] = value;
} else {
initialValues[`survey_${question.variable}`] = value;
}
}
});
}
});
}
return initialValues;
};
export default function useWorkflowNodeSteps(
launchConfig,
surveyConfig,
i18n,
resource,
askLinkType
) {
const { nodeToEdit } = useContext(WorkflowStateContext);
const { resetForm, values: formikValues } = useFormikContext();
const [visited, setVisited] = useState({});
const steps = [
useRunTypeStep(i18n, askLinkType),
useNodeTypeStep(i18n),
useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n),
useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
];
const hasErrors = steps.some(step => step.formError);
steps.push(
usePreviewStep(
launchConfig,
i18n,
resource,
surveyConfig,
hasErrors,
showPreviewStep(formikValues.nodeType, launchConfig)
)
);
const pfSteps = steps.map(s => s.step).filter(s => s != null);
const isReady = !steps.some(s => !s.isReady);
useEffect(() => {
if (launchConfig && surveyConfig && isReady) {
let initialValues = {};
if (
nodeToEdit &&
nodeToEdit?.fullUnifiedJobTemplate &&
nodeToEdit?.fullUnifiedJobTemplate?.id === formikValues.nodeResource?.id
) {
initialValues = getNodeToEditDefaultValues(
launchConfig,
surveyConfig,
nodeToEdit
);
} else {
initialValues = steps.reduce((acc, cur) => {
return {
...acc,
...cur.initialValues,
};
}, {});
}
resetForm({
values: {
...initialValues,
nodeResource: formikValues.nodeResource,
nodeType: formikValues.nodeType,
linkType: formikValues.linkType,
verbosity: initialValues?.verbosity?.toString(),
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [launchConfig, surveyConfig, isReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
return {
steps: pfSteps,
visitStep: stepId =>
setVisited({
...visited,
[stepId]: true,
}),
visitAllSteps: setFieldsTouched => {
setVisited({
inventory: true,
credentials: true,
other: true,
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
},
contentError,
};
}

View File

@@ -1,16 +1,21 @@
import React, { useEffect, useReducer } from 'react'; import React, { useCallback, useEffect, useReducer } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import styled from 'styled-components'; import styled from 'styled-components';
import { shape } from 'prop-types'; import { shape } from 'prop-types';
import { t } from '@lingui/macro';
import { import {
WorkflowDispatchContext, WorkflowDispatchContext,
WorkflowStateContext, WorkflowStateContext,
} from '../../../contexts/Workflow'; } from '../../../contexts/Workflow';
import { getAddedAndRemoved } from '../../../util/lists';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import { layoutGraph } from '../../../components/Workflow/WorkflowUtils'; import { layoutGraph } from '../../../components/Workflow/WorkflowUtils';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import workflowReducer from '../../../components/Workflow/workflowReducer'; import workflowReducer from '../../../components/Workflow/workflowReducer';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { DeleteAllNodesModal, UnsavedChangesModal } from './Modals'; import { DeleteAllNodesModal, UnsavedChangesModal } from './Modals';
import { import {
LinkAddModal, LinkAddModal,
@@ -46,6 +51,45 @@ const Wrapper = styled.div`
height: 100%; height: 100%;
`; `;
const getAggregatedCredentials = (
originalNodeOverride = [],
templateDefaultCredentials = []
) => {
let theArray = [];
const isCredentialOverriden = templateDefaultCred => {
let credentialHasOverride = false;
originalNodeOverride.forEach(overrideCred => {
if (
templateDefaultCred.credential_type === overrideCred.credential_type
) {
if (
(!templateDefaultCred.vault_id && !overrideCred.inputs.vault_id) ||
(templateDefaultCred.vault_id &&
overrideCred.inputs.vault_id &&
templateDefaultCred.vault_id === overrideCred.inputs.vault_id)
) {
credentialHasOverride = true;
}
}
});
return credentialHasOverride;
};
if (templateDefaultCredentials.length > 0) {
templateDefaultCredentials.forEach(defaultCred => {
if (!isCredentialOverriden(defaultCred)) {
theArray.push(defaultCred);
}
});
}
theArray = theArray.concat(originalNodeOverride);
return theArray;
};
const fetchWorkflowNodes = async ( const fetchWorkflowNodes = async (
templateId, templateId,
pageNo = 1, pageNo = 1,
@@ -206,7 +250,49 @@ function Visualizer({ template, i18n }) {
return disassociateNodeRequests; return disassociateNodeRequests;
}; };
const generateLinkMapAndNewLinks = originalLinkMap => { useEffect(() => {
async function fetchData() {
try {
const workflowNodes = await fetchWorkflowNodes(template.id);
dispatch({
type: 'GENERATE_NODES_AND_LINKS',
nodes: workflowNodes,
i18n,
});
} catch (error) {
dispatch({ type: 'SET_CONTENT_ERROR', value: error });
} finally {
dispatch({ type: 'SET_IS_LOADING', value: false });
}
}
fetchData();
}, [template.id, i18n]);
// Update positions of nodes/links
useEffect(() => {
if (nodes) {
const newNodePositions = {};
const nonDeletedNodes = nodes.filter(node => !node.isDeleted);
const g = layoutGraph(nonDeletedNodes, links);
g.nodes().forEach(node => {
newNodePositions[node] = g.node(node);
});
dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
}
}, [links, nodes]);
const { error: saveVisualizerError, request: saveVisualizer } = useRequest(
useCallback(async () => {
const nodeRequests = [];
const approvalTemplateRequests = [];
const originalLinkMap = {};
const deletedNodeIds = [];
const associateCredentialRequests = [];
const disassociateCredentialRequests = [];
const generateLinkMapAndNewLinks = () => {
const linkMap = {}; const linkMap = {};
const newLinks = []; const newLinks = [];
@@ -254,11 +340,6 @@ function Visualizer({ template, i18n }) {
return [linkMap, newLinks]; return [linkMap, newLinks];
}; };
const handleVisualizerSave = async () => {
const nodeRequests = [];
const approvalTemplateRequests = [];
const originalLinkMap = {};
const deletedNodeIds = [];
nodes.forEach(node => { nodes.forEach(node => {
// node with id=1 is the artificial start node // node with id=1 is the artificial start node
if (node.id === 1) { if (node.id === 1) {
@@ -284,7 +365,9 @@ function Visualizer({ template, i18n }) {
WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id) WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id)
); );
} else if (!node.isDeleted && !node.originalNodeObject) { } else if (!node.isDeleted && !node.originalNodeObject) {
if (node.unifiedJobTemplate.type === 'workflow_approval_template') { if (
node.fullUnifiedJobTemplate.type === 'workflow_approval_template'
) {
nodeRequests.push( nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {}).then( WorkflowJobTemplatesAPI.createNode(template.id, {}).then(
({ data }) => { ({ data }) => {
@@ -296,11 +379,14 @@ function Visualizer({ template, i18n }) {
always_nodes: [], always_nodes: [],
}; };
approvalTemplateRequests.push( approvalTemplateRequests.push(
WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, { WorkflowJobTemplateNodesAPI.createApprovalTemplate(
name: node.unifiedJobTemplate.name, data.id,
description: node.unifiedJobTemplate.description, {
timeout: node.unifiedJobTemplate.timeout, name: node.fullUnifiedJobTemplate.name,
}) description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
}
)
); );
} }
) )
@@ -308,7 +394,9 @@ function Visualizer({ template, i18n }) {
} else { } else {
nodeRequests.push( nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, { WorkflowJobTemplatesAPI.createNode(template.id, {
unified_job_template: node.unifiedJobTemplate.id, ...node.promptValues,
inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id,
}).then(({ data }) => { }).then(({ data }) => {
node.originalNodeObject = data; node.originalNodeObject = data;
originalLinkMap[node.id] = { originalLinkMap[node.id] = {
@@ -317,14 +405,32 @@ function Visualizer({ template, i18n }) {
failure_nodes: [], failure_nodes: [],
always_nodes: [], always_nodes: [],
}; };
if (node.promptValues?.removedCredentials?.length > 0) {
node.promptValues.removedCredentials.forEach(cred => {
disassociateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.disassociateCredentials(
data.id,
cred.id
)
);
});
}
if (node.promptValues?.addedCredentials?.length > 0) {
node.promptValues.addedCredentials.forEach(cred => {
associateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.associateCredentials(
data.id,
cred.id
)
);
});
}
}) })
); );
} }
} else if (node.isEdited) { } else if (node.isEdited) {
if ( if (
node.unifiedJobTemplate && node.fullUnifiedJobTemplate.type === 'workflow_approval_template'
(node.unifiedJobTemplate.unified_job_type === 'workflow_approval' ||
node.unifiedJobTemplate.type === 'workflow_approval_template')
) { ) {
if ( if (
node.originalNodeObject.summary_fields.unified_job_template node.originalNodeObject.summary_fields.unified_job_template
@@ -332,11 +438,12 @@ function Visualizer({ template, i18n }) {
) { ) {
approvalTemplateRequests.push( approvalTemplateRequests.push(
WorkflowApprovalTemplatesAPI.update( WorkflowApprovalTemplatesAPI.update(
node.originalNodeObject.summary_fields.unified_job_template.id, node.originalNodeObject.summary_fields.unified_job_template
.id,
{ {
name: node.unifiedJobTemplate.name, name: node.fullUnifiedJobTemplate.name,
description: node.unifiedJobTemplate.description, description: node.fullUnifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout, timeout: node.fullUnifiedJobTemplate.timeout,
} }
) )
); );
@@ -345,17 +452,51 @@ function Visualizer({ template, i18n }) {
WorkflowJobTemplateNodesAPI.createApprovalTemplate( WorkflowJobTemplateNodesAPI.createApprovalTemplate(
node.originalNodeObject.id, node.originalNodeObject.id,
{ {
name: node.unifiedJobTemplate.name, name: node.fullUnifiedJobTemplate.name,
description: node.unifiedJobTemplate.description, description: node.fullUnifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout, timeout: node.fullUnifiedJobTemplate.timeout,
} }
) )
); );
} }
} else { } else {
nodeRequests.push( nodeRequests.push(
WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, { WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, {
unified_job_template: node.unifiedJobTemplate.id, ...node.promptValues,
inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id,
}).then(() => {
const {
added: addedCredentials,
removed: removedCredentials,
} = getAddedAndRemoved(
getAggregatedCredentials(
node?.originalNodeCredentials,
node.launchConfig?.defaults?.credentials
),
node.promptValues?.credentials
);
if (addedCredentials.length > 0) {
addedCredentials.forEach(cred => {
associateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.associateCredentials(
node.originalNodeObject.id,
cred.id
)
);
});
}
if (removedCredentials?.length > 0) {
removedCredentials.forEach(cred =>
disassociateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.disassociateCredentials(
node.originalNodeObject.id,
cred.id
)
)
);
}
}) })
); );
} }
@@ -372,41 +513,18 @@ function Visualizer({ template, i18n }) {
); );
await Promise.all(associateNodes(newLinks, originalLinkMap)); await Promise.all(associateNodes(newLinks, originalLinkMap));
await Promise.all(disassociateCredentialRequests);
await Promise.all(associateCredentialRequests);
history.push(`/templates/workflow_job_template/${template.id}/details`); history.push(`/templates/workflow_job_template/${template.id}/details`);
}; }, [links, nodes, history, template.id]),
{}
);
useEffect(() => { const {
async function fetchData() { error: nodeRequestError,
try { dismissError: dismissNodeRequestError,
const workflowNodes = await fetchWorkflowNodes(template.id); } = useDismissableError(saveVisualizerError);
dispatch({
type: 'GENERATE_NODES_AND_LINKS',
nodes: workflowNodes,
i18n,
});
} catch (error) {
dispatch({ type: 'SET_CONTENT_ERROR', value: error });
} finally {
dispatch({ type: 'SET_IS_LOADING', value: false });
}
}
fetchData();
}, [template.id, i18n]);
// Update positions of nodes/links
useEffect(() => {
if (nodes) {
const newNodePositions = {};
const nonDeletedNodes = nodes.filter(node => !node.isDeleted);
const g = layoutGraph(nonDeletedNodes, links);
g.nodes().forEach(node => {
newNodePositions[node] = g.node(node);
});
dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
}
}, [links, nodes]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -432,7 +550,7 @@ function Visualizer({ template, i18n }) {
<Wrapper> <Wrapper>
<VisualizerToolbar <VisualizerToolbar
onClose={handleVisualizerClose} onClose={handleVisualizerClose}
onSave={handleVisualizerSave} onSave={() => saveVisualizer(nodes)}
hasUnsavedChanges={unsavedChanges} hasUnsavedChanges={unsavedChanges}
template={template} template={template}
readOnly={readOnly} readOnly={readOnly}
@@ -456,11 +574,22 @@ function Visualizer({ template, i18n }) {
`/templates/workflow_job_template/${template.id}/details` `/templates/workflow_job_template/${template.id}/details`
) )
} }
onSaveAndExit={() => handleVisualizerSave()} onSaveAndExit={() => saveVisualizer(nodes)}
/> />
)} )}
{showDeleteAllNodesModal && <DeleteAllNodesModal />} {showDeleteAllNodesModal && <DeleteAllNodesModal />}
{nodeToView && <NodeViewModal readOnly={readOnly} />} {nodeToView && <NodeViewModal readOnly={readOnly} />}
{nodeRequestError && (
<AlertModal
isOpen
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissNodeRequestError}
>
{i18n._(t`There was an error saving the workflow.`)}
<ErrorDetail error={nodeRequestError} />
</AlertModal>
)}
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
); );

View File

@@ -64,7 +64,7 @@ const workflowContext = {
}, },
{ {
id: 2, id: 2,
unifiedJobTemplate: { fullUnifiedJobTemplate: {
name: 'Foo JT', name: 'Foo JT',
type: 'job_template', type: 'job_template',
}, },

View File

@@ -14,12 +14,16 @@ import {
WorkflowDispatchContext, WorkflowDispatchContext,
WorkflowStateContext, WorkflowStateContext,
} from '../../../contexts/Workflow'; } from '../../../contexts/Workflow';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import { WorkflowJobTemplateNodesAPI } from '../../../api';
import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils'; import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils';
import { import {
WorkflowActionTooltip, WorkflowActionTooltip,
WorkflowActionTooltipItem, WorkflowActionTooltipItem,
WorkflowNodeTypeLetter, WorkflowNodeTypeLetter,
} from '../../../components/Workflow'; } from '../../../components/Workflow';
import getNodeType from './shared/WorkflowJobTemplateVisualizerUtils';
const NodeG = styled.g` const NodeG = styled.g`
pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')}; pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')};
@@ -52,13 +56,85 @@ function VisualizerNode({
}) { }) {
const ref = useRef(null); const ref = useRef(null);
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const [credentialsError, setCredentialsError] = useState(null);
const [detailError, setDetailError] = useState(null);
const dispatch = useContext(WorkflowDispatchContext); const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, addLinkSourceNode, nodePositions } = useContext( const { addingLink, addLinkSourceNode, nodePositions, nodes } = useContext(
WorkflowStateContext WorkflowStateContext
); );
const isAddLinkSourceNode = const isAddLinkSourceNode =
addLinkSourceNode && addLinkSourceNode.id === node.id; addLinkSourceNode && addLinkSourceNode.id === node.id;
const handleCredentialsErrorClose = () => setCredentialsError(null);
const handleDetailErrorClose = () => setDetailError(null);
const updateNode = async () => {
const updatedNodes = [...nodes];
const updatedNode = updatedNodes.find(n => n.id === node.id);
if (
!node.fullUnifiedJobTemplate &&
node?.originalNodeObject?.summary_fields?.unified_job_template
) {
const [, nodeAPI] = getNodeType(
node.originalNodeObject.summary_fields.unified_job_template
);
try {
const { data: fullUnifiedJobTemplate } = await nodeAPI.readDetail(
node.originalNodeObject.unified_job_template
);
updatedNode.fullUnifiedJobTemplate = fullUnifiedJobTemplate;
} catch (err) {
setDetailError(err);
return null;
}
}
if (
node?.originalNodeObject?.summary_fields?.unified_job_template
?.unified_job_type === 'job' &&
!node?.originalNodeCredentials
) {
try {
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readCredentials(
node.originalNodeObject.id
);
updatedNode.originalNodeCredentials = results;
} catch (err) {
setCredentialsError(err);
return null;
}
}
dispatch({
type: 'SET_NODES',
value: updatedNodes,
});
return updatedNode;
};
const handleEditClick = async () => {
updateHelpText(null);
setHovering(false);
const nodeToEdit = await updateNode();
if (nodeToEdit) {
dispatch({ type: 'SET_NODE_TO_EDIT', value: nodeToEdit });
}
};
const handleViewClick = async () => {
updateHelpText(null);
setHovering(false);
const nodeToView = await updateNode();
if (nodeToView) {
dispatch({ type: 'SET_NODE_TO_VIEW', value: nodeToView });
}
};
const handleNodeMouseEnter = () => { const handleNodeMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current); ref.current.parentNode.appendChild(ref.current);
setHovering(true); setHovering(true);
@@ -91,11 +167,7 @@ function VisualizerNode({
<WorkflowActionTooltipItem <WorkflowActionTooltipItem
id="node-details" id="node-details"
key="details" key="details"
onClick={() => { onClick={handleViewClick}
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_VIEW', value: node });
}}
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))} onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)} onMouseLeave={() => updateHelpText(null)}
> >
@@ -123,11 +195,7 @@ function VisualizerNode({
<WorkflowActionTooltipItem <WorkflowActionTooltipItem
id="node-edit" id="node-edit"
key="edit" key="edit"
onClick={() => { onClick={handleEditClick}
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_EDIT', value: node });
}}
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))} onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => updateHelpText(null)} onMouseLeave={() => updateHelpText(null)}
> >
@@ -164,6 +232,7 @@ function VisualizerNode({
]; ];
return ( return (
<>
<NodeG <NodeG
id={`node-${node.id}`} id={`node-${node.id}`}
job={node.job} job={node.job}
@@ -171,8 +240,9 @@ function VisualizerNode({
onMouseEnter={handleNodeMouseEnter} onMouseEnter={handleNodeMouseEnter}
onMouseLeave={handleNodeMouseLeave} onMouseLeave={handleNodeMouseLeave}
ref={ref} ref={ref}
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id] transform={`translate(${nodePositions[node.id].x},${nodePositions[
.y - nodePositions[1].y})`} node.id
].y - nodePositions[1].y})`}
> >
<rect <rect
fill="#FFFFFF" fill="#FFFFFF"
@@ -200,13 +270,14 @@ function VisualizerNode({
> >
<NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}> <NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}>
<NodeResourceName id={`node-${node.id}-name`}> <NodeResourceName id={`node-${node.id}-name`}>
{node.unifiedJobTemplate {node?.fullUnifiedJobTemplate?.name ||
? node.unifiedJobTemplate.name node?.originalNodeObject?.summary_fields?.unified_job_template
: i18n._(t`DELETED`)} ?.name ||
i18n._(t`DELETED`)}
</NodeResourceName> </NodeResourceName>
</NodeContents> </NodeContents>
</foreignObject> </foreignObject>
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />} <WorkflowNodeTypeLetter node={node} />
{hovering && !addingLink && ( {hovering && !addingLink && (
<WorkflowActionTooltip <WorkflowActionTooltip
pointX={wfConstants.nodeW} pointX={wfConstants.nodeW}
@@ -215,6 +286,29 @@ function VisualizerNode({
/> />
)} )}
</NodeG> </NodeG>
{detailError && (
<AlertModal
isOpen={detailError}
variant="error"
title={i18n._(t`Error!`)}
onClose={handleDetailErrorClose}
>
{i18n._(t`Failed to retrieve full node resource object.`)}
<ErrorDetail error={detailError} />
</AlertModal>
)}
{credentialsError && (
<AlertModal
isOpen={credentialsError}
variant="error"
title={i18n._(t`Error!`)}
onClose={handleCredentialsErrorClose}
>
{i18n._(t`Failed to retrieve node credentials.`)}
<ErrorDetail error={credentialsError} />
</AlertModal>
)}
</>
); );
} }

View File

@@ -1,10 +1,31 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { import {
WorkflowDispatchContext, WorkflowDispatchContext,
WorkflowStateContext, WorkflowStateContext,
} from '../../../contexts/Workflow'; } from '../../../contexts/Workflow';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '../../../api';
import VisualizerNode from './VisualizerNode'; import VisualizerNode from './VisualizerNode';
import { asyncFlush } from '../../../setupTests';
jest.mock('../../../api/models/JobTemplates');
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
WorkflowJobTemplateNodesAPI.readCredentials.mockResolvedValue({
data: {
results: [],
},
});
const nodeWithJT = {
id: 2,
fullUnifiedJobTemplate: {
id: 77,
name: 'Automation JT',
type: 'job_template',
},
};
const mockedContext = { const mockedContext = {
addingLink: false, addingLink: false,
@@ -23,15 +44,7 @@ const mockedContext = {
y: 40, y: 40,
}, },
}, },
}; nodes: [nodeWithJT],
const nodeWithJT = {
id: 2,
unifiedJobTemplate: {
id: 77,
name: 'Automation JT',
type: 'job_template',
},
}; };
const dispatch = jest.fn(); const dispatch = jest.fn();
@@ -47,8 +60,6 @@ describe('VisualizerNode', () => {
<WorkflowStateContext.Provider value={mockedContext}> <WorkflowStateContext.Provider value={mockedContext}>
<svg> <svg>
<VisualizerNode <VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT} node={nodeWithJT}
readOnly={false} readOnly={false}
updateHelpText={updateHelpText} updateHelpText={updateHelpText}
@@ -59,6 +70,9 @@ describe('VisualizerNode', () => {
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
); );
}); });
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => { afterAll(() => {
wrapper.unmount(); wrapper.unmount();
}); });
@@ -67,10 +81,10 @@ describe('VisualizerNode', () => {
}); });
test('Displays action tooltip on hover and updates help text on hover', () => { test('Displays action tooltip on hover and updates help text on hover', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(1); expect(wrapper.find('WorkflowActionTooltip').length).toBe(1);
expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(5); expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(5);
wrapper.find('VisualizerNode').simulate('mouseleave'); wrapper.find('g').simulate('mouseleave');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper wrapper
.find('foreignObject') .find('foreignObject')
@@ -85,7 +99,7 @@ describe('VisualizerNode', () => {
}); });
test('Add tooltip action hover/click updates help text and dispatches properly', () => { test('Add tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseenter'); wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Add a new node'); expect(updateHelpText).toHaveBeenCalledWith('Add a new node');
wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseleave'); wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseleave');
@@ -98,8 +112,8 @@ describe('VisualizerNode', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
}); });
test('Edit tooltip action hover/click updates help text and dispatches properly', () => { test('Edit tooltip action hover/click updates help text and dispatches properly', async () => {
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
wrapper wrapper
.find('WorkflowActionTooltipItem#node-edit') .find('WorkflowActionTooltipItem#node-edit')
.simulate('mouseenter'); .simulate('mouseenter');
@@ -109,15 +123,27 @@ describe('VisualizerNode', () => {
.simulate('mouseleave'); .simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null); expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('WorkflowActionTooltipItem#node-edit').simulate('click'); wrapper.find('WorkflowActionTooltipItem#node-edit').simulate('click');
expect(dispatch).toHaveBeenCalledWith({ await asyncFlush();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch.mock.calls).toEqual([
[
{
type: 'SET_NODES',
value: [nodeWithJT],
},
],
[
{
type: 'SET_NODE_TO_EDIT', type: 'SET_NODE_TO_EDIT',
value: nodeWithJT, value: nodeWithJT,
}); },
],
]);
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
}); });
test('Details tooltip action hover/click updates help text and dispatches properly', () => { test('Details tooltip action hover/click updates help text and dispatches properly', async () => {
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
wrapper wrapper
.find('WorkflowActionTooltipItem#node-details') .find('WorkflowActionTooltipItem#node-details')
.simulate('mouseenter'); .simulate('mouseenter');
@@ -127,15 +153,27 @@ describe('VisualizerNode', () => {
.simulate('mouseleave'); .simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null); expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('WorkflowActionTooltipItem#node-details').simulate('click'); wrapper.find('WorkflowActionTooltipItem#node-details').simulate('click');
expect(dispatch).toHaveBeenCalledWith({ await asyncFlush();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch.mock.calls).toEqual([
[
{
type: 'SET_NODES',
value: [nodeWithJT],
},
],
[
{
type: 'SET_NODE_TO_VIEW', type: 'SET_NODE_TO_VIEW',
value: nodeWithJT, value: nodeWithJT,
}); },
],
]);
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
}); });
test('Link tooltip action hover/click updates help text and dispatches properly', () => { test('Link tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
wrapper wrapper
.find('WorkflowActionTooltipItem#node-link') .find('WorkflowActionTooltipItem#node-link')
.simulate('mouseenter'); .simulate('mouseenter');
@@ -153,7 +191,7 @@ describe('VisualizerNode', () => {
}); });
test('Delete tooltip action hover/click updates help text and dispatches properly', () => { test('Delete tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
wrapper wrapper
.find('WorkflowActionTooltipItem#node-delete') .find('WorkflowActionTooltipItem#node-delete')
.simulate('mouseenter'); .simulate('mouseenter');
@@ -201,12 +239,12 @@ describe('VisualizerNode', () => {
}); });
test('Displays correct help text when hovering over node while adding link', () => { test('Displays correct help text when hovering over node while adding link', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper.find('VisualizerNode').simulate('mouseenter'); wrapper.find('g').simulate('mouseenter');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
expect(updateHelpText).toHaveBeenCalledWith( expect(updateHelpText).toHaveBeenCalledWith(
'Click to create a new link to this node.' 'Click to create a new link to this node.'
); );
wrapper.find('VisualizerNode').simulate('mouseleave'); wrapper.find('g').simulate('mouseleave');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
expect(updateHelpText).toHaveBeenCalledWith(null); expect(updateHelpText).toHaveBeenCalledWith(null);
}); });
@@ -227,8 +265,6 @@ describe('VisualizerNode', () => {
<svg> <svg>
<WorkflowStateContext.Provider value={mockedContext}> <WorkflowStateContext.Provider value={mockedContext}>
<VisualizerNode <VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={{ node={{
id: 2, id: 2,
}} }}
@@ -243,4 +279,143 @@ describe('VisualizerNode', () => {
expect(wrapper.find('NodeResourceName').text()).toBe('DELETED'); expect(wrapper.find('NodeResourceName').text()).toBe('DELETED');
}); });
}); });
describe('Node without full unified job template', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={mockedContext}>
<svg>
<VisualizerNode
node={{
id: 2,
originalNodeObject: {
all_parents_must_converge: false,
always_nodes: [],
created: '2020-11-19T21:47:55.278081Z',
diff_mode: null,
extra_data: {},
failure_nodes: [],
id: 49,
identifier: 'f03b62c5-40f8-49e4-97c3-5bb20c91ec91',
inventory: null,
job_tags: null,
job_type: null,
limit: null,
modified: '2020-11-19T21:47:55.278156Z',
related: {
credentials:
'/api/v2/workflow_job_template_nodes/49/credentials/',
},
scm_branch: null,
skip_tags: null,
success_nodes: [],
summary_fields: {
workflow_job_template: { id: 15 },
unified_job_template: {
id: 7,
description: '',
name: 'Example',
unified_job_type: 'job',
},
},
type: 'workflow_job_template_node',
unified_job_template: 7,
url: '/api/v2/workflow_job_template_nodes/49/',
verbosity: null,
workflowMakerNodeId: 2,
workflow_job_template: 15,
},
}}
readOnly={false}
updateHelpText={updateHelpText}
updateNodeHelp={updateNodeHelp}
/>
</svg>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
afterEach(() => {
wrapper.unmount();
});
test('Attempts to fetch full unified job template on view', async () => {
wrapper.find('g').simulate('mouseenter');
await act(async () => {
wrapper
.find('WorkflowActionTooltipItem#node-details')
.simulate('click');
});
expect(JobTemplatesAPI.readDetail).toHaveBeenCalledWith(7);
});
test('Displays error fetching full unified job template', async () => {
JobTemplatesAPI.readDetail.mockRejectedValueOnce(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/job_templates/7',
},
data: 'An error occurred',
status: 403,
},
})
);
expect(wrapper.find('AlertModal').length).toBe(0);
wrapper.find('g').simulate('mouseenter');
await act(async () => {
wrapper
.find('WorkflowActionTooltipItem#node-details')
.simulate('click');
});
wrapper.update();
expect(wrapper.find('AlertModal').length).toBe(1);
});
test('Attempts to fetch credentials on view', async () => {
JobTemplatesAPI.readDetail.mockResolvedValueOnce({
data: {
id: 7,
name: 'Example',
},
});
wrapper.find('g').simulate('mouseenter');
await act(async () => {
wrapper
.find('WorkflowActionTooltipItem#node-details')
.simulate('click');
});
expect(WorkflowJobTemplateNodesAPI.readCredentials).toHaveBeenCalledWith(
49
);
});
test('Displays error fetching credentials', async () => {
JobTemplatesAPI.readDetail.mockResolvedValueOnce({
data: {
id: 7,
name: 'Example',
},
});
WorkflowJobTemplateNodesAPI.readCredentials.mockRejectedValueOnce(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/workflow_job_template_nodes/49/credentials',
},
data: 'An error occurred',
status: 403,
},
})
);
expect(wrapper.find('AlertModal').length).toBe(0);
wrapper.find('g').simulate('mouseenter');
await act(async () => {
wrapper
.find('WorkflowActionTooltipItem#node-details')
.simulate('click');
});
wrapper.update();
expect(wrapper.find('AlertModal').length).toBe(1);
});
});
}); });

View File

@@ -79,8 +79,9 @@ function VisualizerToolbar({
<Badge id="visualizer-total-nodes-badge" isRead> <Badge id="visualizer-total-nodes-badge" isRead>
{totalNodes} {totalNodes}
</Badge> </Badge>
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> <Tooltip content={i18n._(t`Toggle legend`)} position="bottom">
<ActionButton <ActionButton
aria-label={i18n._(t`Toggle legend`)}
id="visualizer-toggle-legend" id="visualizer-toggle-legend"
isActive={totalNodes > 0 && showLegend} isActive={totalNodes > 0 && showLegend}
isDisabled={totalNodes === 0} isDisabled={totalNodes === 0}
@@ -90,8 +91,9 @@ function VisualizerToolbar({
<CompassIcon /> <CompassIcon />
</ActionButton> </ActionButton>
</Tooltip> </Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom"> <Tooltip content={i18n._(t`Toggle tools`)} position="bottom">
<ActionButton <ActionButton
aria-label={i18n._(t`Toggle tools`)}
id="visualizer-toggle-tools" id="visualizer-toggle-tools"
isActive={totalNodes > 0 && showTools} isActive={totalNodes > 0 && showTools}
isDisabled={totalNodes === 0} isDisabled={totalNodes === 0}
@@ -101,8 +103,12 @@ function VisualizerToolbar({
<WrenchIcon /> <WrenchIcon />
</ActionButton> </ActionButton>
</Tooltip> </Tooltip>
<Tooltip
content={i18n._(t`Workflow documentation`)}
position="bottom"
>
<ActionButton <ActionButton
aria-label={i18n._(t`Workflow Documentation`)} aria-label={i18n._(t`Workflow documentation`)}
id="visualizer-documentation" id="visualizer-documentation"
variant="plain" variant="plain"
component="a" component="a"
@@ -111,8 +117,13 @@ function VisualizerToolbar({
> >
<BookIcon /> <BookIcon />
</ActionButton> </ActionButton>
</Tooltip>
{template.summary_fields?.user_capabilities?.start && ( {template.summary_fields?.user_capabilities?.start && (
<LaunchButton resource={template} aria-label={i18n._(t`Launch`)}> <Tooltip content={i18n._(t`Launch workflow`)} position="bottom">
<LaunchButton
resource={template}
aria-label={i18n._(t`Launch workflow`)}
>
{({ handleLaunch }) => ( {({ handleLaunch }) => (
<ActionButton <ActionButton
id="visualizer-launch" id="visualizer-launch"
@@ -124,10 +135,11 @@ function VisualizerToolbar({
</ActionButton> </ActionButton>
)} )}
</LaunchButton> </LaunchButton>
</Tooltip>
)} )}
{!readOnly && ( {!readOnly && (
<> <>
<Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom"> <Tooltip content={i18n._(t`Delete all nodes`)} position="bottom">
<ActionButton <ActionButton
id="visualizer-delete-all" id="visualizer-delete-all"
aria-label={i18n._(t`Delete all nodes`)} aria-label={i18n._(t`Delete all nodes`)}

View File

@@ -0,0 +1,29 @@
import {
InventorySourcesAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobTemplatesAPI,
} from '../../../../api';
export default function getNodeType(node) {
const ujtType = node?.type || node?.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
return ['job_template', JobTemplatesAPI];
case 'project':
case 'project_update':
return ['project_sync', ProjectsAPI];
case 'inventory_source':
case 'inventory_update':
return ['inventory_source_sync', InventorySourcesAPI];
case 'workflow_job_template':
case 'workflow_job':
return ['workflow_job_template', WorkflowJobTemplatesAPI];
case 'workflow_approval_template':
case 'workflow_approval':
return ['approval', null];
default:
return [null, null];
}
}