Fixes for various prompt related ui issues

Fixes bug where Forks showed up in both default values and prompted values in launch summary

Fixes prompting IGs with defaults on launch

Make job tags and skip tags full width on workflow form

Fixes bug where we attempted to fetch instance groups for workflows

Fetch default instance groups from jt/schedule for schedule form prompt

Grab default IGs when adding a node that prompts for them

Adds support for saving labels on a new wf node

Fix linting errors

Fixes for various prompt on launch related issues

Adds support for saving instance groups on a new node

Adds support for saving instance groups when editing an existing node

Fix workflowReducer test

Updates useSelected to handle a non-empty starting state

Fixes visualizerNode tests

Fix visualizer test

Second batch of prompt related ui issues:

Fixes bug saving existing node when instance groups is not promptable

Fixes bug removing newly added label

Adds onError function to label prompt

Fixes tooltips on the other prompts step

Properly fetch all labels to show on schedule details
This commit is contained in:
mabashian 2022-09-06 14:34:40 -04:00 committed by Alan Rominger
parent e076f1ee2a
commit e05eaeccab
No known key found for this signature in database
GPG Key ID: C2D7EAAA12B63559
24 changed files with 402 additions and 109 deletions

View File

@ -1,6 +1,8 @@
import Base from '../Base';
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
import LabelsMixin from '../mixins/Labels.mixin';
class WorkflowJobTemplateNodes extends Base {
class WorkflowJobTemplateNodes extends LabelsMixin(InstanceGroupsMixin(Base)) {
constructor(http) {
super(http);
this.baseUrl = 'api/v2/workflow_job_template_nodes/';

View File

@ -91,11 +91,8 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
<Chip
isReadOnly={currentChip.isReadOnly}
key={currentChip.name}
onClick={(e, item) => {
if (typeof item === 'string') {
item = { id: item, name: item };
}
onSelect(e, item);
onClick={(e) => {
onSelect(e, currentChip);
}}
>
{currentChip.name}

View File

@ -42,6 +42,7 @@ function LaunchButton({ resource, children }) {
const [launchConfig, setLaunchConfig] = useState(null);
const [surveyConfig, setSurveyConfig] = useState(null);
const [labels, setLabels] = useState([]);
const [instanceGroups, setInstanceGroups] = useState([]);
const [isLaunching, setIsLaunching] = useState(false);
const [error, setError] = useState(null);
@ -83,6 +84,14 @@ function LaunchButton({ resource, children }) {
setLabels(allLabels);
}
if (launch.ask_instance_groups_on_launch) {
const {
data: { results },
} = await JobTemplatesAPI.readInstanceGroups(resource.id);
setInstanceGroups(results);
}
if (canLaunchWithoutPrompt(launch)) {
await launchWithParams({});
} else {
@ -197,6 +206,7 @@ function LaunchButton({ resource, children }) {
labels={labels}
onLaunch={launchWithParams}
onCancel={() => setShowLaunchPrompt(false)}
instanceGroups={instanceGroups}
/>
)}
</>

View File

@ -18,6 +18,7 @@ function PromptModalForm({
resource,
labels,
surveyConfig,
instanceGroups,
}) {
const { setFieldTouched, values } = useFormikContext();
const [showDescription, setShowDescription] = useState(false);
@ -29,7 +30,13 @@ function PromptModalForm({
visitStep,
visitAllSteps,
contentError,
} = useLaunchSteps(launchConfig, surveyConfig, resource, labels);
} = useLaunchSteps(
launchConfig,
surveyConfig,
resource,
labels,
instanceGroups
);
const handleSubmit = async () => {
const postValues = {};
@ -197,6 +204,7 @@ function LaunchPrompt({
labels = [],
surveyConfig,
resourceDefaultCredentials = [],
instanceGroups = [],
}) {
return (
<Formik initialValues={{}} onSubmit={(values) => onLaunch(values)}>
@ -208,6 +216,7 @@ function LaunchPrompt({
resource={resource}
labels={labels}
resourceDefaultCredentials={resourceDefaultCredentials}
instanceGroups={instanceGroups}
/>
</Formik>
);

View File

@ -19,7 +19,7 @@ const QS_CONFIG = getQSConfig('instance-groups', {
function InstanceGroupsStep() {
const [field, , helpers] = useField('instance_groups');
const { selected, handleSelect, setSelected } = useSelected([]);
const { selected, handleSelect, setSelected } = useSelected([], field.value);
const history = useHistory();
@ -69,7 +69,7 @@ function InstanceGroupsStep() {
return (
<OptionsList
value={field.value}
value={selected}
options={instance_groups}
optionCount={count}
searchColumns={[

View File

@ -10,6 +10,8 @@ import AnsibleSelect from '../../AnsibleSelect';
import { VariablesField } from '../../CodeEditor';
import Popover from '../../Popover';
import { VerbositySelectField } from '../../VerbositySelectField';
import jobHelpText from '../../../screens/Job/Job.helptext';
import workflowHelpText from '../../../screens/Template/shared/WorkflowJobTemplate.helptext';
const FieldHeader = styled.div`
display: flex;
@ -22,22 +24,29 @@ const FieldHeader = styled.div`
`;
function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
const helpTextSource = launchConfig.job_template_data
? jobHelpText
: workflowHelpText;
return (
<Form
onSubmit={(e) => {
e.preventDefault();
}}
>
{launchConfig.ask_job_type_on_launch && <JobTypeField />}
{launchConfig.ask_job_type_on_launch && (
<JobTypeField helpTextSource={helpTextSource} />
)}
{launchConfig.ask_scm_branch_on_launch && (
<FormField
id="prompt-scm-branch"
name="scm_branch"
label={t`Source Control Branch`}
tooltip={t`Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch`}
tooltip={helpTextSource.sourceControlBranch}
/>
)}
{launchConfig.ask_labels_on_launch && <LabelsField />}
{launchConfig.ask_labels_on_launch && (
<LabelsField helpTextSource={helpTextSource} />
)}
{launchConfig.ask_forks_on_launch && (
<FormField
id="prompt-forks"
@ -45,6 +54,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
label={t`Forks`}
type="number"
min="0"
tooltip={helpTextSource.forks}
/>
)}
{launchConfig.ask_limit_on_launch && (
@ -52,13 +62,12 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
id="prompt-limit"
name="limit"
label={t`Limit`}
tooltip={t`Provide a host pattern to further constrain the list
of hosts that will be managed or affected by the playbook. Multiple
patterns are allowed. Refer to Ansible documentation for more
information and examples on patterns.`}
tooltip={helpTextSource.limit}
/>
)}
{launchConfig.ask_verbosity_on_launch && <VerbosityField />}
{launchConfig.ask_verbosity_on_launch && (
<VerbosityField helpTextSource={helpTextSource} />
)}
{launchConfig.ask_job_slice_count_on_launch && (
<FormField
id="prompt-job-slicing"
@ -66,6 +75,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
label={t`Job Slicing`}
type="number"
min="1"
tooltip={helpTextSource.jobSlicing}
/>
)}
{launchConfig.ask_timeout_on_launch && (
@ -75,6 +85,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
label={t`Timeout`}
type="number"
min="0"
tooltip={helpTextSource.timeout}
/>
)}
{launchConfig.ask_diff_mode_on_launch && <ShowChangesToggle />}
@ -84,10 +95,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
name="job_tags"
label={t`Job Tags`}
aria-label={t`Job Tags`}
tooltip={t`Tags are useful when you have a large
playbook, and you want to run a specific part of a play or task.
Use commas to separate multiple tags. Refer to Ansible Controller
documentation for details on the usage of tags.`}
tooltip={helpTextSource.jobTags}
/>
)}
{launchConfig.ask_skip_tags_on_launch && (
@ -96,10 +104,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
name="skip_tags"
label={t`Skip Tags`}
aria-label={t`Skip Tags`}
tooltip={t`Skip tags are useful when you have a large
playbook, and you want to skip specific parts of a play or task.
Use commas to separate multiple tags. Refer to Ansible Controller
documentation for details on the usage of tags.`}
tooltip={helpTextSource.skipTags}
/>
)}
{launchConfig.ask_variables_on_launch && (
@ -115,7 +120,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
);
}
function JobTypeField() {
function JobTypeField({ helpTextSource }) {
const [field, meta, helpers] = useField('job_type');
const options = [
{
@ -135,15 +140,9 @@ function JobTypeField() {
const isValid = !(meta.touched && meta.error);
return (
<FormGroup
fieldId="propmt-job-type"
fieldId="prompt-job-type"
label={t`Job Type`}
labelIcon={
<Popover
content={t`For job templates, select run to execute the playbook.
Select check to only check playbook syntax, test environment setup,
and report problems without executing the playbook.`}
/>
}
labelIcon={<Popover content={helpTextSource.jobType} />}
isRequired
validated={isValid ? 'default' : 'error'}
>
@ -157,15 +156,14 @@ function JobTypeField() {
);
}
function VerbosityField() {
function VerbosityField({ helpTextSource }) {
const [, meta] = useField('verbosity');
const isValid = !(meta.touched && meta.error);
return (
<VerbositySelectField
fieldId="prompt-verbosity"
tooltip={t`Control the level of output ansible
will produce as the playbook executes.`}
tooltip={helpTextSource.verbosity}
isValid={isValid ? 'default' : 'error'}
/>
);
@ -214,24 +212,22 @@ function TagField({ id, name, label, tooltip }) {
);
}
function LabelsField() {
const [field, , helpers] = useField('labels');
function LabelsField({ helpTextSource }) {
const [field, meta, helpers] = useField('labels');
return (
<FormGroup
fieldId="propmt-labels"
fieldId="prompt-labels"
label={t`Labels`}
labelIcon={
<Popover
content={t`Optional labels that describe this job, such as 'dev' or 'test'. Labels can be used to group and filter completed jobs.`}
/>
}
labelIcon={<Popover content={helpTextSource.labels} />}
validated={!meta.touched || !meta.error ? 'default' : 'error'}
helperTextInvalid={meta.error}
>
<LabelSelect
value={field.value}
onChange={(labels) => helpers.setValue(labels)}
createText={t`Create`}
onError={() => {}}
onError={(err) => helpers.setError(err)}
/>
</FormGroup>
);

View File

@ -13,6 +13,11 @@ describe('OtherPromptsStep', () => {
<OtherPromptsStep
launchConfig={{
ask_job_type_on_launch: true,
job_template_data: {
name: 'Demo Job Template',
id: 1,
description: '',
},
}}
/>
</Formik>
@ -36,6 +41,11 @@ describe('OtherPromptsStep', () => {
<OtherPromptsStep
launchConfig={{
ask_limit_on_launch: true,
job_template_data: {
name: 'Demo Job Template',
id: 1,
description: '',
},
}}
/>
</Formik>
@ -56,6 +66,11 @@ describe('OtherPromptsStep', () => {
<OtherPromptsStep
launchConfig={{
ask_scm_branch_on_launch: true,
job_template_data: {
name: 'Demo Job Template',
id: 1,
description: '',
},
}}
/>
</Formik>
@ -76,6 +91,11 @@ describe('OtherPromptsStep', () => {
<OtherPromptsStep
launchConfig={{
ask_verbosity_on_launch: true,
job_template_data: {
name: 'Demo Job Template',
id: 1,
description: '',
},
}}
/>
</Formik>
@ -96,6 +116,11 @@ describe('OtherPromptsStep', () => {
<OtherPromptsStep
launchConfig={{
ask_diff_mode_on_launch: true,
job_template_data: {
name: 'Demo Job Template',
id: 1,
description: '',
},
}}
/>
</Formik>
@ -119,6 +144,11 @@ describe('OtherPromptsStep', () => {
onVarModeChange={onModeChange}
launchConfig={{
ask_variables_on_launch: true,
job_template_data: {
name: 'Demo Job Template',
id: 1,
description: '',
},
}}
/>
</Formik>

View File

@ -45,7 +45,8 @@ export default function useLaunchSteps(
launchConfig,
surveyConfig,
resource,
labels
labels,
instanceGroups
) {
const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false);
@ -64,7 +65,7 @@ export default function useLaunchSteps(
visited
),
useExecutionEnvironmentStep(launchConfig, resource),
useInstanceGroupsStep(launchConfig, resource),
useInstanceGroupsStep(launchConfig, resource, instanceGroups),
useOtherPromptsStep(launchConfig, resource, labels),
useSurveyStep(launchConfig, surveyConfig, resource, visited),
];

View File

@ -17,7 +17,7 @@ export default function useSyncedSelectValue(value, onChange) {
return;
}
const newOptions = [];
if (value !== selections && options.length) {
if (value && value !== selections && options.length) {
const syncedValue = value.map((item) => {
const match = options.find((i) => i.id === item.id);
if (!match) {

View File

@ -146,7 +146,10 @@ function PromptJobTemplateDetail({ resource }) {
/>
<Detail label={t`Source Control Branch`} value={scm_branch} />
<Detail label={t`Playbook`} value={playbook} />
<Detail label={t`Forks`} value={forks || '0'} />
<Detail
label={t`Forks`}
value={typeof forks === 'number' ? forks.toString() : forks}
/>
<Detail label={t`Limit`} value={limit} />
<Detail label={t`Verbosity`} value={VERBOSITY()[verbosity]} />
{typeof diff_mode === 'boolean' && (

View File

@ -27,6 +27,11 @@ import { VariablesDetail } from '../../CodeEditor';
import { VERBOSITY } from '../../VerbositySelectField';
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
const buildLinkURL = (instance) =>
instance.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
const PromptDivider = styled(Divider)`
margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg);
@ -80,7 +85,6 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
job_slice_count,
job_tags,
job_type,
labels,
limit,
modified,
name,
@ -113,7 +117,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
const { error, dismissError } = useDismissableError(deleteError);
const {
result: [credentials, preview, launchData],
result: [credentials, preview, launchData, labels, instanceGroups],
isLoading,
error: readContentError,
request: fetchCredentialsAndPreview,
@ -133,7 +137,9 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
promises.push(
JobTemplatesAPI.readLaunch(
schedule.summary_fields.unified_job_template.id
)
),
SchedulesAPI.readAllLabels(id),
SchedulesAPI.readInstanceGroups(id)
);
} else if (
schedule?.summary_fields?.unified_job_template?.unified_job_type ===
@ -142,17 +148,28 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
promises.push(
WorkflowJobTemplatesAPI.readLaunch(
schedule.summary_fields.unified_job_template.id
)
),
SchedulesAPI.readAllLabels(id)
);
} else {
promises.push(Promise.resolve());
}
const [{ data }, { data: schedulePreview }, launch] = await Promise.all(
promises
);
const [
{ data },
{ data: schedulePreview },
launch,
allLabelsResults,
instanceGroupsResults,
] = await Promise.all(promises);
return [data.results, schedulePreview, launch?.data];
return [
data.results,
schedulePreview,
launch?.data,
allLabelsResults?.data?.results,
instanceGroupsResults?.data?.results,
];
}, [id, schedule, rrule]),
[]
);
@ -195,6 +212,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
ask_forks_on_launch,
ask_job_slice_count_on_launch,
ask_timeout_on_launch,
ask_instance_groups_on_launch,
survey_enabled,
} = launchData || {};
@ -255,6 +273,8 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
const showForksDetail = ask_forks_on_launch;
const showJobSlicingDetail = ask_job_slice_count_on_launch;
const showTimeoutDetail = ask_timeout_on_launch;
const showInstanceGroupsDetail =
ask_instance_groups_on_launch && instanceGroups.length > 0;
const showPromptedFields =
showCredentialsDetail ||
@ -271,7 +291,8 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
showLabelsDetail ||
showForksDetail ||
showJobSlicingDetail ||
showTimeoutDetail;
showTimeoutDetail ||
showInstanceGroupsDetail;
if (isLoading) {
return <ContentLoading />;
@ -471,6 +492,35 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
{ask_job_slice_count_on_launch && (
<Detail label={t`Job Slicing`} value={job_slice_count} />
)}
{showInstanceGroupsDetail && (
<Detail
fullWidth
label={t`Instance Groups`}
value={
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
key={ig.id}
>
<Chip
key={ig.id}
ouiaId={`instance-group-${ig.id}-chip`}
isReadOnly
>
{ig.name}
</Chip>
</Link>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0}
/>
)}
{showCredentialsDetail && (
<Detail
fullWidth

View File

@ -54,13 +54,14 @@ function ScheduleForm({
request: loadScheduleData,
error: contentError,
isLoading: contentLoading,
result: { zoneOptions, zoneLinks, credentials, labels },
result: { zoneOptions, zoneLinks, credentials, labels, instanceGroups },
} = useRequest(
useCallback(async () => {
const { data } = await SchedulesAPI.readZoneInfo();
let creds = [];
let allLabels;
let allLabels = [];
let allInstanceGroups = [];
if (schedule.id) {
if (
resource.type === 'job_template' &&
@ -77,15 +78,30 @@ function ScheduleForm({
} = await SchedulesAPI.readAllLabels(schedule.id);
allLabels = results;
}
} else {
if (
resource.type === 'job_template' &&
launchConfig.ask_labels_on_launch
launchConfig.ask_instance_groups_on_launch
) {
const {
data: { results },
} = await JobTemplatesAPI.readAllLabels(resource.id);
allLabels = results;
} = await SchedulesAPI.readInstanceGroups(schedule.id);
allInstanceGroups = results;
}
} else {
if (resource.type === 'job_template') {
if (launchConfig.ask_labels_on_launch) {
const {
data: { results },
} = await JobTemplatesAPI.readAllLabels(resource.id);
allLabels = results;
}
if (launchConfig.ask_instance_groups_on_launch) {
const {
data: { results },
} = await JobTemplatesAPI.readInstanceGroups(resource.id);
allInstanceGroups = results;
}
}
if (
resource.type === 'workflow_job_template' &&
@ -108,13 +124,15 @@ function ScheduleForm({
zoneOptions: zones,
zoneLinks: data.links,
credentials: creds,
labels: allLabels || [],
labels: allLabels,
instanceGroups: allInstanceGroups,
};
}, [
schedule,
resource.id,
resource.type,
launchConfig.ask_labels_on_launch,
launchConfig.ask_instance_groups_on_launch,
launchConfig.ask_credential_on_launch,
]),
{
@ -123,6 +141,7 @@ function ScheduleForm({
credentials: [],
isLoading: true,
labels: [],
instanceGroups: [],
}
);
@ -501,6 +520,7 @@ function ScheduleForm({
}}
resourceDefaultCredentials={resourceDefaultCredentials}
labels={labels}
instanceGroups={instanceGroups}
/>
)}
<FormSubmitError error={submitError} />

View File

@ -18,6 +18,7 @@ function SchedulePromptableFields({
resource,
resourceDefaultCredentials,
labels,
instanceGroups,
}) {
const { setFieldTouched, values, initialValues, resetForm } =
useFormikContext();
@ -35,7 +36,8 @@ function SchedulePromptableFields({
resource,
credentials,
resourceDefaultCredentials,
labels
labels,
instanceGroups
);
const [showDescription, setShowDescription] = useState(false);
const { error, dismissError } = useDismissableError(contentError);

View File

@ -16,7 +16,8 @@ export default function useSchedulePromptSteps(
resource,
scheduleCredentials,
resourceDefaultCredentials,
labels
labels,
instanceGroups
) {
const sourceOfValues =
(Object.keys(schedule).length > 0 && schedule) || resource;
@ -31,7 +32,7 @@ export default function useSchedulePromptSteps(
resourceDefaultCredentials
),
useExecutionEnvironmentStep(launchConfig, resource),
useInstanceGroupsStep(launchConfig, resource),
useInstanceGroupsStep(launchConfig, resource, instanceGroups),
useOtherPromptsStep(launchConfig, sourceOfValues, labels),
useSurveyStep(launchConfig, surveyConfig, sourceOfValues, visited),
];

View File

@ -8,6 +8,7 @@ export function initReducer() {
addNodeTarget: null,
addingLink: false,
contentError: null,
defaultOrganization: null,
isLoading: true,
linkToDelete: null,
linkToEdit: null,
@ -64,6 +65,11 @@ export default function visualizerReducer(state, action) {
...state,
contentError: action.value,
};
case 'SET_DEFAULT_ORGANIZATION':
return {
...state,
defaultOrganization: action.value,
};
case 'SET_IS_LOADING':
return {
...state,

View File

@ -7,6 +7,7 @@ const defaultState = {
addNodeTarget: null,
addingLink: false,
contentError: null,
defaultOrganization: null,
isLoading: true,
linkToDelete: null,
linkToEdit: null,
@ -1281,6 +1282,18 @@ describe('Workflow reducer', () => {
});
});
});
describe('SET_DEFAULT_ORGANIZATION', () => {
it('should set the state variable', () => {
const result = workflowReducer(defaultState, {
type: 'SET_DEFAULT_ORGANIZATION',
value: 1,
});
expect(result).toEqual({
...defaultState,
defaultOrganization: 1,
});
});
});
describe('SET_IS_LOADING', () => {
it('should set the state variable', () => {
const result = workflowReducer(defaultState, {

View File

@ -12,8 +12,8 @@ import { useState, useCallback } from 'react';
* }
*/
export default function useSelected(list = []) {
const [selected, setSelected] = useState([]);
export default function useSelected(list = [], defaultSelected = []) {
const [selected, setSelected] = useState(defaultSelected);
const isAllSelected = selected.length > 0 && selected.length === list.length;
const handleSelect = (row) => {

View File

@ -39,6 +39,7 @@ function NodeModalForm({
isLaunchLoading,
resourceDefaultCredentials,
labels,
instanceGroups,
}) {
const history = useHistory();
const dispatch = useContext(WorkflowDispatchContext);
@ -68,7 +69,8 @@ function NodeModalForm({
values.nodeResource,
askLinkType,
resourceDefaultCredentials,
labels
labels,
instanceGroups
);
const handleSaveNode = () => {
@ -243,7 +245,13 @@ const NodeModalInner = ({ title, ...rest }) => {
const {
request: readLaunchConfigs,
error: launchConfigError,
result: { launchConfig, surveyConfig, resourceDefaultCredentials, labels },
result: {
launchConfig,
surveyConfig,
resourceDefaultCredentials,
labels,
instanceGroups,
},
isLoading,
} = useRequest(
useCallback(async () => {
@ -263,6 +271,7 @@ const NodeModalInner = ({ title, ...rest }) => {
surveyConfig: {},
resourceDefaultCredentials: [],
labels: [],
instanceGroups: [],
};
}
@ -309,11 +318,27 @@ const NodeModalInner = ({ title, ...rest }) => {
defaultLabels = results;
}
let defaultInstanceGroups = [];
if (launch.ask_instance_groups_on_launch) {
const {
data: { results },
} = await await JobTemplatesAPI.readInstanceGroups(
values?.nodeResource?.id,
{
page_size: 200,
}
);
defaultInstanceGroups = results;
}
return {
launchConfig: launch,
surveyConfig: survey,
resourceDefaultCredentials: defaultCredentials,
labels: defaultLabels,
instanceGroups: defaultInstanceGroups,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -367,11 +392,12 @@ const NodeModalInner = ({ title, ...rest }) => {
isLaunchLoading={isLoading}
title={wizardTitle}
labels={labels}
instanceGroups={instanceGroups}
/>
);
};
const NodeModal = ({ onSave, askLinkType, title, labels }) => {
const NodeModal = ({ onSave, askLinkType, title }) => {
const { nodeToEdit } = useContext(WorkflowStateContext);
const onSaveForm = (values, config) => {
onSave(values, config);
@ -398,7 +424,6 @@ const NodeModal = ({ onSave, askLinkType, title, labels }) => {
onSave={onSaveForm}
title={title}
askLinkType={askLinkType}
labels={labels}
/>
</Form>
)}

View File

@ -228,6 +228,12 @@ const getNodeToEditDefaultValues = (
if (launchConfig.ask_timeout_on_launch) {
initialValues.timeout = sourceOfValues?.timeout || 0;
}
if (launchConfig.ask_labels_on_launch) {
initialValues.labels = sourceOfValues?.labels || [];
}
if (launchConfig.ask_instance_groups_on_launch) {
initialValues.instance_groups = sourceOfValues?.instance_groups || [];
}
if (launchConfig.ask_variables_on_launch) {
const newExtraData = { ...sourceOfValues.extra_data };
@ -274,7 +280,8 @@ export default function useWorkflowNodeSteps(
resource,
askLinkType,
resourceDefaultCredentials,
labels
labels,
instanceGroups
) {
const { nodeToEdit } = useContext(WorkflowStateContext);
const {
@ -291,7 +298,7 @@ export default function useWorkflowNodeSteps(
useInventoryStep(launchConfig, resource, visited),
useCredentialsStep(launchConfig, resource, resourceDefaultCredentials),
useExecutionEnvironmentStep(launchConfig, resource),
useInstanceGroupsStep(launchConfig, resource),
useInstanceGroupsStep(launchConfig, resource, instanceGroups),
useOtherPromptsStep(launchConfig, resource, labels),
useSurveyStep(launchConfig, surveyConfig, resource, visited),
];

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useReducer } from 'react';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { shape } from 'prop-types';
import { t } from '@lingui/macro';
@ -18,6 +17,7 @@ import ContentLoading from 'components/ContentLoading';
import workflowReducer from 'components/Workflow/workflowReducer';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import {
OrganizationsAPI,
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
@ -53,7 +53,18 @@ const Wrapper = styled.div`
`;
const replaceIdentifier = (node) => {
if (stringIsUUID(node.originalNodeObject.identifier) || node.identifier) {
if (
stringIsUUID(node.originalNodeObject.identifier) &&
typeof node.identifier === 'string' &&
node.identifier !== ''
) {
return true;
}
if (
!stringIsUUID(node.originalNodeObject.identifier) &&
node.originalNodeObject.identifier !== node.identifier
) {
return true;
}
@ -126,6 +137,7 @@ function Visualizer({ template }) {
addNodeTarget: null,
addingLink: false,
contentError: null,
defaultOrganization: null,
isLoading: true,
linkToDelete: null,
linkToEdit: null,
@ -148,6 +160,7 @@ function Visualizer({ template }) {
addLinkTargetNode,
addNodeSource,
contentError,
defaultOrganization,
isLoading,
linkToDelete,
linkToEdit,
@ -261,6 +274,14 @@ function Visualizer({ template }) {
useEffect(() => {
async function fetchData() {
try {
const {
data: { results },
} = await OrganizationsAPI.read({ page_size: 1, page: 1 });
dispatch({
type: 'SET_DEFAULT_ORGANIZATION',
value: results[0]?.id,
});
const workflowNodes = await fetchWorkflowNodes(template.id);
dispatch({
type: 'GENERATE_NODES_AND_LINKS',
@ -302,6 +323,9 @@ function Visualizer({ template }) {
const deletedNodeIds = [];
const associateCredentialRequests = [];
const disassociateCredentialRequests = [];
const associateLabelRequests = [];
const disassociateLabelRequests = [];
const instanceGroupRequests = [];
const generateLinkMapAndNewLinks = () => {
const linkMap = {};
@ -400,6 +424,8 @@ function Visualizer({ template }) {
nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {
...node.promptValues,
execution_environment:
node.promptValues?.execution_environment?.id || null,
inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id,
all_parents_must_converge: node.all_parents_must_converge,
@ -423,6 +449,29 @@ function Visualizer({ template }) {
);
});
}
if (node.promptValues?.labels?.length > 0) {
node.promptValues.labels.forEach((label) => {
associateLabelRequests.push(
WorkflowJobTemplateNodesAPI.associateLabel(
data.id,
label,
node.fullUnifiedJobTemplate.organization ||
defaultOrganization
)
);
});
}
if (node.promptValues?.instance_groups?.length > 0)
/* eslint-disable no-await-in-loop, no-restricted-syntax */
for (const group of node.promptValues.instance_groups) {
instanceGroupRequests.push(
WorkflowJobTemplateNodesAPI.associateInstanceGroup(
data.id,
group.id
)
);
}
})
);
}
@ -487,6 +536,8 @@ function Visualizer({ template }) {
nodeRequests.push(
WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, {
...node.promptValues,
execution_environment:
node.promptValues?.execution_environment?.id || null,
inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id,
all_parents_must_converge: node.all_parents_must_converge,
@ -503,6 +554,12 @@ function Visualizer({ template }) {
node.promptValues?.credentials
);
const { added: addedLabels, removed: removedLabels } =
getAddedAndRemoved(
node?.originalNodeLabels,
node.promptValues?.labels
);
if (addedCredentials.length > 0) {
addedCredentials.forEach((cred) => {
associateCredentialRequests.push(
@ -523,6 +580,41 @@ function Visualizer({ template }) {
)
);
}
if (addedLabels.length > 0) {
addedLabels.forEach((label) => {
associateLabelRequests.push(
WorkflowJobTemplateNodesAPI.associateLabel(
node.originalNodeObject.id,
label,
node.fullUnifiedJobTemplate.organization ||
defaultOrganization
)
);
});
}
if (removedLabels?.length > 0) {
removedLabels.forEach((label) =>
disassociateLabelRequests.push(
WorkflowJobTemplateNodesAPI.disassociateLabel(
node.originalNodeObject.id,
label,
node.fullUnifiedJobTemplate.organization ||
defaultOrganization
)
)
);
}
if (node.promptValues?.instance_groups) {
instanceGroupRequests.push(
WorkflowJobTemplateNodesAPI.orderInstanceGroups(
node.originalNodeObject.id,
node.promptValues?.instance_groups,
node?.originalNodeInstanceGroups || []
)
);
}
})
);
}
@ -539,11 +631,18 @@ function Visualizer({ template }) {
);
await Promise.all(associateNodes(newLinks, originalLinkMap));
await Promise.all(disassociateCredentialRequests);
await Promise.all(associateCredentialRequests);
await Promise.all([
...disassociateCredentialRequests,
...disassociateLabelRequests,
]);
await Promise.all([
...associateCredentialRequests,
...associateLabelRequests,
...instanceGroupRequests,
]);
history.push(`/templates/workflow_job_template/${template.id}/details`);
}, [links, nodes, history, template.id]),
}, [links, nodes, history, defaultOrganization, template.id]),
{}
);

View File

@ -1,6 +1,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
OrganizationsAPI,
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
@ -104,6 +105,12 @@ const mockWorkflowNodes = [
describe('Visualizer', () => {
let wrapper;
beforeEach(() => {
OrganizationsAPI.read.mockResolvedValue({
data: {
count: 1,
results: [{ id: 1, name: 'Default' }],
},
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: mockWorkflowNodes.length,

View File

@ -64,7 +64,6 @@ 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, nodes } =
@ -72,7 +71,6 @@ function VisualizerNode({
const isAddLinkSourceNode =
addLinkSourceNode && addLinkSourceNode.id === node.id;
const handleCredentialsErrorClose = () => setCredentialsError(null);
const handleDetailErrorClose = () => setDetailError(null);
const updateNode = async () => {
@ -98,18 +96,47 @@ function VisualizerNode({
if (
node?.originalNodeObject?.summary_fields?.unified_job_template
?.unified_job_type === 'job' &&
!node?.originalNodeCredentials
?.unified_job_type === 'job' ||
node?.originalNodeObject?.summary_fields?.unified_job_template
?.unified_job_type === 'workflow_job'
) {
try {
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readCredentials(
node.originalNodeObject.id
);
updatedNode.originalNodeCredentials = results;
if (
node?.originalNodeObject?.summary_fields?.unified_job_template
?.unified_job_type === 'job' &&
!node?.originalNodeCredentials
) {
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readCredentials(
node.originalNodeObject.id
);
updatedNode.originalNodeCredentials = results;
}
if (
node?.originalNodeObject?.summary_fields?.unified_job_template
?.unified_job_type === 'job' &&
!node.originalNodeLabels
) {
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readAllLabels(
node.originalNodeObject.id
);
updatedNode.originalNodeLabels = results;
updatedNode.originalNodeObject.labels = results;
}
if (!node.originalNodeInstanceGroups) {
const {
data: { results },
} = await WorkflowJobTemplateNodesAPI.readInstanceGroups(
node.originalNodeObject.id
);
updatedNode.originalNodeInstanceGroups = results;
updatedNode.originalNodeObject.instance_groups = results;
}
} catch (err) {
setCredentialsError(err);
setDetailError(err);
return null;
}
}
@ -350,17 +377,6 @@ function VisualizerNode({
<ErrorDetail error={detailError} />
</AlertModal>
)}
{credentialsError && (
<AlertModal
isOpen={credentialsError}
variant="error"
title={t`Error!`}
onClose={handleCredentialsErrorClose}
>
{t`Failed to retrieve node credentials.`}
<ErrorDetail error={credentialsError} />
</AlertModal>
)}
</>
);
}

View File

@ -8,9 +8,9 @@ const wfHelpTextStrings = () => ({
playbook. Multiple patterns are allowed. Refer to Ansible
documentation for more information and examples on patterns.`,
sourceControlBranch: t`Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch.`,
labels: t`Optional labels that describe this job template,
labels: t`Optional labels that describe this workflow job template,
such as 'dev' or 'test'. Labels can be used to group and filter
job templates and completed jobs.`,
workflow job templates and completed jobs.`,
variables: t`Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Controller documentation for example syntax.`,
enableWebhook: t`Enable Webhook for this workflow job template.`,
enableConcurrentJobs: t`If enabled, simultaneous runs of this workflow job template will be allowed.`,
@ -18,6 +18,7 @@ const wfHelpTextStrings = () => ({
webhookKey: t`Webhook services can use this as a shared secret.`,
webhookCredential: t`Optionally select the credential to use to send status updates back to the webhook service.`,
webhookService: t`Select a webhook service.`,
jobTags: t`Tags are useful when you have a large playbook, and you want to run a specific part of a play or task. Use commas to separate multiple tags. Refer to the documentation for details on the usage of tags.`,
skipTags: t`Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task. Use commas to separate multiple tags. Refer to the documentation for details on the usage of tags.`,
enabledOptions: (
<>

View File

@ -211,8 +211,6 @@ function WorkflowJobTemplateForm({
promptId="template-ask-variables-on-launch"
tooltip={helpText.variables}
/>
</FormFullWidthLayout>
<FormColumnLayout>
<FieldWithPrompt
fieldId="template-tags"
label={t`Job Tags`}
@ -237,7 +235,7 @@ function WorkflowJobTemplateForm({
onChange={(value) => skipTagsHelpers.setValue(value)}
/>
</FieldWithPrompt>
</FormColumnLayout>
</FormFullWidthLayout>
<FormGroup fieldId="options" label={t`Options`}>
<FormCheckboxLayout isInline>
<Checkbox