addresses missing name property and fixes tests

This commit is contained in:
Alex Corey 2020-12-07 18:35:38 -05:00 committed by mabashian
parent 7d5b198ce6
commit 307c9eafb3
42 changed files with 1704 additions and 987 deletions

View File

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

View File

@ -11,7 +11,14 @@ import useLaunchSteps from './useLaunchSteps';
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 {
@ -20,7 +27,7 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
visitStep,
visitAllSteps,
contentError,
} = useLaunchSteps(config, resource, i18n);
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
const handleSave = () => {
const postValues = {};
@ -39,7 +46,7 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
setValue('limit', values.limit);
setValue('job_tags', values.job_tags);
setValue('skip_tags', values.skip_tags);
const extraVars = config.ask_variables_on_launch
const extraVars = launchConfig.ask_variables_on_launch
? values.extra_vars || '---'
: resource.extra_vars;
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
@ -103,33 +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 (
<Formik
initialValues={{
verbosity: resource.verbosity || 0,
inventory:
resource.summary_fields?.inventory || null,
credentials:
resource.summary_fields?.credentials || [],
diff_mode:
config.ask_diff_mode_on_launch && (resource.diff_mode || false),
extra_vars:
resource.extra_vars || '---',
job_type: resource.job_type || 'run',
job_tags: resource.job_tags || '',
skip_tags: resource.skip_tags || '',
scm_branch:
resource.scm_branch || '',
limit: resource.limit || '',
}}
onSubmit={values => onLaunch(values)}
>
<Formik initialValues={{}} onSubmit={values => onLaunch(values)}>
<PromptModalForm
onSubmit={values => onLaunch(values)}
onCancel={onCancel}
i18n={i18n}
config={config}
launchConfig={launchConfig}
surveyConfig={surveyConfig}
resource={resource}
/>
</Formik>

View File

@ -76,7 +76,7 @@ describe('LaunchPrompt', () => {
await act(async () => {
wrapper = mountWithContexts(
<LaunchPrompt
config={{
launchConfig={{
...config,
ask_inventory_on_launch: true,
ask_credential_on_launch: true,
@ -86,6 +86,24 @@ describe('LaunchPrompt', () => {
resource={resource}
onLaunch={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',
},
],
}}
/>
);
});
@ -105,7 +123,7 @@ describe('LaunchPrompt', () => {
await act(async () => {
wrapper = mountWithContexts(
<LaunchPrompt
config={{
launchConfig={{
...config,
ask_inventory_on_launch: true,
}}
@ -129,7 +147,7 @@ describe('LaunchPrompt', () => {
await act(async () => {
wrapper = mountWithContexts(
<LaunchPrompt
config={{
launchConfig={{
...config,
ask_credential_on_launch: true,
}}
@ -153,7 +171,7 @@ describe('LaunchPrompt', () => {
await act(async () => {
wrapper = mountWithContexts(
<LaunchPrompt
config={{
launchConfig={{
...config,
ask_verbosity_on_launch: true,
}}

View File

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

View File

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

View File

@ -24,18 +24,25 @@ const ErrorMessageWrapper = styled.div`
margin-bottom: 10px;
`;
function PreviewStep({ resource, config, survey, formErrors, i18n }) {
function PreviewStep({
resource,
launchConfig,
surveyConfig,
formErrors,
i18n,
}) {
const { values } = useFormikContext();
const surveyValues = getSurveyValues(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 && (overrides.extra_vars || '---');
if (survey && survey.spec) {
const passwordFields = survey.spec
launchConfig.ask_variables_on_launch && (overrides.extra_vars || '---');
if (surveyConfig?.spec) {
const passwordFields = surveyConfig.spec
.filter(q => q.type === 'password')
.map(q => q.variable);
const masked = maskPasswords(surveyValues, passwordFields);
@ -46,11 +53,10 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
overrides.extra_vars = initialExtraVars;
}
}
// Api expects extra vars to be merged with the survey data.
// We put the extra_data key/value pair on the values object here
// so that we don't have to do this loop again inside of the NodeAddModal.jsx
values.extra_data =
overrides.extra_vars && parseVariableField(overrides?.extra_vars);
return (
<Fragment>
{formErrors && (
@ -67,7 +73,7 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
)}
<PromptDetail
resource={resource}
launchConfig={config}
launchConfig={launchConfig}
overrides={overrides}
/>
</Fragment>

View File

@ -36,11 +36,11 @@ describe('PreviewStep', () => {
<Formik initialValues={{ limit: '4', survey_foo: 'abc' }}>
<PreviewStep
resource={resource}
config={{
launchConfig={{
ask_limit_on_launch: true,
survey_enabled: true,
}}
survey={survey}
surveyConfig={survey}
formErrors={formErrors}
/>
</Formik>
@ -64,7 +64,7 @@ describe('PreviewStep', () => {
<Formik initialValues={{ limit: '4' }}>
<PreviewStep
resource={resource}
config={{
launchConfig={{
ask_limit_on_launch: true,
}}
formErrors={formErrors}
@ -80,7 +80,32 @@ describe('PreviewStep', () => {
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 () => {
let wrapper;
await act(async () => {
@ -88,7 +113,7 @@ describe('PreviewStep', () => {
<Formik initialValues={{ extra_vars: 'one: 1' }}>
<PreviewStep
resource={resource}
config={{
launchConfig={{
ask_variables_on_launch: true,
}}
formErrors={formErrors}
@ -104,30 +129,30 @@ describe('PreviewStep', () => {
extra_vars: 'one: 1',
});
});
test('should remove survey with empty array value', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{ extra_vars: 'one: 1' }}
values={{ extra_vars: 'one: 1', survey_foo: [] }}
>
<PreviewStep
resource={resource}
config={{
ask_variables_on_launch: true,
}}
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',
});
test('should remove survey with empty array value', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{ extra_vars: 'one: 1' }}
values={{ extra_vars: 'one: 1', survey_foo: [] }}
>
<PreviewStep
resource={resource}
launchConfig={{
ask_variables_on_launch: true,
}}
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',
});
});
});

View File

@ -22,7 +22,7 @@ import {
} from '../../../util/validators';
import { Survey } from '../../../types';
function SurveyStep({ survey, i18n }) {
function SurveyStep({ surveyConfig, i18n }) {
const fieldTypes = {
text: TextField,
textarea: TextField,
@ -34,7 +34,7 @@ function SurveyStep({ survey, i18n }) {
};
return (
<Form>
{survey.spec.map(question => {
{surveyConfig.spec.map(question => {
const Field = fieldTypes[question.type];
return (
<Field key={question.variable} question={question} i18n={i18n} />
@ -44,7 +44,7 @@ function SurveyStep({ survey, i18n }) {
);
}
SurveyStep.propTypes = {
survey: Survey.isRequired,
surveyConfig: Survey.isRequired,
};
function TextField({ question, i18n }) {

View File

@ -1,64 +1,17 @@
import React, { useCallback, useEffect } from 'react';
import React from 'react';
import { t } from '@lingui/macro';
import useRequest from '../../../util/useRequest';
import {
WorkflowJobTemplateNodesAPI,
JobTemplatesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
import CredentialsStep from './CredentialsStep';
const STEP_ID = 'credentials';
export default function useCredentialsStep(
config,
i18n,
selectedResource,
nodeToEdit
) {
const resource = nodeToEdit || selectedResource;
const { request: fetchCredentials, result, error, isLoading } = useRequest(
useCallback(async () => {
let credentials;
if (!nodeToEdit?.related?.credentials) {
return {};
}
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readCredentials(nodeToEdit.id);
credentials = results;
if (results.length === 0 && config?.defaults?.credentials) {
const fetchCreds = config.job_template_data
? JobTemplatesAPI.readDetail(config.job_template_data.id)
: WorkflowJobTemplatesAPI.readDetail(
config.workflow_job_template_data.id
);
const {
data: {
summary_fields: { credentials: defaultCreds },
},
} = await fetchCreds;
credentials = defaultCreds;
}
return credentials;
}, [nodeToEdit, config])
);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials, nodeToEdit]);
const validate = () => {
return {};
};
export default function useCredentialsStep(launchConfig, resource, i18n) {
return {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource, result),
validate,
isReady: !isLoading && !!result,
contentError: error,
step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
validate: () => ({}),
isReady: true,
contentError: null,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
@ -68,8 +21,8 @@ export default function useCredentialsStep(
};
}
function getStep(config, i18n) {
if (!config.ask_credential_on_launch) {
function getStep(launchConfig, i18n) {
if (!launchConfig.ask_credential_on_launch) {
return null;
}
return {
@ -81,11 +34,12 @@ function getStep(config, i18n) {
};
}
function getInitialValues(config, resource, result) {
if (!config.ask_credential_on_launch) {
function getInitialValues(launchConfig, resource) {
if (!launchConfig.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || result || [],
credentials: resource?.summary_fields?.credentials || [],
};
}

View File

@ -7,23 +7,21 @@ import StepName from './StepName';
const STEP_ID = 'inventory';
export default function useInventoryStep(
config,
launchConfig,
resource,
i18n,
visitedSteps,
selectedResource,
nodeToEdit
visitedSteps
) {
const [, meta] = useField('inventory');
const resource = nodeToEdit?.originalNodeObject || nodeToEdit?.promptValues || selectedResource;
const formError =
Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error);
return {
step: getStep(config, i18n, formError),
initialValues: getInitialValues(config, resource),
step: getStep(launchConfig, i18n, formError),
initialValues: getInitialValues(launchConfig, resource),
isReady: true,
contentError: null,
formError: config.ask_inventory_on_launch && formError,
formError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
@ -31,25 +29,24 @@ export default function useInventoryStep(
},
};
}
function getStep(config, i18n, formError) {
if (!config.ask_inventory_on_launch) {
function getStep(launchConfig, i18n, formError) {
if (!launchConfig.ask_inventory_on_launch) {
return null;
}
return {
id: STEP_ID,
key: 3,
name: <StepName hasErrors={formError}>{i18n._(t`Inventory`)}</StepName>,
component: <InventoryStep i18n={i18n} />,
enableNext: true,
};
}
function getInitialValues(config, resource) {
if (!config.ask_inventory_on_launch) {
function getInitialValues(launchConfig, resource) {
if (!launchConfig.ask_inventory_on_launch) {
return {};
}
return {
inventory: resource?.summary_fields?.inventory || resource?.inventory || null,
inventory: resource?.summary_fields?.inventory || null,
};
}

View File

@ -5,16 +5,20 @@ import OtherPromptsStep from './OtherPromptsStep';
const STEP_ID = 'other';
export default function useOtherPrompt(
config,
i18n,
selectedResource,
nodeToEdit
) {
const resource = nodeToEdit || selectedResource;
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 {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource),
step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
isReady: true,
contentError: null,
formError: null,
@ -32,73 +36,61 @@ export default function useOtherPrompt(
};
}
function getStep(config, i18n) {
if (!shouldShowPrompt(config)) {
function getStep(launchConfig, i18n) {
if (!shouldShowPrompt(launchConfig)) {
return null;
}
return {
id: STEP_ID,
key: 5,
name: i18n._(t`Other Prompts`),
component: <OtherPromptsStep config={config} i18n={i18n} />,
component: <OtherPromptsStep launchConfig={launchConfig} i18n={i18n} />,
enableNext: true,
};
}
function shouldShowPrompt(config) {
function shouldShowPrompt(launchConfig) {
return (
config.ask_job_type_on_launch ||
config.ask_limit_on_launch ||
config.ask_verbosity_on_launch ||
config.ask_tags_on_launch ||
config.ask_skip_tags_on_launch ||
config.ask_variables_on_launch ||
config.ask_scm_branch_on_launch ||
config.ask_diff_mode_on_launch
launchConfig.ask_job_type_on_launch ||
launchConfig.ask_limit_on_launch ||
launchConfig.ask_verbosity_on_launch ||
launchConfig.ask_tags_on_launch ||
launchConfig.ask_skip_tags_on_launch ||
launchConfig.ask_variables_on_launch ||
launchConfig.ask_scm_branch_on_launch ||
launchConfig.ask_diff_mode_on_launch
);
}
function getInitialValues(config, resource) {
if (!config) {
return {};
function getInitialValues(launchConfig, resource) {
const initialValues = {};
if (!launchConfig) {
return initialValues;
}
const getVariablesData = () => {
if (resource?.extra_data) {
return jsonToYaml(JSON.stringify(resource?.extra_data));
}
if (resource?.extra_vars) {
if (resource.extra_vars !== '---') {
return jsonToYaml(
JSON.stringify(parseVariableField(resource?.extra_vars))
);
}
}
return '---';
};
const initialValues = {};
if (config.ask_job_type_on_launch) {
if (launchConfig.ask_job_type_on_launch) {
initialValues.job_type = resource?.job_type || '';
}
if (config.ask_limit_on_launch) {
if (launchConfig.ask_limit_on_launch) {
initialValues.limit = resource?.limit || '';
}
if (config.ask_verbosity_on_launch) {
if (launchConfig.ask_verbosity_on_launch) {
initialValues.verbosity = resource?.verbosity || 0;
}
if (config.ask_tags_on_launch) {
if (launchConfig.ask_tags_on_launch) {
initialValues.job_tags = resource?.job_tags || '';
}
if (config.ask_skip_tags_on_launch) {
if (launchConfig.ask_skip_tags_on_launch) {
initialValues.skip_tags = resource?.skip_tags || '';
}
if (config.ask_variables_on_launch) {
initialValues.extra_vars = getVariablesData();
if (launchConfig.ask_variables_on_launch) {
initialValues.extra_vars = getVariablesData(resource);
}
if (config.ask_scm_branch_on_launch) {
if (launchConfig.ask_scm_branch_on_launch) {
initialValues.scm_branch = resource?.scm_branch || '';
}
if (config.ask_diff_mode_on_launch) {
if (launchConfig.ask_diff_mode_on_launch) {
initialValues.diff_mode = resource?.diff_mode || false;
}
return initialValues;

View File

@ -5,28 +5,23 @@ import PreviewStep from './PreviewStep';
const STEP_ID = 'preview';
export default function usePreviewStep(
config,
launchConfig,
i18n,
resource,
survey,
surveyConfig,
hasErrors,
needsPreviewStep,
nodeToEdit
showStep
) {
const showStep =
needsPreviewStep && resource && Object.keys(config).length > 0;
const promptResource = nodeToEdit || resource
return {
step: showStep
? {
id: STEP_ID,
key: 7,
name: i18n._(t`Preview`),
component: (
<PreviewStep
config={config}
resource={promptResource}
survey={survey}
launchConfig={launchConfig}
resource={resource}
surveyConfig={surveyConfig}
formErrors={hasErrors}
/>
),

View File

@ -1,45 +1,25 @@
import React, { useEffect, useCallback } from 'react';
import React from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import useRequest from '../../../util/useRequest';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
import SurveyStep from './SurveyStep';
import StepName from './StepName';
const STEP_ID = 'survey';
export default function useSurveyStep(
config,
i18n,
visitedSteps,
launchConfig,
surveyConfig,
resource,
nodeToEdit
i18n,
visitedSteps
) {
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 errors = {};
const validate = () => {
if (!config.survey_enabled || !survey || !survey.spec) {
if (!launchConfig.survey_enabled || !surveyConfig?.spec) {
return {};
}
survey.spec.forEach(question => {
surveyConfig.spec.forEach(question => {
const errMessage = validateField(
question,
values[`survey_${question.variable}`],
@ -53,19 +33,19 @@ export default function useSurveyStep(
};
const formError = Object.keys(validate()).length > 0;
return {
step: getStep(config, survey, validate, i18n, visitedSteps),
initialValues: getInitialValues(config, survey, nodeToEdit),
step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps),
initialValues: getInitialValues(launchConfig, surveyConfig, resource),
validate,
survey,
isReady: !isLoading && !!survey,
contentError: error,
surveyConfig,
isReady: true,
contentError: null,
formError,
setTouched: setFieldsTouched => {
if (!survey || !survey.spec) {
if (!surveyConfig?.spec) {
return;
}
const fields = {};
survey.spec.forEach(question => {
surveyConfig.spec.forEach(question => {
fields[`survey_${question.variable}`] = true;
});
setFieldsTouched(fields);
@ -96,14 +76,13 @@ function validateField(question, value, i18n) {
}
return null;
}
function getStep(config, survey, validate, i18n, visitedSteps) {
if (!config.survey_enabled) {
function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) {
if (!launchConfig.survey_enabled) {
return null;
}
return {
id: STEP_ID,
key: 6,
name: (
<StepName
hasErrors={
@ -114,26 +93,26 @@ function getStep(config, survey, validate, i18n, visitedSteps) {
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep survey={survey} i18n={i18n} />,
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
};
}
function getInitialValues(config, survey, nodeToEdit) {
if (!config.survey_enabled || !survey) {
function getInitialValues(launchConfig, surveyConfig, resource) {
if (!launchConfig.survey_enabled || !surveyConfig) {
return {};
}
const values = {};
if (survey && survey.spec) {
survey.spec.forEach(question => {
if (surveyConfig?.spec) {
surveyConfig.spec.forEach(question => {
if (question.type === 'multiselect') {
values[`survey_${question.variable}`] = question.default.split('\n');
} else {
values[`survey_${question.variable}`] = question.default;
}
if (nodeToEdit?.extra_data) {
Object.entries(nodeToEdit?.extra_data).forEach(([key, value]) => {
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;

View File

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

View File

@ -71,11 +71,11 @@ function omitOverrides(resource, overrides, defaultConfig) {
const clonedResource = {
...resource,
summary_fields: { ...resource.summary_fields },
...defaultConfig
...defaultConfig,
};
Object.keys(overrides).forEach(keyToOmit => {
delete clonedResource[keyToOmit];
delete clonedResource.summary_fields[keyToOmit];
delete clonedResource?.summary_fields[keyToOmit];
});
return clonedResource;
}
@ -90,7 +90,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
};
const details = omitOverrides(resource, overrides, launchConfig.defaults);
details.type = overrides?.nodeType || details.type
details.type = overrides?.nodeType || details.type;
const hasOverrides = Object.keys(overrides).length > 0;
return (
@ -139,7 +139,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
<Divider css="margin-top: var(--pf-global--spacer--lg)" />
<PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader>
<DetailList aria-label="Prompt Overrides">
{launchConfig.ask_job_type_on_launch && (
{launchConfig.ask_job_type_on_launch && (
<Detail
label={i18n._(t`Job Type`)}
value={toTitleCase(overrides.job_type)}
@ -181,12 +181,13 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
{launchConfig.ask_limit_on_launch && (
<Detail label={i18n._(t`Limit`)} value={overrides.limit} />
)}
{overrides?.verbosity && launchConfig.ask_verbosity_on_launch && (
{Object.prototype.hasOwnProperty.call(overrides, 'verbosity') &&
launchConfig.ask_verbosity_on_launch ? (
<Detail
label={i18n._(t`Verbosity`)}
value={VERBOSITY[overrides.verbosity]}
/>
)}
) : null}
{launchConfig.ask_tags_on_launch && (
<Detail
fullWidth
@ -194,13 +195,18 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
value={
<ChipGroup
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 => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
{overrides.job_tags.length > 0 &&
overrides.job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
}
/>
@ -212,13 +218,18 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
value={
<ChipGroup
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 => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
{overrides.skip_tags.length > 0 &&
overrides.skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
}
/>
@ -231,7 +242,8 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
}
/>
)}
{launchConfig.ask_variables_on_launch && (
{(launchConfig.survey_enabled ||
launchConfig.ask_variables_on_launch) && (
<VariablesDetail
label={i18n._(t`Variables`)}
rows={4}

View File

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

View File

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

View File

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

View File

@ -19,16 +19,27 @@ const CenteredPauseIcon = styled(PauseIcon)`
`;
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;
if (
(node.unifiedJobTemplate &&
(node.unifiedJobTemplate.type ||
node.unifiedJobTemplate.unified_job_type)) ||
(node.job && node.job.type)
unifiedJobTemplate.type ||
unifiedJobTemplate.unified_job_type ||
node?.job?.type
) {
const ujtType = node.unifiedJobTemplate
? node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type
: node.job.type;
const ujtType =
unifiedJobTemplate.type ||
unifiedJobTemplate.unified_job_type ||
node.job.type;
switch (ujtType) {
case 'job_template':
case 'job':

View File

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

View File

@ -180,7 +180,7 @@ function createNode(state, node) {
newNodes.push({
id: nextNodeId,
unifiedJobTemplate: node.nodeResource,
fullUnifiedJobTemplate: node.nodeResource,
isInvalidLinkTarget: false,
promptValues: node.promptValues,
});
@ -407,7 +407,7 @@ function generateNodes(workflowNodes, i18n) {
const arrayOfNodesForChart = [
{
id: 1,
unifiedJobTemplate: {
fullUnifiedJobTemplate: {
name: i18n._(t`START`),
},
},
@ -420,8 +420,14 @@ function generateNodes(workflowNodes, i18n) {
originalNodeObject: node,
};
if (node.summary_fields.unified_job_template) {
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
if (
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);
@ -651,12 +657,19 @@ function updateLink(state, linkType) {
function updateNode(state, editedNode) {
const { nodeToEdit, nodes } = state;
const { nodeResource, launchConfig, promptValues } = editedNode;
const newNodes = [...nodes];
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
matchingNode.unifiedJobTemplate = editedNode.nodeResource;
matchingNode.fullUnifiedJobTemplate = nodeResource;
matchingNode.isEdited = true;
matchingNode.promptValues = editedNode.promptValues;
matchingNode.launchConfig = launchConfig;
if (promptValues) {
matchingNode.promptValues = promptValues;
} else {
delete matchingNode.promptValues;
}
return {
...state,
@ -671,7 +684,15 @@ function refreshNode(state, refreshedNode) {
const newNodes = [...nodes];
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 {
...state,

View File

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

View File

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

View File

@ -14,6 +14,9 @@ const nodeWithJT = {
status: 'successful',
type: 'job',
},
unified_job_template: {
name: 'Automation JT',
},
},
unifiedJobTemplate: {
id: 77,
@ -34,6 +37,9 @@ const nodeWithoutJT = {
status: 'successful',
type: 'job',
},
unified_job_template: {
name: 'Automation JT 2',
},
},
},
};
@ -80,7 +86,7 @@ describe('WorkflowOutputNode', () => {
</WorkflowStateContext.Provider>
</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');
});
test('node contents displayed correctly when Job Template deleted', () => {
@ -95,7 +101,7 @@ describe('WorkflowOutputNode', () => {
</WorkflowStateContext.Provider>
</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');
});
test('node contents displayed correctly when Job deleted', () => {

View File

@ -12,8 +12,15 @@ function NodeAddModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const { addNodeSource } = useContext(WorkflowStateContext);
const addNode = (values, linkType, config) => {
const { approvalName, approvalDescription, approvalTimeout } = values;
const addNode = (values, config) => {
const {
approvalName,
approvalDescription,
timeoutMinutes,
timeoutSeconds,
linkType,
} = values;
if (values) {
const { added, removed } = getAddedAndRemoved(
config?.defaults?.credentials,
@ -24,21 +31,21 @@ function NodeAddModal({ i18n }) {
values.removedCredentials = removed;
}
let node;
if (values.nodeType === 'approval') {
node = {
nodeResource: {
description: approvalDescription,
name: approvalName,
timeout: approvalTimeout,
type: 'workflow_approval_template',
},
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 = {
linkType,
nodeResource: values.nodeResource,
};
node.nodeResource = values.nodeResource;
if (
values?.nodeType === 'job_template' ||
values?.nodeType === 'workflow_job_template'

View File

@ -40,7 +40,10 @@ describe('NodeAddModal', () => {
el => el.length === 0
);
await act(async () => {
wrapper.find('NodeModal').prop('onSave')({ nodeResource }, 'success', {});
wrapper.find('NodeModal').prop('onSave')(
{ linkType: 'success', nodeResource },
{}
);
});
expect(dispatch).toHaveBeenCalledWith({

View File

@ -2,34 +2,47 @@ import React, { useContext } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { WorkflowDispatchContext } from '../../../../../contexts/Workflow';
import { getAddedAndRemoved } from '../../../../../util/lists';
import NodeModal from './NodeModal';
function NodeEditModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const updateNode = (values, linkType, config) => {
const { added, removed } = getAddedAndRemoved(
config?.defaults?.credentials,
values?.credentials
);
if (added?.length > 0) {
values.addedCredentals = added;
}
if (removed?.length > 0) {
values.removedCredentals = removed;
}
values.inventory = values?.inventory?.id;
delete values.linkType;
const node = {
nodeResource: values.nodeResource,
};
if (
values?.nodeType === 'job_template' ||
values?.nodeType === 'workflow_job_template'
) {
node.promptValues = values;
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({
type: 'UPDATE_NODE',
node,

View File

@ -29,26 +29,18 @@ import AlertModal from '../../../../../components/AlertModal';
import NodeNextButton from './NodeNextButton';
function canLaunchWithoutPrompt(nodeType, launchData) {
if (nodeType !== 'workflow_job_template' && nodeType !== 'job_template') {
return true;
}
return (
launchData.can_start_without_user_input &&
!launchData.ask_inventory_on_launch &&
!launchData.ask_variables_on_launch &&
!launchData.ask_limit_on_launch &&
!launchData.ask_scm_branch_on_launch &&
!launchData.survey_enabled &&
(!launchData.variables_needed_to_start ||
launchData.variables_needed_to_start.length === 0)
);
}
function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
function NodeModalForm({
askLinkType,
i18n,
onSave,
title,
credentialError,
launchConfig,
surveyConfig,
isLaunchLoading,
}) {
const history = useHistory();
const dispatch = useContext(WorkflowDispatchContext);
const { nodeToEdit } = useContext(WorkflowStateContext);
const { values, setTouched, validateForm } = useFormikContext();
const [triggerNext, setTriggerNext] = useState(0);
@ -63,67 +55,28 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
};
const {
request: readLaunchConfig,
error: launchConfigError,
result: launchConfig,
isLoading,
} = useRequest(
useCallback(async () => {
const readLaunch = (type, id) =>
type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(id)
: JobTemplatesAPI.readLaunch(id);
if (
(values?.nodeType === 'workflow_job_template' &&
values.nodeResource?.unified_job_type === 'job') ||
(values?.nodeType === 'job_template' &&
values.nodeResource?.unified_job_type === 'workflow_job')
) {
return {};
}
if (
values.nodeType === 'workflow_job_template' ||
values.nodeType === 'job_template'
) {
if (values.nodeResource) {
const { data } = await readLaunch(
values.nodeType,
values?.nodeResource?.id
);
return data;
}
}
return {};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [values.nodeResource, values.nodeType]),
{}
);
useEffect(() => {
readLaunchConfig();
}, [readLaunchConfig, values.nodeResource, values.nodeType]);
const {
steps: promptSteps,
isReady,
visitStep,
visitAllSteps,
contentError,
} = useWorkflowNodeSteps(
launchConfig,
surveyConfig,
i18n,
values.nodeResource,
askLinkType,
!canLaunchWithoutPrompt(values.nodeType, launchConfig),
nodeToEdit
askLinkType
);
const handleSaveNode = () => {
clearQueryParams();
onSave(values, askLinkType ? values.linkType : null, launchConfig);
if (values.nodeType !== 'workflow_approval_template') {
delete values.approvalName;
delete values.approvalDescription;
delete values.timeoutMinutes;
delete values.timeoutSeconds;
}
onSave(values, launchConfig);
};
const handleCancel = () => {
@ -132,30 +85,22 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
};
const { error, dismissError } = useDismissableError(
launchConfigError || contentError || credentialError
contentError || credentialError
);
const steps = [
...(isReady
? [...promptSteps]
: [
{
name: i18n._(t`Content Loading`),
component: <ContentLoading />,
},
]),
];
const nextButtonText = activeStep =>
activeStep.id === steps[steps?.length - 1]?.id ||
activeStep.id === promptSteps[promptSteps?.length - 1]?.id ||
activeStep.name === 'Preview'
? i18n._(t`Save`)
: i18n._(t`Next`);
const CustomFooter = (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack }) => (
<>
<NodeNextButton
isDisabled={isLaunchLoading}
triggerNext={triggerNext}
activeStep={activeStep}
aria-label={nextButtonText(activeStep)}
@ -163,7 +108,7 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
onClick={() => setTriggerNext(triggerNext + 1)}
buttonText={nextButtonText(activeStep)}
/>
{activeStep && activeStep.id !== steps[0]?.id && (
{activeStep && activeStep.id !== promptSteps[0]?.id && (
<Button
id="back-node-modal"
variant="secondary"
@ -187,11 +132,22 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
</WizardFooter>
);
const wizardTitle = values.nodeResource
? `${title} | ${values.nodeResource.name}`
: title;
if (error) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
if (error && !isLoading) {
if (error && !isLaunchLoading) {
return (
<AlertModal
isOpen={error}
@ -208,7 +164,7 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
return (
<Wizard
footer={CustomFooter}
isOpen={!error || !contentError}
isOpen={!error}
onClose={handleCancel}
onSave={() => {
handleSaveNode();
@ -221,9 +177,9 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
}
await validateForm();
}}
steps={steps}
steps={promptSteps}
css="overflow: scroll"
title={wizardTitle}
title={title}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
@ -236,24 +192,135 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) {
);
}
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, linkType, config) => {
onSave(values, linkType, config);
const onSaveForm = (values, config) => {
onSave(values, config);
};
return (
<Formik
initialValues={{
nodeResource:
nodeToEdit?.originalNodeObject?.summary_fields
?.unified_job_template || null,
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}>
<NodeModalForm
<NodeModalInner
onSave={onSaveForm}
i18n={i18n}
title={title}

View File

@ -16,6 +16,8 @@ import {
} from '../../../../../api';
import NodeModal from './NodeModal';
jest.mock('../../../../../api/models/Credentials');
jest.mock('../../../../../api/models/Inventories');
jest.mock('../../../../../api/models/InventorySources');
jest.mock('../../../../../api/models/JobTemplates');
jest.mock('../../../../../api/models/Projects');
@ -25,23 +27,81 @@ let wrapper;
const dispatch = 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', () => {
beforeAll(() => {
JobTemplatesAPI.read.mockResolvedValue({
data: {
count: 1,
results: [
{
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
summary_fields: {
recent_jobs: [],
},
related: { webhook_receiver: '' },
},
],
results: [mockJobTemplate],
},
});
JobTemplatesAPI.readOptions.mockResolvedValue({
@ -53,60 +113,7 @@ describe('NodeModal', () => {
related_search_fields: [],
},
});
JobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
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: '',
},
},
});
JobTemplatesAPI.readLaunch.mockResolvedValue({ data: jtLaunchConfig });
JobTemplatesAPI.readSurvey.mockResolvedValue({
data: {
name: '',
@ -241,6 +248,7 @@ describe('NodeModal', () => {
afterEach(() => {
wrapper.unmount();
onSave.mockClear();
});
test('Can successfully create a new job template node', async () => {
@ -251,7 +259,10 @@ describe('NodeModal', () => {
wrapper.find('button#next-node-modal').simulate('click');
});
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');
});
@ -265,10 +276,24 @@ describe('NodeModal', () => {
act(() => {
wrapper
.find('WizardNavItem[content="Preview"]')
.find('a')
.find('button')
.prop('onClick')();
});
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 () => {
wrapper.find('button#next-node-modal').simulate('click');
@ -276,19 +301,20 @@ describe('NodeModal', () => {
expect(onSave).toBeCalledWith(
{
linkType: 'always',
nodeResource: {
id: 1,
name: 'Test Job Template',
related: { webhook_receiver: '' },
summary_fields: { recent_jobs: [] },
type: 'job_template',
url: '/api/v2/job_templates/1',
},
nodeType: 'job_template',
verbosity: undefined,
inventory: { name: 'Foo Inv', id: 1 },
credentials: [],
job_type: '',
verbosity: '0',
job_tags: '',
skip_tags: '',
extra_vars: '---',
diff_mode: false,
survey_bar: 'answer',
nodeResource: mockJobTemplate,
extra_data: { bar: 'answer' },
},
'always',
{}
jtLaunchConfig
);
});
@ -301,10 +327,12 @@ describe('NodeModal', () => {
});
wrapper.update();
await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(null, 'project_sync');
wrapper.find('AnsibleSelect').prop('onChange')(null, 'project');
});
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');
@ -318,10 +346,9 @@ describe('NodeModal', () => {
type: 'project',
url: '/api/v2/projects/1',
},
nodeType: 'project_sync',
nodeType: 'project',
verbosity: undefined,
},
'failure',
{}
);
});
@ -337,11 +364,13 @@ describe('NodeModal', () => {
await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(
null,
'inventory_source_sync'
'inventory_source'
);
});
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');
@ -355,10 +384,9 @@ describe('NodeModal', () => {
type: 'inventory_source',
url: '/api/v2/inventory_sources/1',
},
nodeType: 'inventory_source_sync',
nodeType: 'inventory_source',
verbosity: undefined,
},
'failure',
{}
);
});
@ -398,7 +426,6 @@ describe('NodeModal', () => {
nodeType: 'workflow_job_template',
verbosity: undefined,
},
'success',
{
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
@ -429,7 +456,10 @@ describe('NodeModal', () => {
});
wrapper.update();
await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval');
wrapper.find('AnsibleSelect').prop('onChange')(
null,
'workflow_approval_template'
);
});
wrapper.update();
@ -446,12 +476,6 @@ describe('NodeModal', () => {
wrapper.find('input#approval-timeout-minutes').simulate('change', {
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', {
target: { value: 30, name: 'timeoutSeconds' },
});
@ -480,11 +504,10 @@ describe('NodeModal', () => {
approvalName: 'Test Approval',
linkType: 'always',
nodeResource: null,
nodeType: 'approval',
timeout: 330,
verbosity: undefined,
nodeType: 'workflow_approval_template',
timeoutMinutes: 5,
timeoutSeconds: 30,
},
'always',
{}
);
});
@ -511,10 +534,10 @@ describe('NodeModal', () => {
value={{
nodeToEdit: {
id: 2,
unifiedJobTemplate: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Test Project',
unified_job_type: 'project_update',
type: 'project',
},
},
}}
@ -530,11 +553,12 @@ describe('NodeModal', () => {
});
await waitForElement(newWrapper, 'PFWizard');
newWrapper.update();
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe(
'project_sync'
);
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe('project');
await act(async () => {
newWrapper.find('AnsibleSelect').prop('onChange')(null, 'approval');
newWrapper.find('AnsibleSelect').prop('onChange')(
null,
'workflow_approval_template'
);
});
newWrapper.update();
await act(async () => {
@ -550,12 +574,6 @@ describe('NodeModal', () => {
newWrapper.find('input#approval-timeout-minutes').simulate('change', {
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 () => {
newWrapper.find('input#approval-timeout-seconds').simulate('change', {
target: { value: 30, name: 'timeoutSeconds' },
});
@ -584,11 +602,10 @@ describe('NodeModal', () => {
approvalName: 'Test Approval',
linkType: 'success',
nodeResource: null,
nodeType: 'approval',
timeout: 330,
verbosity: undefined,
nodeType: 'workflow_approval_template',
timeoutMinutes: 5,
timeoutSeconds: 30,
},
null,
{}
);
});
@ -601,11 +618,11 @@ describe('NodeModal', () => {
value={{
nodeToEdit: {
id: 2,
unifiedJobTemplate: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Test Approval',
description: 'Test Approval Description',
unified_job_type: 'workflow_approval',
type: 'workflow_approval_template',
timeout: 0,
},
},
@ -621,7 +638,9 @@ describe('NodeModal', () => {
);
});
await waitForElement(newWrapper, 'PFWizard');
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe('approval');
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe(
'workflow_approval_template'
);
await act(async () => {
newWrapper.find('AnsibleSelect').prop('onChange')(
null,
@ -649,9 +668,7 @@ describe('NodeModal', () => {
url: '/api/v2/workflow_job_templates/1',
},
nodeType: 'workflow_job_template',
verbosity: undefined,
},
null,
{
ask_inventory_on_launch: false,
ask_limit_on_launch: false,

View File

@ -8,6 +8,7 @@ function NodeNextButton({
onClick,
onNext,
triggerNext,
isDisabled,
}) {
useEffect(() => {
if (!triggerNext) {
@ -22,7 +23,7 @@ function NodeNextButton({
variant="primary"
type="submit"
onClick={() => onClick(activeStep)}
isDisabled={!activeStep.enableNext}
isDisabled={isDisabled || !activeStep.enableNext}
>
{buttonText}
</Button>

View File

@ -1,5 +1,5 @@
import 'styled-components/macro';
import React, { useState } from 'react';
import React from 'react';
import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import styled from 'styled-components';
@ -28,13 +28,16 @@ const TimeoutLabel = styled.p`
`;
function NodeTypeStep({ i18n }) {
const [timeoutMinutes, setTimeoutMinutes] = useState(0);
const [timeoutSeconds, setTimeoutSeconds] = useState(0);
const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType');
const [nodeResourceField, , nodeResourceHelpers] = useField('nodeResource');
const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName');
const [, , approvalDescriptionHelpers] = useField('approvalDescription');
const [, , timeoutHelpers] = useField('timeout');
const [timeoutMinutesField, , timeoutMinutesHelpers] = useField(
'timeoutMinutes'
);
const [timeoutSecondsField, , timeoutSecondsHelpers] = useField(
'timeoutSeconds'
);
const isValid = !approvalNameMeta.touched || !approvalNameMeta.error;
return (
@ -47,14 +50,14 @@ function NodeTypeStep({ i18n }) {
label={i18n._(t`Select a Node Type`)}
data={[
{
key: 'approval',
value: 'approval',
key: 'workflow_approval_template',
value: 'workflow_approval_template',
label: i18n._(t`Approval`),
isDisabled: false,
},
{
key: 'inventory_source_sync',
value: 'inventory_source_sync',
key: 'inventory_source',
value: 'inventory_source',
label: i18n._(t`Inventory Source Sync`),
isDisabled: false,
},
@ -65,8 +68,8 @@ function NodeTypeStep({ i18n }) {
isDisabled: false,
},
{
key: 'project_sync',
value: 'project_sync',
key: 'project',
value: 'project',
label: i18n._(t`Project Sync`),
isDisabled: false,
},
@ -83,7 +86,8 @@ function NodeTypeStep({ i18n }) {
nodeResourceHelpers.setValue(null);
approvalNameHelpers.setValue('');
approvalDescriptionHelpers.setValue('');
timeoutHelpers.setValue(0);
timeoutMinutesHelpers.setValue(0);
timeoutSecondsHelpers.setValue(0);
}}
/>
</div>
@ -94,13 +98,13 @@ function NodeTypeStep({ i18n }) {
onUpdateNodeResource={nodeResourceHelpers.setValue}
/>
)}
{nodeTypeField.value === 'project_sync' && (
{nodeTypeField.value === 'project' && (
<ProjectsList
nodeResource={nodeResourceField.value}
onUpdateNodeResource={nodeResourceHelpers.setValue}
/>
)}
{nodeTypeField.value === 'inventory_source_sync' && (
{nodeTypeField.value === 'inventory_source' && (
<InventorySourcesList
nodeResource={nodeResourceField.value}
onUpdateNodeResource={nodeResourceHelpers.setValue}
@ -112,12 +116,11 @@ function NodeTypeStep({ i18n }) {
onUpdateNodeResource={nodeResourceHelpers.setValue}
/>
)}
{nodeTypeField.value === 'approval' && (
{nodeTypeField.value === 'workflow_approval_template' && (
<Form css="margin-top: 20px;">
<FormFullWidthLayout>
<FormField
name="approvalName"
fieldId="approval-name"
id="approval-name"
isRequired
validate={required(null, i18n)}
@ -126,7 +129,6 @@ function NodeTypeStep({ i18n }) {
/>
<FormField
name="approvalDescription"
fieldId="approval-description"
id="approval-description"
label={i18n._(t`Description`)}
/>
@ -137,44 +139,29 @@ function NodeTypeStep({ i18n }) {
>
<div css="display: flex;align-items: center;">
<TimeoutInput
aria-label={i18n._(t`timeout-minutes`)}
name="timeoutMinutes"
{...timeoutMinutesField}
aria-label={i18n._(t`Timeout minutes`)}
id="approval-timeout-minutes"
type="number"
min="0"
step="1"
value={timeoutMinutes}
onChange={(value, evt) => {
if (!evt.target.value || evt.target.value === '') {
evt.target.value = 0;
}
setTimeoutMinutes(evt.target.value);
timeoutHelpers.setValue(
Number(evt.target.value) * 60 + Number(timeoutSeconds)
);
onChange={(value, event) => {
timeoutMinutesField.onChange(event);
}}
step="1"
type="number"
/>
<TimeoutLabel>
<Trans>min</Trans>
</TimeoutLabel>
<TimeoutInput
name="timeoutSeconds"
{...timeoutSecondsField}
aria-label={i18n._(t`Timeout seconds`)}
id="approval-timeout-seconds"
type="number"
aria-label={i18n._(t`timeout-seconds`)}
min="0"
step="1"
value={timeoutSeconds}
onChange={(value, evt) => {
if (!evt.target.value || evt.target.value === '') {
evt.target.value = 0;
}
setTimeoutSeconds(evt.target.value);
timeoutHelpers.setValue(
Number(evt.target.value) + Number(timeoutMinutes) * 60
);
onChange={(value, event) => {
timeoutSecondsField.onChange(event);
}}
step="1"
type="number"
/>
<TimeoutLabel>
<Trans>sec</Trans>

View File

@ -123,31 +123,31 @@ describe('NodeTypeStep', () => {
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template');
expect(wrapper.find('JobTemplatesList').length).toBe(1);
});
test('It shows the project list when node type is project sync', async () => {
test('It shows the project list when node type is project', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{ nodeType: 'project_sync' }}>
<Formik initialValues={{ nodeType: 'project' }}>
<NodeTypeStep />
</Formik>
);
});
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);
});
test('It shows the inventory source list when node type is inventory source sync', async () => {
test('It shows the inventory source list when node type is inventory source', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{ nodeType: 'inventory_source_sync' }}>
<Formik initialValues={{ nodeType: 'inventory_source' }}>
<NodeTypeStep />
</Formik>
);
});
wrapper.update();
expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
'inventory_source_sync'
'inventory_source'
);
expect(wrapper.find('InventorySourcesList').length).toBe(1);
});
@ -172,10 +172,11 @@ describe('NodeTypeStep', () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
nodeType: 'approval',
nodeType: 'workflow_approval_template',
approvalName: '',
approvalDescription: '',
timeout: '',
timeoutMinutes: 0,
timeoutSeconds: 0,
}}
>
<NodeTypeStep />
@ -183,7 +184,9 @@ describe('NodeTypeStep', () => {
);
});
wrapper.update();
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval');
expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
'workflow_approval_template'
);
expect(wrapper.find('FormField[label="Name"]').length).toBe(1);
expect(wrapper.find('FormField[label="Description"]').length).toBe(1);
expect(wrapper.find('input[name="timeoutMinutes"]').length).toBe(1);

View File

@ -5,7 +5,7 @@ import NodeTypeStep from './NodeTypeStep';
const STEP_ID = 'nodeType';
export default function useNodeTypeStep(i18n, nodeToEdit) {
export default function useNodeTypeStep(i18n) {
const [, meta] = useField('nodeType');
const [approvalNameField] = useField('approvalName');
const [nodeTypeField, ,] = useField('nodeType');
@ -13,7 +13,7 @@ export default function useNodeTypeStep(i18n, nodeToEdit) {
return {
step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField),
initialValues: getInitialValues(nodeToEdit),
initialValues: getInitialValues(),
isReady: true,
contentError: null,
formError: meta.error,
@ -27,8 +27,9 @@ export default function useNodeTypeStep(i18n, nodeToEdit) {
function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {
const isEnabled = () => {
if (
(nodeTypeField.value !== 'approval' && nodeResourceField.value === null) ||
(nodeTypeField.value === 'approval' &&
(nodeTypeField.value !== 'workflow_approval_template' &&
nodeResourceField.value === null) ||
(nodeTypeField.value === 'workflow_approval_template' &&
approvalNameField.value === undefined)
) {
return false;
@ -37,57 +38,18 @@ function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {
};
return {
id: STEP_ID,
key: 3,
name: i18n._(t`Node Type`),
component: <NodeTypeStep i18n={i18n} />,
enableNext: isEnabled(),
};
}
function getInitialValues(nodeToEdit) {
let typeOfNode;
if (
!nodeToEdit?.unifiedJobTemplate?.type &&
!nodeToEdit?.unifiedJobTemplate?.unified_job_type
) {
return { nodeType: 'job_template' };
}
const {
unifiedJobTemplate: { type, unified_job_type },
} = nodeToEdit;
const unifiedType = type || unified_job_type;
if (unifiedType === 'job' || unifiedType === 'job_template')
typeOfNode = {
nodeType: 'job_template',
nodeResource:
nodeToEdit.originalNodeObject?.summary_fields?.unified_job_template
|| nodeToEdit.unifiedJobTemplate,
};
if (unifiedType === 'project' || unifiedType === 'project_update') {
typeOfNode = { nodeType: 'project_sync' };
}
if (
unifiedType === 'inventory_source' ||
unifiedType === 'inventory_update'
) {
typeOfNode = { nodeType: 'inventory_source_sync' };
}
if (
unifiedType === 'workflow_job' ||
unifiedType === 'workflow_job_template'
) {
typeOfNode = {
nodeType: 'workflow_job_template',
};
}
if (
unifiedType === 'workflow_approval_template' ||
unifiedType === 'workflow_approval'
) {
typeOfNode = {
nodeType: 'approval',
};
}
return typeOfNode;
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 PromptDetail from '../../../../../components/PromptDetail';
import useRequest from '../../../../../util/useRequest';
import {
InventorySourcesAPI,
JobTemplatesAPI,
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;
}
}
import { jsonToYaml } from '../../../../../util/yaml';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../../../api';
import getNodeType from '../../shared/WorkflowJobTemplateVisualizerUtils';
function NodeViewModal({ i18n, readOnly }) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodeToView } = useContext(WorkflowStateContext);
const { unifiedJobTemplate } = nodeToView;
const [nodeType, nodeAPI] = getNodeType(unifiedJobTemplate);
const {
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 {
result: launchConfig,
@ -56,39 +42,44 @@ function NodeViewModal({ i18n, readOnly }) {
useCallback(async () => {
const readLaunch =
nodeType === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(unifiedJobTemplate.id)
: JobTemplatesAPI.readLaunch(unifiedJobTemplate.id);
? WorkflowJobTemplatesAPI.readLaunch(id)
: JobTemplatesAPI.readLaunch(id);
const { data } = await readLaunch;
return data;
}, [nodeType, unifiedJobTemplate.id]),
}, [nodeType, id]),
{}
);
const {
result: nodeDetail,
isLoading: isNodeDetailLoading,
error: nodeDetailError,
request: fetchNodeDetail,
result: relatedData,
isLoading: isRelatedDataLoading,
error: relatedDataError,
request: fetchRelatedData,
} = useRequest(
useCallback(async () => {
let { data } = await nodeAPI?.readDetail(unifiedJobTemplate.id);
if (data?.type === 'job_template') {
const related = {};
if (
nodeType === 'job_template' &&
!fullUnifiedJobTemplate.instance_groups
) {
const {
data: { results = [] },
} = await JobTemplatesAPI.readInstanceGroups(data.id);
data = Object.assign(data, { instance_groups: results });
} = await JobTemplatesAPI.readInstanceGroups(fullUnifiedJobTemplate.id);
related.instance_groups = results;
}
if (data?.related?.webhook_receiver) {
if (
fullUnifiedJobTemplate?.related?.webhook_receiver &&
!fullUnifiedJobTemplate.webhook_key
) {
const {
data: { webhook_key },
} = await nodeAPI?.readWebhookKey(data.id);
data = Object.assign(data, { webhook_key });
} = await nodeAPI?.readWebhookKey(fullUnifiedJobTemplate.id);
related.webhook_key = webhook_key;
}
return data;
}, [nodeAPI, unifiedJobTemplate.id]),
return related;
}, [nodeAPI, fullUnifiedJobTemplate, nodeType]),
null
);
@ -97,21 +88,27 @@ function NodeViewModal({ i18n, readOnly }) {
fetchLaunchConfig();
}
if (unifiedJobTemplate.unified_job_type && nodeType !== 'approval') {
fetchNodeDetail();
if (
fullUnifiedJobTemplate &&
((nodeType === 'job_template' &&
!fullUnifiedJobTemplate.instance_groups) ||
(fullUnifiedJobTemplate?.related?.webhook_receiver &&
!fullUnifiedJobTemplate.webhook_key))
) {
fetchRelatedData();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (nodeDetail) {
if (relatedData) {
dispatch({
type: 'REFRESH_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 = () => {
dispatch({ type: 'SET_NODE_TO_VIEW', value: null });
@ -119,13 +116,74 @@ function NodeViewModal({ i18n, readOnly }) {
};
let Content;
if (isLaunchConfigLoading || isNodeDetailLoading) {
if (isLaunchConfigLoading || isRelatedDataLoading) {
Content = <ContentLoading />;
} else if (launchConfigError || nodeDetailError) {
Content = <ContentError error={launchConfigError || nodeDetailError} />;
} else {
} else if (launchConfigError || relatedDataError) {
Content = <ContentError error={launchConfigError || relatedDataError} />;
} else if (!fullUnifiedJobTemplate) {
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
variant="large"
isOpen
title={unifiedJobTemplate.name}
title={fullUnifiedJobTemplate?.name || i18n._(t`Resource deleted`)}
aria-label={i18n._(t`Workflow node view modal`)}
onClose={() => dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
actions={

View File

@ -53,13 +53,16 @@ describe('NodeViewModal', () => {
let wrapper;
const workflowContext = {
nodeToView: {
unifiedJobTemplate: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
unified_job_type: 'workflow_job',
created: '2019-08-08T19:24:05.344276Z',
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', () => {
expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readDetail).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled();
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
expect(WorkflowJobTemplatesAPI.readWebhookKey).toHaveBeenCalledWith(1);
@ -118,7 +120,7 @@ describe('NodeViewModal', () => {
describe('Job template node', () => {
const workflowContext = {
nodeToView: {
unifiedJobTemplate: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
@ -145,7 +147,6 @@ describe('NodeViewModal', () => {
expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readWebhookKey).not.toHaveBeenCalledWith();
expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
expect(JobTemplatesAPI.readDetail).toHaveBeenCalledWith(1);
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
wrapper.unmount();
jest.clearAllMocks();
@ -207,7 +208,7 @@ describe('NodeViewModal', () => {
describe('Project node', () => {
const workflowContext = {
nodeToView: {
unifiedJobTemplate: {
fullUnifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
@ -237,4 +238,71 @@ describe('NodeViewModal', () => {
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

@ -28,7 +28,6 @@ function getStep(askLinkType, meta, i18n) {
}
return {
id: STEP_ID,
key: 1,
name: i18n._(t`Run Type`),
component: <RunStep />,
enableNext: meta.value !== '',

View File

@ -1,88 +1,263 @@
import { useState, useEffect } from 'react';
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 && launchConfig.survey_enabled) {
if (nodeToEdit?.promptValues?.extra_vars) {
initialValues.extra_vars = nodeToEdit.promptValues.extra_vars;
} else {
const newExtraData = { ...nodeToEdit.originalNodeObject.extra_data };
if (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(
config,
launchConfig,
surveyConfig,
i18n,
resource,
askLinkType,
needsPreviewStep,
nodeToEdit
askLinkType
) {
const { nodeToEdit } = useContext(WorkflowStateContext);
const { resetForm, values: formikValues } = useFormikContext();
const [visited, setVisited] = useState({});
const steps = [
useRunTypeStep(i18n, askLinkType),
useNodeTypeStep(i18n, nodeToEdit),
useInventoryStep(
config,
i18n,
visited,
resource,
nodeToEdit
),
useCredentialsStep(config, i18n, resource, nodeToEdit?.originalNodeObject),
useOtherPromptsStep(config, i18n, resource, nodeToEdit?.originalNodeObject),
useSurveyStep(
config,
i18n,
visited,
resource,
nodeToEdit?.originalNodeObject
),
useNodeTypeStep(i18n),
useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n),
useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
];
const { resetForm, values: formikValues } = useFormikContext();
const hasErrors = steps.some(step => step.formError);
const surveyStepIndex = steps.findIndex(step => step.survey);
steps.push(
usePreviewStep(
config,
launchConfig,
i18n,
resource,
steps[surveyStepIndex]?.survey,
surveyConfig,
hasErrors,
needsPreviewStep,
nodeToEdit?.originalNodeObject
showPreviewStep(formikValues.nodeType, launchConfig)
)
);
const pfSteps = steps.map(s => s.step).filter(s => s != null);
const isReady = !steps.some(s => !s.isReady);
const initialValues = steps.reduce((acc, cur) => {
return {
...acc,
...cur.initialValues,
};
}, {});
useEffect(() => {
if (isReady) {
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 || initialValues.nodeResource,
nodeType: formikValues.nodeType || initialValues.nodeType,
linkType: formikValues.linkType || 'success',
nodeResource: formikValues.nodeResource,
nodeType: formikValues.nodeType,
linkType: formikValues.linkType,
verbosity: initialValues?.verbosity?.toString(),
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, isReady]);
}, [launchConfig, surveyConfig, isReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
return {
steps: pfSteps,
initialValues,
isReady,
visitStep: stepId =>
setVisited({
...visited,

View File

@ -7,6 +7,7 @@ import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '../../../contexts/Workflow';
import { getAddedAndRemoved } from '../../../util/lists';
import { layoutGraph } from '../../../components/Workflow/WorkflowUtils';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
@ -46,6 +47,45 @@ const Wrapper = styled.div`
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 (
templateId,
pageNo = 1,
@ -286,7 +326,7 @@ function Visualizer({ template, i18n }) {
WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id)
);
} else if (!node.isDeleted && !node.originalNodeObject) {
if (node.unifiedJobTemplate.type === 'workflow_approval_template') {
if (node.fullUnifiedJobTemplate.type === 'workflow_approval_template') {
nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {}).then(
({ data }) => {
@ -299,20 +339,20 @@ function Visualizer({ template, i18n }) {
};
approvalTemplateRequests.push(
WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, {
name: node.unifiedJobTemplate.name,
description: node.unifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout,
name: node.fullUnifiedJobTemplate.name,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
})
);
}
)
);
} else {
node.promptValues.inventory = node.promptValues?.inventory?.id
nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {
...node.promptValues,
unified_job_template: node.unifiedJobTemplate.id,
...node.promptValues,
inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id,
}).then(({ data }) => {
node.originalNodeObject = data;
originalLinkMap[node.id] = {
@ -345,11 +385,7 @@ function Visualizer({ template, i18n }) {
);
}
} else if (node.isEdited) {
if (
node.unifiedJobTemplate &&
(node.unifiedJobTemplate.unified_job_type === 'workflow_approval' ||
node.unifiedJobTemplate.type === 'workflow_approval_template')
) {
if (node.fullUnifiedJobTemplate.type === 'workflow_approval_template') {
if (
node.originalNodeObject.summary_fields.unified_job_template
.unified_job_type === 'workflow_approval'
@ -358,9 +394,9 @@ function Visualizer({ template, i18n }) {
WorkflowApprovalTemplatesAPI.update(
node.originalNodeObject.summary_fields.unified_job_template.id,
{
name: node.unifiedJobTemplate.name,
description: node.unifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout,
name: node.fullUnifiedJobTemplate.name,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
}
)
);
@ -369,32 +405,45 @@ function Visualizer({ template, i18n }) {
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
node.originalNodeObject.id,
{
name: node.unifiedJobTemplate.name,
description: node.unifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout,
name: node.fullUnifiedJobTemplate.name,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
}
)
);
}
} else {
nodeRequests.push(
WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, {
WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, {
...node.promptValues,
unified_job_template: node.unifiedJobTemplate.id,
inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id,
})
);
if (node?.promptValues?.addedCredentials?.length > 0) {
node.promptValues.addedCredentials.forEach(cred =>
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 (node?.promptValues?.removedCredentials?.length > 0) {
node.promptValues.removedCredentials.forEach(cred =>
if (removedCredentials?.length > 0) {
removedCredentials.forEach(cred =>
disassociateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.disassociateCredentials(
node.originalNodeObject.id,

View File

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

View File

@ -14,12 +14,16 @@ import {
WorkflowDispatchContext,
WorkflowStateContext,
} 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 {
WorkflowActionTooltip,
WorkflowActionTooltipItem,
WorkflowNodeTypeLetter,
} from '../../../components/Workflow';
import getNodeType from './shared/WorkflowJobTemplateVisualizerUtils';
const NodeG = styled.g`
pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')};
@ -52,13 +56,85 @@ function VisualizerNode({
}) {
const ref = useRef(null);
const [hovering, setHovering] = useState(false);
const [credentialsError, setCredentialsError] = useState(null);
const [detailError, setDetailError] = useState(null);
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, addLinkSourceNode, nodePositions } = useContext(
const { addingLink, addLinkSourceNode, nodePositions, nodes } = useContext(
WorkflowStateContext
);
const isAddLinkSourceNode =
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 = () => {
ref.current.parentNode.appendChild(ref.current);
setHovering(true);
@ -91,11 +167,7 @@ function VisualizerNode({
<WorkflowActionTooltipItem
id="node-details"
key="details"
onClick={() => {
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_VIEW', value: node });
}}
onClick={handleViewClick}
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)}
>
@ -123,11 +195,7 @@ function VisualizerNode({
<WorkflowActionTooltipItem
id="node-edit"
key="edit"
onClick={() => {
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_EDIT', value: node });
}}
onClick={handleEditClick}
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => updateHelpText(null)}
>
@ -164,57 +232,83 @@ function VisualizerNode({
];
return (
<NodeG
id={`node-${node.id}`}
job={node.job}
noPointerEvents={isAddLinkSourceNode}
onMouseEnter={handleNodeMouseEnter}
onMouseLeave={handleNodeMouseLeave}
ref={ref}
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
.y - nodePositions[1].y})`}
>
<rect
fill="#FFFFFF"
height={wfConstants.nodeH}
rx="2"
ry="2"
stroke={
hovering && addingLink && !node.isInvalidLinkTarget
? '#007ABC'
: '#93969A'
}
strokeWidth="2px"
width={wfConstants.nodeW}
/>
<foreignObject
height="58"
{...(!addingLink && {
onMouseEnter: () => updateNodeHelp(node),
onMouseLeave: () => updateNodeHelp(null),
})}
onClick={() => handleNodeClick()}
width="178"
x="1"
y="1"
<>
<NodeG
id={`node-${node.id}`}
job={node.job}
noPointerEvents={isAddLinkSourceNode}
onMouseEnter={handleNodeMouseEnter}
onMouseLeave={handleNodeMouseLeave}
ref={ref}
transform={`translate(${nodePositions[node.id].x},${nodePositions[
node.id
].y - nodePositions[1].y})`}
>
<NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}>
<NodeResourceName id={`node-${node.id}-name`}>
{node.unifiedJobTemplate
? node.unifiedJobTemplate.name
: i18n._(t`DELETED`)}
</NodeResourceName>
</NodeContents>
</foreignObject>
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
{hovering && !addingLink && (
<WorkflowActionTooltip
pointX={wfConstants.nodeW}
pointY={wfConstants.nodeH / 2}
actions={tooltipActions}
<rect
fill="#FFFFFF"
height={wfConstants.nodeH}
rx="2"
ry="2"
stroke={
hovering && addingLink && !node.isInvalidLinkTarget
? '#007ABC'
: '#93969A'
}
strokeWidth="2px"
width={wfConstants.nodeW}
/>
<foreignObject
height="58"
{...(!addingLink && {
onMouseEnter: () => updateNodeHelp(node),
onMouseLeave: () => updateNodeHelp(null),
})}
onClick={() => handleNodeClick()}
width="178"
x="1"
y="1"
>
<NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}>
<NodeResourceName id={`node-${node.id}-name`}>
{node?.fullUnifiedJobTemplate?.name ||
node?.originalNodeObject?.summary_fields?.unified_job_template
?.name ||
i18n._(t`DELETED`)}
</NodeResourceName>
</NodeContents>
</foreignObject>
<WorkflowNodeTypeLetter node={node} />
{hovering && !addingLink && (
<WorkflowActionTooltip
pointX={wfConstants.nodeW}
pointY={wfConstants.nodeH / 2}
actions={tooltipActions}
/>
)}
</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>
)}
</NodeG>
{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 { act } from 'react-dom/test-utils';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '../../../contexts/Workflow';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '../../../api';
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 = {
addingLink: false,
@ -23,15 +44,7 @@ const mockedContext = {
y: 40,
},
},
};
const nodeWithJT = {
id: 2,
unifiedJobTemplate: {
id: 77,
name: 'Automation JT',
type: 'job_template',
},
nodes: [nodeWithJT],
};
const dispatch = jest.fn();
@ -47,8 +60,6 @@ describe('VisualizerNode', () => {
<WorkflowStateContext.Provider value={mockedContext}>
<svg>
<VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
readOnly={false}
updateHelpText={updateHelpText}
@ -59,6 +70,9 @@ describe('VisualizerNode', () => {
</WorkflowDispatchContext.Provider>
);
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
wrapper.unmount();
});
@ -67,10 +81,10 @@ describe('VisualizerNode', () => {
});
test('Displays action tooltip on hover and updates help text on hover', () => {
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('WorkflowActionTooltipItem').length).toBe(5);
wrapper.find('VisualizerNode').simulate('mouseleave');
wrapper.find('g').simulate('mouseleave');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper
.find('foreignObject')
@ -85,7 +99,7 @@ describe('VisualizerNode', () => {
});
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');
expect(updateHelpText).toHaveBeenCalledWith('Add a new node');
wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseleave');
@ -98,8 +112,8 @@ describe('VisualizerNode', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Edit tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
test('Edit tooltip action hover/click updates help text and dispatches properly', async () => {
wrapper.find('g').simulate('mouseenter');
wrapper
.find('WorkflowActionTooltipItem#node-edit')
.simulate('mouseenter');
@ -109,15 +123,27 @@ describe('VisualizerNode', () => {
.simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('WorkflowActionTooltipItem#node-edit').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_EDIT',
value: nodeWithJT,
});
await asyncFlush();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch.mock.calls).toEqual([
[
{
type: 'SET_NODES',
value: [nodeWithJT],
},
],
[
{
type: 'SET_NODE_TO_EDIT',
value: nodeWithJT,
},
],
]);
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Details tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
test('Details tooltip action hover/click updates help text and dispatches properly', async () => {
wrapper.find('g').simulate('mouseenter');
wrapper
.find('WorkflowActionTooltipItem#node-details')
.simulate('mouseenter');
@ -127,15 +153,27 @@ describe('VisualizerNode', () => {
.simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('WorkflowActionTooltipItem#node-details').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_VIEW',
value: nodeWithJT,
});
await asyncFlush();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch.mock.calls).toEqual([
[
{
type: 'SET_NODES',
value: [nodeWithJT],
},
],
[
{
type: 'SET_NODE_TO_VIEW',
value: nodeWithJT,
},
],
]);
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Link tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('g').simulate('mouseenter');
wrapper
.find('WorkflowActionTooltipItem#node-link')
.simulate('mouseenter');
@ -153,7 +191,7 @@ describe('VisualizerNode', () => {
});
test('Delete tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('g').simulate('mouseenter');
wrapper
.find('WorkflowActionTooltipItem#node-delete')
.simulate('mouseenter');
@ -201,12 +239,12 @@ describe('VisualizerNode', () => {
});
test('Displays correct help text when hovering over node while adding link', () => {
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(updateHelpText).toHaveBeenCalledWith(
'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(updateHelpText).toHaveBeenCalledWith(null);
});
@ -227,8 +265,6 @@ describe('VisualizerNode', () => {
<svg>
<WorkflowStateContext.Provider value={mockedContext}>
<VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={{
id: 2,
}}
@ -243,4 +279,143 @@ describe('VisualizerNode', () => {
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

@ -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];
}
}