From e05eaeccab0d599fd19722edc36c33364ed3bbb8 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 6 Sep 2022 14:34:40 -0400 Subject: [PATCH] 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 --- .../api/models/WorkflowJobTemplateNodes.js | 4 +- .../src/components/LabelSelect/LabelSelect.js | 7 +- .../components/LaunchButton/LaunchButton.js | 10 ++ .../components/LaunchPrompt/LaunchPrompt.js | 11 +- .../LaunchPrompt/steps/InstanceGroupsStep.js | 4 +- .../LaunchPrompt/steps/OtherPromptsStep.js | 70 ++++++----- .../steps/OtherPromptsStep.test.js | 30 +++++ .../components/LaunchPrompt/useLaunchSteps.js | 5 +- .../MultiSelect/useSyncedSelectValue.js | 2 +- .../PromptDetail/PromptJobTemplateDetail.js | 5 +- .../Schedule/ScheduleDetail/ScheduleDetail.js | 68 +++++++++-- .../Schedule/shared/ScheduleForm.js | 34 ++++-- .../shared/SchedulePromptableFields.js | 4 +- .../Schedule/shared/useSchedulePromptSteps.js | 5 +- .../components/Workflow/workflowReducer.js | 6 + .../Workflow/workflowReducer.test.js | 13 +++ awx/ui/src/hooks/useSelected.js | 4 +- .../Modals/NodeModals/NodeModal.js | 33 +++++- .../Modals/NodeModals/useWorkflowNodeSteps.js | 11 +- .../Visualizer.js | 109 +++++++++++++++++- .../Visualizer.test.js | 7 ++ .../VisualizerNode.js | 60 ++++++---- .../shared/WorkflowJobTemplate.helptext.js | 5 +- .../shared/WorkflowJobTemplateForm.js | 4 +- 24 files changed, 402 insertions(+), 109 deletions(-) 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)} /> - +