diff --git a/awx/ui/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui/src/api/models/WorkflowJobTemplateNodes.js index eab9c1ddca..fce36ad516 100644 --- a/awx/ui/src/api/models/WorkflowJobTemplateNodes.js +++ b/awx/ui/src/api/models/WorkflowJobTemplateNodes.js @@ -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/'; diff --git a/awx/ui/src/components/LabelSelect/LabelSelect.js b/awx/ui/src/components/LabelSelect/LabelSelect.js index ccb5836407..2aa06b77b6 100644 --- a/awx/ui/src/components/LabelSelect/LabelSelect.js +++ b/awx/ui/src/components/LabelSelect/LabelSelect.js @@ -91,11 +91,8 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) { { - if (typeof item === 'string') { - item = { id: item, name: item }; - } - onSelect(e, item); + onClick={(e) => { + onSelect(e, currentChip); }} > {currentChip.name} diff --git a/awx/ui/src/components/LaunchButton/LaunchButton.js b/awx/ui/src/components/LaunchButton/LaunchButton.js index 5f207e3be7..ad133d64b7 100644 --- a/awx/ui/src/components/LaunchButton/LaunchButton.js +++ b/awx/ui/src/components/LaunchButton/LaunchButton.js @@ -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} /> )} diff --git a/awx/ui/src/components/LaunchPrompt/LaunchPrompt.js b/awx/ui/src/components/LaunchPrompt/LaunchPrompt.js index 290faff03f..c177405e59 100644 --- a/awx/ui/src/components/LaunchPrompt/LaunchPrompt.js +++ b/awx/ui/src/components/LaunchPrompt/LaunchPrompt.js @@ -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 ( onLaunch(values)}> @@ -208,6 +216,7 @@ function LaunchPrompt({ resource={resource} labels={labels} resourceDefaultCredentials={resourceDefaultCredentials} + instanceGroups={instanceGroups} /> ); diff --git a/awx/ui/src/components/LaunchPrompt/steps/InstanceGroupsStep.js b/awx/ui/src/components/LaunchPrompt/steps/InstanceGroupsStep.js index bd369f8d83..c4ea9d60fe 100644 --- a/awx/ui/src/components/LaunchPrompt/steps/InstanceGroupsStep.js +++ b/awx/ui/src/components/LaunchPrompt/steps/InstanceGroupsStep.js @@ -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 ( { e.preventDefault(); }} > - {launchConfig.ask_job_type_on_launch && } + {launchConfig.ask_job_type_on_launch && ( + + )} {launchConfig.ask_scm_branch_on_launch && ( )} - {launchConfig.ask_labels_on_launch && } + {launchConfig.ask_labels_on_launch && ( + + )} {launchConfig.ask_forks_on_launch && ( )} {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 && } + {launchConfig.ask_verbosity_on_launch && ( + + )} {launchConfig.ask_job_slice_count_on_launch && ( )} {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 && } @@ -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 ( - } + labelIcon={} 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 ( ); @@ -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 ( - } + labelIcon={} + validated={!meta.touched || !meta.error ? 'default' : 'error'} + helperTextInvalid={meta.error} > helpers.setValue(labels)} createText={t`Create`} - onError={() => {}} + onError={(err) => helpers.setError(err)} /> ); diff --git a/awx/ui/src/components/LaunchPrompt/steps/OtherPromptsStep.test.js b/awx/ui/src/components/LaunchPrompt/steps/OtherPromptsStep.test.js index 5b5e5eb19a..fc33c3d03a 100644 --- a/awx/ui/src/components/LaunchPrompt/steps/OtherPromptsStep.test.js +++ b/awx/ui/src/components/LaunchPrompt/steps/OtherPromptsStep.test.js @@ -13,6 +13,11 @@ describe('OtherPromptsStep', () => { @@ -36,6 +41,11 @@ describe('OtherPromptsStep', () => { @@ -56,6 +66,11 @@ describe('OtherPromptsStep', () => { @@ -76,6 +91,11 @@ describe('OtherPromptsStep', () => { @@ -96,6 +116,11 @@ describe('OtherPromptsStep', () => { @@ -119,6 +144,11 @@ describe('OtherPromptsStep', () => { onVarModeChange={onModeChange} launchConfig={{ ask_variables_on_launch: true, + job_template_data: { + name: 'Demo Job Template', + id: 1, + description: '', + }, }} /> diff --git a/awx/ui/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui/src/components/LaunchPrompt/useLaunchSteps.js index fda7c79854..7cbba9be8a 100644 --- a/awx/ui/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui/src/components/LaunchPrompt/useLaunchSteps.js @@ -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), ]; diff --git a/awx/ui/src/components/MultiSelect/useSyncedSelectValue.js b/awx/ui/src/components/MultiSelect/useSyncedSelectValue.js index 94eba4f0aa..38e226b895 100644 --- a/awx/ui/src/components/MultiSelect/useSyncedSelectValue.js +++ b/awx/ui/src/components/MultiSelect/useSyncedSelectValue.js @@ -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) { diff --git a/awx/ui/src/components/PromptDetail/PromptJobTemplateDetail.js b/awx/ui/src/components/PromptDetail/PromptJobTemplateDetail.js index 6e690337e1..adc82c3256 100644 --- a/awx/ui/src/components/PromptDetail/PromptJobTemplateDetail.js +++ b/awx/ui/src/components/PromptDetail/PromptJobTemplateDetail.js @@ -146,7 +146,10 @@ function PromptJobTemplateDetail({ resource }) { /> - + {typeof diff_mode === 'boolean' && ( diff --git a/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js b/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js index 05fd0ba3cb..cfaf4de14b 100644 --- a/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js +++ b/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js @@ -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 ; @@ -471,6 +492,35 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { {ask_job_slice_count_on_launch && ( )} + {showInstanceGroupsDetail && ( + + {instanceGroups.map((ig) => ( + + + {ig.name} + + + ))} + + } + isEmpty={instanceGroups.length === 0} + /> + )} {showCredentialsDetail && ( { 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} /> )} diff --git a/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js b/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js index 21a33d21cf..d0faf3248d 100644 --- a/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js +++ b/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js @@ -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); diff --git a/awx/ui/src/components/Schedule/shared/useSchedulePromptSteps.js b/awx/ui/src/components/Schedule/shared/useSchedulePromptSteps.js index 7644d8c277..630cc119ba 100644 --- a/awx/ui/src/components/Schedule/shared/useSchedulePromptSteps.js +++ b/awx/ui/src/components/Schedule/shared/useSchedulePromptSteps.js @@ -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), ]; diff --git a/awx/ui/src/components/Workflow/workflowReducer.js b/awx/ui/src/components/Workflow/workflowReducer.js index e1bd2dfc8e..be77528136 100644 --- a/awx/ui/src/components/Workflow/workflowReducer.js +++ b/awx/ui/src/components/Workflow/workflowReducer.js @@ -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, diff --git a/awx/ui/src/components/Workflow/workflowReducer.test.js b/awx/ui/src/components/Workflow/workflowReducer.test.js index 3570f701dd..e241d76bff 100644 --- a/awx/ui/src/components/Workflow/workflowReducer.test.js +++ b/awx/ui/src/components/Workflow/workflowReducer.test.js @@ -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, { diff --git a/awx/ui/src/hooks/useSelected.js b/awx/ui/src/hooks/useSelected.js index 3587a2efe2..f596f5ca7f 100644 --- a/awx/ui/src/hooks/useSelected.js +++ b/awx/ui/src/hooks/useSelected.js @@ -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) => { diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.js b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.js index 028c80f3e8..df072a067c 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.js @@ -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} /> )} diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js index 9688f9c703..853c23d31a 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -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), ]; diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.js b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.js index 170dc59ad1..f0ae5bcfb3 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.js @@ -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]), {} ); diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.js b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.js index 1be1ae3bdd..28b250dca3 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.js @@ -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, diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.js b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.js index 88f6346f2b..9b42148346 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.js @@ -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({ )} - {credentialsError && ( - - {t`Failed to retrieve node credentials.`} - - - )} ); } diff --git a/awx/ui/src/screens/Template/shared/WorkflowJobTemplate.helptext.js b/awx/ui/src/screens/Template/shared/WorkflowJobTemplate.helptext.js index a8f29f7bc6..dbdc2ce188 100644 --- a/awx/ui/src/screens/Template/shared/WorkflowJobTemplate.helptext.js +++ b/awx/ui/src/screens/Template/shared/WorkflowJobTemplate.helptext.js @@ -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: ( <> diff --git a/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js b/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js index 658f075c70..9d974f3105 100644 --- a/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js +++ b/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js @@ -211,8 +211,6 @@ function WorkflowJobTemplateForm({ promptId="template-ask-variables-on-launch" tooltip={helpText.variables} /> - - skipTagsHelpers.setValue(value)} /> - +