Adds Node Modal Promptability

Adds steps for NodeType, RunType, Inventory, Credentials, updates Reducers, adds API calls, adds Add functionality to Visualizer;

Adds other prompt step

Adds SurveyStep

refactors add node functionality
This commit is contained in:
Alex Corey 2020-10-12 14:51:23 -04:00 committed by mabashian
parent 33c3a6d89b
commit 20231041e6
26 changed files with 1285 additions and 654 deletions

View File

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

View File

@ -28,10 +28,9 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
const surveyValues = getSurveyValues(values);
const overrides = { ...values };
if (config.ask_variables_on_launch || config.survey_enabled) {
const initialExtraVars = config.ask_variables_on_launch
? values.extra_vars || '---'
? overrides.extra_vars || '---'
: resource.extra_vars;
if (survey && survey.spec) {
const passwordFields = survey.spec
@ -45,6 +44,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;
return (
<Fragment>

View File

@ -104,31 +104,4 @@ 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',
});
});
});

View File

@ -4,9 +4,15 @@ import CredentialsStep from './CredentialsStep';
const STEP_ID = 'credentials';
export default function useCredentialsStep(config, i18n) {
export default function useCredentialsStep(config, i18n, resource) {
const validate = () => {
return {};
};
return {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
contentError: null,
formError: null,
@ -24,7 +30,18 @@ function getStep(config, i18n) {
}
return {
id: STEP_ID,
key: 4,
name: i18n._(t`Credentials`),
component: <CredentialsStep i18n={i18n} />,
enableNext: true,
};
}
function getInitialValues(config, resource) {
if (!config.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || [],
};
}

View File

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

View File

@ -1,12 +1,14 @@
import React from 'react';
import { t } from '@lingui/macro';
import { jsonToYaml, parseVariableField } from '../../../util/yaml';
import OtherPromptsStep from './OtherPromptsStep';
const STEP_ID = 'other';
export default function useOtherPrompt(config, i18n) {
export default function useOtherPrompt(config, i18n, resource) {
return {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource),
isReady: true,
contentError: null,
formError: null,
@ -30,8 +32,10 @@ function getStep(config, i18n) {
}
return {
id: STEP_ID,
key: 5,
name: i18n._(t`Other Prompts`),
component: <OtherPromptsStep config={config} i18n={i18n} />,
enableNext: true,
};
}
@ -47,3 +51,49 @@ function shouldShowPrompt(config) {
config.ask_diff_mode_on_launch
);
}
function getInitialValues(config, resource) {
if (!config) {
return {};
}
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) {
initialValues.job_type = resource?.job_type || '';
}
if (config.ask_limit_on_launch) {
initialValues.limit = resource?.limit || '';
}
if (config.ask_verbosity_on_launch) {
initialValues.verbosity = resource?.verbosity || 0;
}
if (config.ask_tags_on_launch) {
initialValues.job_tags = resource?.job_tags || '';
}
if (config.ask_skip_tags_on_launch) {
initialValues.skip_tags = resource?.skip_tags || '';
}
if (config.ask_variables_on_launch) {
initialValues.extra_vars = getVariablesData();
}
if (config.ask_scm_branch_on_launch) {
initialValues.scm_branch = resource?.scm_branch || '';
}
if (config.ask_diff_mode_on_launch) {
initialValues.diff_mode = resource?.diff_mode || false;
}
return initialValues;
}

View File

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

View File

@ -8,7 +8,7 @@ import StepName from './StepName';
const STEP_ID = 'survey';
export default function useSurveyStep(config, visitedSteps, i18n) {
export default function useSurveyStep(config, i18n, visitedSteps, resource) {
const { values } = useFormikContext();
const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
useCallback(async () => {
@ -28,11 +28,11 @@ export default function useSurveyStep(config, visitedSteps, i18n) {
fetchSurvey();
}, [fetchSurvey]);
const errors = {};
const validate = () => {
if (!config.survey_enabled || !survey || !survey.spec) {
return {};
}
const errors = {};
survey.spec.forEach(question => {
const errMessage = validateField(
question,
@ -47,12 +47,13 @@ export default function useSurveyStep(config, visitedSteps, i18n) {
};
const formError = Object.keys(validate()).length > 0;
return {
step: getStep(config, survey, formError, i18n, visitedSteps),
formError,
initialValues: getInitialValues(config, survey),
step: getStep(config, survey, validate, i18n, visitedSteps),
initialValues: getInitialValues(config, survey, resource),
validate,
survey,
isReady: !isLoading && !!survey,
contentError: error,
formError,
setTouched: setFieldsTouched => {
if (!survey || !survey.spec) {
return;
@ -84,24 +85,25 @@ function validateField(question, value, i18n) {
);
}
}
if (
question.required &&
((!value && value !== 0) || (Array.isArray(value) && value.length === 0))
) {
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function getStep(config, survey, hasErrors, i18n, visitedSteps) {
function getStep(config, survey, validate, i18n, visitedSteps) {
if (!config.survey_enabled) {
return null;
}
return {
id: STEP_ID,
key: 6,
name: (
<StepName
hasErrors={Object.keys(visitedSteps).includes(STEP_ID) && hasErrors}
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
Object.keys(validate()).length
}
>
{i18n._(t`Survey`)}
</StepName>
@ -110,23 +112,33 @@ function getStep(config, survey, hasErrors, i18n, visitedSteps) {
enableNext: true,
};
}
function getInitialValues(config, survey) {
function getInitialValues(config, survey, resource) {
if (!config.survey_enabled || !survey) {
return {};
}
const surveyValues = {};
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
if (question.default === '') {
surveyValues[`survey_${question.variable}`] = [];
const values = {};
if (survey && survey.spec) {
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
values[`survey_${question.variable}`] = question.default.split('\n');
} else {
surveyValues[`survey_${question.variable}`] = question.default.split(
'\n'
);
values[`survey_${question.variable}`] = question.default;
}
} else {
surveyValues[`survey_${question.variable}`] = question.default;
}
});
return surveyValues;
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.split('\n');
} else {
values[`survey_${question.variable}`] = value;
}
}
});
}
});
}
return values;
}

View File

@ -9,10 +9,10 @@ import usePreviewStep from './steps/usePreviewStep';
export default function useLaunchSteps(config, resource, i18n) {
const [visited, setVisited] = useState({});
const steps = [
useInventoryStep(config, visited, i18n),
useInventoryStep(config, i18n, visited ),
useCredentialsStep(config, i18n),
useOtherPromptsStep(config, i18n),
useSurveyStep(config, visited, i18n),
useSurveyStep(config, i18n, visited ),
];
const { resetForm, values: formikValues } = useFormikContext();
const hasErrors = steps.some(step => step.formError);
@ -21,10 +21,11 @@ export default function useLaunchSteps(config, resource, i18n) {
steps.push(
usePreviewStep(
config,
i18n,
resource,
steps[surveyStepIndex]?.survey,
hasErrors,
i18n
)
);

View File

@ -53,6 +53,7 @@ function buildResourceLink(resource) {
function hasPromptData(launchData) {
return (
launchData.survey_enabled ||
launchData.ask_credential_on_launch ||
launchData.ask_diff_mode_on_launch ||
launchData.ask_inventory_on_launch ||

View File

@ -72,7 +72,7 @@ function PromptJobTemplateDetail({ i18n, resource }) {
? 'smart_inventory'
: 'inventory';
const recentJobs = summary_fields.recent_jobs.map(job => ({
const recentJobs = summary_fields?.recent_jobs?.map(job => ({
...job,
type: 'job',
}));

View File

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

View File

@ -55,27 +55,60 @@ export default function visualizerReducer(state, action) {
case 'SELECT_SOURCE_FOR_LINKING':
return selectSourceForLinking(state, action.node);
case 'SET_ADD_LINK_TARGET_NODE':
return { ...state, addLinkTargetNode: action.value };
return {
...state,
addLinkTargetNode: action.value,
};
case 'SET_CONTENT_ERROR':
return { ...state, contentError: action.value };
return {
...state,
contentError: action.value,
};
case 'SET_IS_LOADING':
return { ...state, isLoading: action.value };
return {
...state,
isLoading: action.value,
};
case 'SET_LINK_TO_DELETE':
return { ...state, linkToDelete: action.value };
return {
...state,
linkToDelete: action.value,
};
case 'SET_LINK_TO_EDIT':
return { ...state, linkToEdit: action.value };
return {
...state,
linkToEdit: action.value,
};
case 'SET_NODES':
return { ...state, nodes: action.value };
return {
...state,
nodes: action.value,
};
case 'SET_NODE_POSITIONS':
return { ...state, nodePositions: action.value };
return {
...state,
nodePositions: action.value,
};
case 'SET_NODE_TO_DELETE':
return { ...state, nodeToDelete: action.value };
return {
...state,
nodeToDelete: action.value,
};
case 'SET_NODE_TO_EDIT':
return { ...state, nodeToEdit: action.value };
return {
...state,
nodeToEdit: action.value,
};
case 'SET_NODE_TO_VIEW':
return { ...state, nodeToView: action.value };
return {
...state,
nodeToView: action.value,
};
case 'SET_SHOW_DELETE_ALL_NODES_MODAL':
return { ...state, showDeleteAllNodesModal: action.value };
return {
...state,
showDeleteAllNodesModal: action.value,
};
case 'START_ADD_NODE':
return {
...state,
@ -113,8 +146,12 @@ function createLink(state, linkType) {
});
newLinks.push({
source: { id: addLinkSourceNode.id },
target: { id: addLinkTargetNode.id },
source: {
id: addLinkSourceNode.id,
},
target: {
id: addLinkTargetNode.id,
},
linkType,
});
@ -145,6 +182,7 @@ function createNode(state, node) {
id: nextNodeId,
unifiedJobTemplate: node.nodeResource,
isInvalidLinkTarget: false,
promptValues: node.promptValues,
});
// Ensures that root nodes appear to always run
@ -154,8 +192,12 @@ function createNode(state, node) {
}
newLinks.push({
source: { id: addNodeSource },
target: { id: nextNodeId },
source: {
id: addNodeSource,
},
target: {
id: nextNodeId,
},
linkType: node.linkType,
});
@ -165,7 +207,9 @@ function createNode(state, node) {
linkToCompare.source.id === addNodeSource &&
linkToCompare.target.id === addNodeTarget
) {
linkToCompare.source = { id: nextNodeId };
linkToCompare.source = {
id: nextNodeId,
};
}
});
}
@ -268,15 +312,23 @@ function addLinksFromParentsToChildren(
// doesn't have any other parents
if (linkParentMapping[child.id].length === 1) {
newLinks.push({
source: { id: parentId },
target: { id: child.id },
source: {
id: parentId,
},
target: {
id: child.id,
},
linkType: 'always',
});
}
} else if (!linkParentMapping[child.id].includes(parentId)) {
newLinks.push({
source: { id: parentId },
target: { id: child.id },
source: {
id: parentId,
},
target: {
id: child.id,
},
linkType: child.linkType,
});
}
@ -302,7 +354,10 @@ function removeLinksFromDeletedNode(
if (link.source.id === nodeId || link.target.id === nodeId) {
if (link.source.id === nodeId) {
children.push({ id: link.target.id, linkType: link.linkType });
children.push({
id: link.target.id,
linkType: link.linkType,
});
} else if (link.target.id === nodeId) {
parents.push(link.source.id);
}
@ -601,6 +656,7 @@ function updateNode(state, editedNode) {
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
matchingNode.unifiedJobTemplate = editedNode.nodeResource;
matchingNode.isEdited = true;
matchingNode.promptValues = editedNode.promptValues;
return {
...state,

View File

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

View File

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

View File

@ -1,86 +1,66 @@
import 'styled-components/macro';
import React, { useContext, useState } from 'react';
import React, { useContext, useState, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, useFormikContext } from 'formik';
import { bool, node, func } from 'prop-types';
import {
Button,
WizardContextConsumer,
WizardFooter,
Form,
} from '@patternfly/react-core';
import ContentError from '../../../../../components/ContentError';
import ContentLoading from '../../../../../components/ContentLoading';
import useRequest, {
useDismissableError,
} from '../../../../../util/useRequest';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '../../../../../contexts/Workflow';
import {
JobTemplatesAPI,
WorkflowJobTemplatesAPI,
WorkflowJobTemplateNodesAPI,
} from '../../../../../api';
import Wizard from '../../../../../components/Wizard';
import { NodeTypeStep } from './NodeTypeStep';
import RunStep from './RunStep';
import useWorkflowNodeSteps from './useWorkflowNodeSteps';
import AlertModal from '../../../../../components/AlertModal';
import NodeNextButton from './NodeNextButton';
function NodeModal({ askLinkType, i18n, onSave, title }) {
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 }) {
const history = useHistory();
const dispatch = useContext(WorkflowDispatchContext);
const { nodeToEdit } = useContext(WorkflowStateContext);
const {
values,
setTouched,
validateForm,
setFieldValue,
resetForm,
} = useFormikContext();
let defaultApprovalDescription = '';
let defaultApprovalName = '';
let defaultApprovalTimeout = 0;
let defaultNodeResource = null;
let defaultNodeType = 'job_template';
if (nodeToEdit && nodeToEdit.unifiedJobTemplate) {
if (
nodeToEdit &&
nodeToEdit.unifiedJobTemplate &&
(nodeToEdit.unifiedJobTemplate.type ||
nodeToEdit.unifiedJobTemplate.unified_job_type)
) {
const ujtType =
nodeToEdit.unifiedJobTemplate.type ||
nodeToEdit.unifiedJobTemplate.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
defaultNodeType = 'job_template';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'project':
case 'project_update':
defaultNodeType = 'project_sync';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'inventory_source':
case 'inventory_update':
defaultNodeType = 'inventory_source_sync';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'workflow_job_template':
case 'workflow_job':
defaultNodeType = 'workflow_job_template';
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'workflow_approval_template':
case 'workflow_approval':
defaultNodeType = 'approval';
defaultApprovalName = nodeToEdit.unifiedJobTemplate.name;
defaultApprovalDescription =
nodeToEdit.unifiedJobTemplate.description;
defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout;
break;
default:
}
}
}
const [approvalDescription, setApprovalDescription] = useState(
defaultApprovalDescription
);
const [approvalName, setApprovalName] = useState(defaultApprovalName);
const [approvalTimeout, setApprovalTimeout] = useState(
defaultApprovalTimeout
);
const [linkType, setLinkType] = useState('success');
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
const [nodeType, setNodeType] = useState(defaultNodeType);
const [triggerNext, setTriggerNext] = useState(0);
const clearQueryParams = () => {
@ -92,21 +72,82 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
);
history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
};
useEffect(() => {
if (values?.nodeResource?.summary_fields?.credentials?.length > 0) {
setFieldValue(
'credentials',
values.nodeResource.summary_fields.credentials
);
}
if (nodeToEdit?.unified_job_type === 'workflow_job') {
setFieldValue('nodeType', 'workflow_job_template');
}
if (nodeToEdit?.unified_job_type === 'job') {
setFieldValue('nodeType', 'job_template');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeToEdit, values.nodeResource]);
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,
initialValues,
isReady,
visitStep,
visitAllSteps,
contentError,
} = useWorkflowNodeSteps(
launchConfig,
i18n,
values.nodeResource,
askLinkType,
!canLaunchWithoutPrompt(values.nodeType, launchConfig)
);
const handleSaveNode = () => {
clearQueryParams();
const resource =
nodeType === 'approval'
? {
description: approvalDescription,
name: approvalName,
timeout: approvalTimeout,
type: 'workflow_approval_template',
}
: nodeResource;
onSave(resource, askLinkType ? linkType : null);
onSave(values, askLinkType ? values.linkType : null, launchConfig);
};
const handleCancel = () => {
@ -114,53 +155,24 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
dispatch({ type: 'CANCEL_NODE_MODAL' });
};
const handleNodeTypeChange = newNodeType => {
setNodeType(newNodeType);
setNodeResource(null);
setApprovalName('');
setApprovalDescription('');
setApprovalTimeout(0);
};
const steps = [
...(askLinkType
? [
{
name: i18n._(t`Run Type`),
key: 'run_type',
component: (
<RunStep linkType={linkType} onUpdateLinkType={setLinkType} />
),
enableNext: linkType !== null,
},
]
: []),
{
name: i18n._(t`Node Type`),
key: 'node_resource',
enableNext:
(nodeType !== 'approval' && nodeResource !== null) ||
(nodeType === 'approval' && approvalName !== ''),
component: (
<NodeTypeStep
description={approvalDescription}
name={approvalName}
nodeResource={nodeResource}
nodeType={nodeType}
onUpdateDescription={setApprovalDescription}
onUpdateName={setApprovalName}
onUpdateNodeResource={setNodeResource}
onUpdateNodeType={handleNodeTypeChange}
onUpdateTimeout={setApprovalTimeout}
timeout={approvalTimeout}
/>
),
},
];
steps.forEach((step, n) => {
step.id = n + 1;
});
const { error, dismissError } = useDismissableError(
launchConfigError || contentError || credentialError
);
useEffect(() => {
if (isReady) {
resetForm({
values: {
...initialValues,
nodeResource: values.nodeResource,
nodeType: values.nodeType || 'job_template',
linkType: values.linkType || 'success',
verbosity: initialValues?.verbosity?.toString(),
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [launchConfig, values.nodeType, isReady, values.nodeResource]);
const steps = [...(isReady ? [...promptSteps] : [])];
const CustomFooter = (
<WizardFooter>
@ -173,12 +185,13 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
onNext={onNext}
onClick={() => setTriggerNext(triggerNext + 1)}
buttonText={
activeStep.key === 'node_resource'
activeStep.id === steps[steps?.length - 1]?.id ||
activeStep.name === 'Preview'
? i18n._(t`Save`)
: i18n._(t`Next`)
}
/>
{activeStep && activeStep.id !== 1 && (
{activeStep && activeStep.id !== steps[0]?.id && (
<Button id="back-node-modal" variant="secondary" onClick={onBack}>
{i18n._(t`Back`)}
</Button>
@ -196,21 +209,123 @@ function NodeModal({ askLinkType, i18n, onSave, title }) {
</WizardFooter>
);
const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title;
const wizardTitle = values.nodeResource
? `${title} | ${values.nodeResource.name}`
: title;
if (error && !isLoading) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
return (
<Wizard
footer={CustomFooter}
isOpen
isOpen={!error || !contentError}
onClose={handleCancel}
onSave={handleSaveNode}
steps={steps}
onSave={() => {
handleSaveNode();
}}
onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
steps={
isReady
? steps
: [
{
name: i18n._(t`Content Loading`),
component: <ContentLoading />,
},
]
}
css="overflow: scroll"
title={wizardTitle}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
/>
);
}
const NodeModal = ({ onSave, i18n, askLinkType, title }) => {
const { nodeToEdit } = useContext(WorkflowStateContext);
const onSaveForm = (values, linkType, config) => {
onSave(values, linkType, config);
};
const { request: fetchCredentials, result, error } = useRequest(
useCallback(async () => {
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readCredentials(
nodeToEdit.originalNodeObject.id
);
return results;
}, [nodeToEdit])
);
useEffect(() => {
if (nodeToEdit?.originalNodeObject?.related?.credentials) {
fetchCredentials();
}
}, [fetchCredentials, nodeToEdit]);
return (
<Formik
initialValues={{
linkType: 'success',
nodeResource:
nodeToEdit?.originalNodeObject?.summary_fields
?.unified_job_template || null,
inventory:
nodeToEdit?.originalNodeObject?.summary_fields?.inventory || null,
credentials: result || null,
verbosity: nodeToEdit?.originalNodeObject?.verbosity || 0,
diff_mode: nodeToEdit?.originalNodeObject?.verbosty,
skip_tags: nodeToEdit?.originalNodeObject?.skip_tags || '',
job_tags: nodeToEdit?.originalNodeObject?.job_tags || '',
scm_branch:
nodeToEdit?.originalNodeObject?.scm_branch !== null
? nodeToEdit?.originalNodeObject?.scm_branch
: '',
job_type: nodeToEdit?.originalNodeObject?.job_type || 'run',
extra_vars: nodeToEdit?.originalNodeObject?.extra_vars || '---',
}}
onSave={() => onSaveForm}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<NodeModalForm
onSave={onSaveForm}
i18n={i18n}
title={title}
credentialError={error}
askLinkType={askLinkType}
/>
</Form>
)}
</Formik>
);
};
NodeModal.propTypes = {
askLinkType: bool.isRequired,
onSave: func.isRequired,

View File

@ -36,6 +36,10 @@ describe('NodeModal', () => {
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
summary_fields: {
recent_jobs: [],
},
related: { webhook_receiver: '' },
},
],
},
@ -49,6 +53,69 @@ 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.readSurvey.mockResolvedValue({
data: {
name: '',
description: '',
spec: [{ question_name: 'Foo', required: true }],
type: 'text',
variable: 'bar',
},
});
ProjectsAPI.read.mockResolvedValue({
data: {
count: 1,
@ -116,6 +183,33 @@ describe('NodeModal', () => {
},
});
});
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
can_start_without_user_input: false,
defaults: {
extra_vars: '---',
inventory: {
name: null,
id: null,
},
limit: '',
scm_branch: '',
},
survey_enabled: false,
variables_needed_to_start: [],
node_templates_missing: [],
node_prompts_rejected: [272, 273],
workflow_job_template_data: {
name: 'jt',
id: 53,
description: '',
},
ask_variables_on_launch: false,
},
});
afterAll(() => {
jest.clearAllMocks();
});
@ -137,7 +231,7 @@ describe('NodeModal', () => {
await waitForElement(wrapper, 'PFWizard');
});
afterAll(() => {
afterEach(() => {
wrapper.unmount();
});
@ -150,17 +244,41 @@ describe('NodeModal', () => {
});
wrapper.update();
wrapper.find('Radio').simulate('click');
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
wrapper.update();
expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1);
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25);
wrapper.update();
expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next');
wrapper
.find('WizardNavItem[content="Preview"]')
.find('a')
.prop('onClick')();
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
expect(onSave).toBeCalledWith(
{
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
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,
},
'always'
'always',
{}
);
});
@ -177,17 +295,24 @@ describe('NodeModal', () => {
});
wrapper.update();
wrapper.find('Radio').simulate('click');
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
expect(onSave).toBeCalledWith(
{
id: 1,
name: 'Test Project',
type: 'project',
url: '/api/v2/projects/1',
linkType: 'failure',
nodeResource: {
id: 1,
name: 'Test Project',
type: 'project',
url: '/api/v2/projects/1',
},
nodeType: 'project_sync',
verbosity: undefined,
},
'failure'
'failure',
{}
);
});
@ -207,17 +332,24 @@ describe('NodeModal', () => {
});
wrapper.update();
wrapper.find('Radio').simulate('click');
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
expect(onSave).toBeCalledWith(
{
id: 1,
name: 'Test Inventory Source',
type: 'inventory_source',
url: '/api/v2/inventory_sources/1',
linkType: 'failure',
nodeResource: {
id: 1,
name: 'Test Inventory Source',
type: 'inventory_source',
url: '/api/v2/inventory_sources/1',
},
nodeType: 'inventory_source_sync',
verbosity: undefined,
},
'failure'
'failure',
{}
);
});
@ -233,18 +365,48 @@ describe('NodeModal', () => {
);
});
wrapper.update();
wrapper.find('Radio').simulate('click');
await act(async () => wrapper.find('Radio').simulate('click'));
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
wrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
});
expect(onSave).toBeCalledWith(
{
id: 1,
name: 'Test Workflow Job Template',
type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1',
linkType: 'success',
nodeResource: {
id: 1,
name: 'Test Workflow Job Template',
type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1',
},
nodeType: 'workflow_job_template',
verbosity: undefined,
},
'success'
'success',
{
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_variables_on_launch: false,
can_start_without_user_input: false,
defaults: {
extra_vars: '---',
inventory: { id: null, name: null },
limit: '',
scm_branch: '',
},
node_prompts_rejected: [272, 273],
node_templates_missing: [],
survey_enabled: false,
variables_needed_to_start: [],
workflow_job_template_data: { description: '', id: 53, name: 'jt' },
}
);
});
@ -263,10 +425,13 @@ describe('NodeModal', () => {
await act(async () => {
wrapper.find('input#approval-name').simulate('change', {
target: { value: 'Test Approval', name: 'name' },
target: { value: 'Test Approval', name: 'approvalName' },
});
wrapper.find('input#approval-description').simulate('change', {
target: { value: 'Test Approval Description', name: 'description' },
target: {
value: 'Test Approval Description',
name: 'approvalDescription',
},
});
wrapper.find('input#approval-timeout-minutes').simulate('change', {
target: { value: 5, name: 'timeoutMinutes' },
@ -301,12 +466,16 @@ describe('NodeModal', () => {
});
expect(onSave).toBeCalledWith(
{
description: 'Test Approval Description',
name: 'Test Approval',
approvalDescription: 'Test Approval Description',
approvalName: 'Test Approval',
linkType: 'always',
nodeResource: undefined,
nodeType: 'approval',
timeout: 330,
type: 'workflow_approval_template',
verbosity: undefined,
},
'always'
'always',
{}
);
});
@ -318,13 +487,15 @@ describe('NodeModal', () => {
});
});
describe('Edit existing node', () => {
let newWrapper;
afterEach(() => {
wrapper.unmount();
newWrapper.unmount();
jest.clearAllMocks();
});
test('Can successfully change project sync node to workflow approval node', async () => {
await act(async () => {
wrapper = mountWithContexts(
newWrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider
value={{
@ -347,20 +518,26 @@ describe('NodeModal', () => {
</WorkflowDispatchContext.Provider>
);
});
await waitForElement(wrapper, 'PFWizard');
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync');
await waitForElement(newWrapper, 'PFWizard');
newWrapper.update();
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe(
'project_sync'
);
await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval');
newWrapper.find('AnsibleSelect').prop('onChange')(null, 'approval');
});
wrapper.update();
newWrapper.update();
await act(async () => {
wrapper.find('input#approval-name').simulate('change', {
target: { value: 'Test Approval', name: 'name' },
newWrapper.find('input#approval-name').simulate('change', {
target: { value: 'Test Approval', name: 'approvalName' },
});
wrapper.find('input#approval-description').simulate('change', {
target: { value: 'Test Approval Description', name: 'description' },
newWrapper.find('input#approval-description').simulate('change', {
target: {
value: 'Test Approval Description',
name: 'approvalDescription',
},
});
wrapper.find('input#approval-timeout-minutes').simulate('change', {
newWrapper.find('input#approval-timeout-minutes').simulate('change', {
target: { value: 5, name: 'timeoutMinutes' },
});
});
@ -369,42 +546,46 @@ describe('NodeModal', () => {
// 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', {
newWrapper.find('input#approval-timeout-seconds').simulate('change', {
target: { value: 30, name: 'timeoutSeconds' },
});
});
wrapper.update();
newWrapper.update();
expect(wrapper.find('input#approval-name').prop('value')).toBe(
expect(newWrapper.find('input#approval-name').prop('value')).toBe(
'Test Approval'
);
expect(wrapper.find('input#approval-description').prop('value')).toBe(
expect(newWrapper.find('input#approval-description').prop('value')).toBe(
'Test Approval Description'
);
expect(wrapper.find('input#approval-timeout-minutes').prop('value')).toBe(
5
);
expect(wrapper.find('input#approval-timeout-seconds').prop('value')).toBe(
30
);
expect(
newWrapper.find('input#approval-timeout-minutes').prop('value')
).toBe(5);
expect(
newWrapper.find('input#approval-timeout-seconds').prop('value')
).toBe(30);
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
newWrapper.find('button#next-node-modal').simulate('click');
});
expect(onSave).toBeCalledWith(
{
description: 'Test Approval Description',
name: 'Test Approval',
approvalDescription: 'Test Approval Description',
approvalName: 'Test Approval',
linkType: 'success',
nodeResource: undefined,
nodeType: 'approval',
timeout: 330,
type: 'workflow_approval_template',
verbosity: undefined,
},
null
null,
{}
);
});
test('Can successfully change approval node to workflow job template node', async () => {
await act(async () => {
wrapper = mountWithContexts(
newWrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider
value={{
@ -429,27 +610,56 @@ describe('NodeModal', () => {
</WorkflowDispatchContext.Provider>
);
});
await waitForElement(wrapper, 'PFWizard');
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval');
await waitForElement(newWrapper, 'PFWizard');
expect(newWrapper.find('AnsibleSelect').prop('value')).toBe('approval');
await act(async () => {
wrapper.find('AnsibleSelect').prop('onChange')(
newWrapper.find('AnsibleSelect').prop('onChange')(
null,
'workflow_job_template'
);
});
wrapper.update();
wrapper.find('Radio').simulate('click');
newWrapper.update();
await act(async () => newWrapper.find('Radio').simulate('click'));
newWrapper.update();
await act(async () => {
wrapper.find('button#next-node-modal').simulate('click');
newWrapper.find('button#next-node-modal').simulate('click');
});
newWrapper.update();
await act(async () => {
newWrapper.find('button#next-node-modal').simulate('click');
});
expect(onSave).toBeCalledWith(
{
id: 1,
name: 'Test Workflow Job Template',
type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1',
linkType: 'success',
nodeResource: {
id: 1,
name: 'Test Workflow Job Template',
type: 'workflow_job_template',
url: '/api/v2/workflow_job_templates/1',
},
nodeType: 'workflow_job_template',
verbosity: undefined,
},
null
null,
{
ask_inventory_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_variables_on_launch: false,
can_start_without_user_input: false,
defaults: {
extra_vars: '---',
inventory: { id: null, name: null },
limit: '',
scm_branch: '',
},
node_prompts_rejected: [272, 273],
node_templates_missing: [],
survey_enabled: false,
variables_needed_to_start: [],
workflow_job_template_data: { description: '', id: 53, name: 'jt' },
}
);
});
});

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { func, number, shape, string } from 'prop-types';
import { func, oneOfType, number, shape, string } from 'prop-types';
import { Button } from '@patternfly/react-core';
function NodeNextButton({
@ -34,7 +34,7 @@ NodeNextButton.propTypes = {
buttonText: string.isRequired,
onClick: func.isRequired,
onNext: func.isRequired,
triggerNext: number.isRequired,
triggerNext: oneOfType([string, number]).isRequired,
};
export default NodeNextButton;

View File

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

View File

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

View File

@ -0,0 +1,102 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import NodeTypeStep from './NodeTypeStep';
const STEP_ID = 'nodeType';
export default function useNodeTypeStep(i18n, resource) {
const [, meta] = useField('nodeType');
const [approvalNameField] = useField('approvalName');
const [nodeTypeField, ,] = useField('nodeType');
const [nodeResouceField] = useField('nodeResource');
return {
step: getStep(
meta,
i18n,
nodeTypeField,
approvalNameField,
nodeResouceField
),
initialValues: getInitialValues(resource),
isReady: true,
contentError: null,
formError: meta.error,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
},
};
}
function getStep(
meta,
i18n,
nodeTypeField,
approvalNameField,
nodeResouceField
) {
const isEnabled = () => {
if (
(nodeTypeField.value !== 'approval' && nodeResouceField.value === null) ||
(nodeTypeField.value === 'approval' &&
approvalNameField.value === undefined)
) {
return false;
}
return true;
};
return {
id: STEP_ID,
key: 3,
name: i18n._(t`Node Type`),
component: <NodeTypeStep i18n={i18n} />,
enableNext: isEnabled(),
};
}
function getInitialValues(resource) {
let typeOfNode;
if (
!resource?.unifiedJobTemplate?.type &&
!resource?.unifiedJobTemplate?.unified_job_type
) {
return { nodeType: 'job_template' };
}
const {
unifiedJobTemplate: { type, unified_job_type },
} = resource;
const unifiedType = type || unified_job_type;
if (unifiedType === 'job' || unifiedType === 'job_template')
typeOfNode = {
nodeType: 'job_template',
nodeResource:
resource.originalNodeObject.summary_fields.unified_job_template,
};
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;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,93 @@
import {
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 useNodeTypeStep from './NodeTypeStep/useNodeTypeStep';
import useRunTypeStep from './useRunTypeStep';
export default function useWorkflowNodeSteps(
config,
i18n,
resource,
askLinkType,
needsPreviewStep
) {
const [visited, setVisited] = useState({});
const steps = [
useRunTypeStep(i18n, askLinkType),
useNodeTypeStep(i18n, resource),
useInventoryStep(config, i18n, visited, resource),
useCredentialsStep(config, i18n, resource),
useOtherPromptsStep(config, i18n, resource),
useSurveyStep(config, i18n, visited, resource),
];
const {
resetForm,
values: formikValues
} = 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,
needsPreviewStep
)
);
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 (surveyStepIndex > -1 && isReady) {
resetForm({
values: {
...formikValues,
...steps[surveyStepIndex].initialValues,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
return {
steps: pfSteps,
initialValues,
isReady,
visitStep: stepId =>
setVisited({
...visited,
[stepId]: true,
}),
visitAllSteps: setFieldsTouched => {
setVisited({
inventory: true,
credentials: true,
other: true,
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
},
contentError,
};
}

View File

@ -259,6 +259,8 @@ function Visualizer({ template, i18n }) {
const approvalTemplateRequests = [];
const originalLinkMap = {};
const deletedNodeIds = [];
const associateCredentialRequests = [];
const disassociateCredentialRequests = [];
nodes.forEach(node => {
// node with id=1 is the artificial start node
if (node.id === 1) {
@ -308,6 +310,7 @@ function Visualizer({ template, i18n }) {
} else {
nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {
...node.promptValues,
unified_job_template: node.unifiedJobTemplate.id,
}).then(({ data }) => {
node.originalNodeObject = data;
@ -317,6 +320,26 @@ function Visualizer({ template, i18n }) {
failure_nodes: [],
always_nodes: [],
};
if (node.promptValues?.removedCredentials?.length > 0) {
node.promptValues.removedCredentials.forEach(cred => {
disassociateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.disassociateCredentials(
data.id,
cred.id
)
);
});
}
if (node.promptValues?.addedCredentials?.length > 0) {
node.promptValues.addedCredentials.forEach(cred => {
associateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.associateCredentials(
data.id,
cred.id
)
);
});
}
})
);
}
@ -355,9 +378,30 @@ function Visualizer({ template, i18n }) {
} else {
nodeRequests.push(
WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, {
...node.promptValues,
unified_job_template: node.unifiedJobTemplate.id,
})
);
if (node?.promptValues?.addedCredentials?.length > 0) {
node.promptValues.addedCredentials.forEach(cred =>
associateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.associateCredentials(
node.originalNodeObject.id,
cred.id
)
)
);
}
if (node?.promptValues?.removedCredentials?.length > 0) {
node.promptValues.removedCredentials.forEach(cred =>
disassociateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.disassociateCredentials(
node.originalNodeObject.id,
cred.id
)
)
);
}
}
}
});
@ -372,6 +416,9 @@ function Visualizer({ template, i18n }) {
);
await Promise.all(associateNodes(newLinks, originalLinkMap));
await Promise.all(disassociateCredentialRequests);
await Promise.all(associateCredentialRequests);
history.push(`/templates/workflow_job_template/${template.id}/details`);
};