{
+ if (nextStep.id === 'preview') {
+ visitAllSteps(setTouched);
+ } else {
+ visitStep(prevStep.prevId);
+ }
+ await validateForm();
+ }}
+ onGoToStep={async (newStep, prevStep) => {
+ if (newStep.id === 'preview') {
+ visitAllSteps(setTouched);
+ } else {
+ visitStep(prevStep.prevId);
+ }
+ await validateForm();
+ }}
title={i18n._(t`Prompts`)}
steps={steps}
/>
diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx
index 78e8dc5504..650e2cc640 100644
--- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx
+++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx
@@ -1,16 +1,22 @@
import React from 'react';
import { act, isElementOfType } from 'react-dom/test-utils';
-import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import LaunchPrompt from './LaunchPrompt';
-import InventoryStep from './InventoryStep';
-import CredentialsStep from './CredentialsStep';
-import OtherPromptsStep from './OtherPromptsStep';
-import PreviewStep from './PreviewStep';
-import { InventoriesAPI, CredentialsAPI, CredentialTypesAPI } from '@api';
+import InventoryStep from './steps/InventoryStep';
+import CredentialsStep from './steps/CredentialsStep';
+import OtherPromptsStep from './steps/OtherPromptsStep';
+import PreviewStep from './steps/PreviewStep';
+import {
+ InventoriesAPI,
+ CredentialsAPI,
+ CredentialTypesAPI,
+ JobTemplatesAPI,
+} from '@api';
jest.mock('@api/models/Inventories');
jest.mock('@api/models/CredentialTypes');
jest.mock('@api/models/Credentials');
+jest.mock('@api/models/JobTemplates');
let config;
const resource = {
@@ -31,6 +37,13 @@ describe('LaunchPrompt', () => {
data: { results: [{ id: 1 }], count: 1 },
});
CredentialTypesAPI.loadAllTypes({ data: { results: [{ type: 'ssh' }] } });
+ JobTemplatesAPI.readSurvey.mockResolvedValue({
+ data: {
+ name: '',
+ description: '',
+ spec: [{ type: 'text', variable: 'foo' }],
+ },
+ });
config = {
can_start_without_user_input: false,
@@ -73,13 +86,14 @@ describe('LaunchPrompt', () => {
/>
);
});
- const steps = wrapper.find('Wizard').prop('steps');
+ const wizard = await waitForElement(wrapper, 'Wizard');
+ const steps = wizard.prop('steps');
expect(steps).toHaveLength(5);
- expect(steps[0].name).toEqual('Inventory');
+ expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name).toEqual('Credentials');
- expect(steps[2].name).toEqual('Other Prompts');
- expect(steps[3].name).toEqual('Survey');
+ expect(steps[2].name.props.children).toEqual('Other Prompts');
+ expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name).toEqual('Preview');
});
@@ -98,10 +112,11 @@ describe('LaunchPrompt', () => {
/>
);
});
- const steps = wrapper.find('Wizard').prop('steps');
+ const wizard = await waitForElement(wrapper, 'Wizard');
+ const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
- expect(steps[0].name).toEqual('Inventory');
+ expect(steps[0].name.props.children).toEqual('Inventory');
expect(isElementOfType(steps[0].component, InventoryStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
});
@@ -121,7 +136,8 @@ describe('LaunchPrompt', () => {
/>
);
});
- const steps = wrapper.find('Wizard').prop('steps');
+ const wizard = await waitForElement(wrapper, 'Wizard');
+ const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Credentials');
@@ -144,10 +160,11 @@ describe('LaunchPrompt', () => {
/>
);
});
- const steps = wrapper.find('Wizard').prop('steps');
+ const wizard = await waitForElement(wrapper, 'Wizard');
+ const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
- expect(steps[0].name).toEqual('Other Prompts');
+ expect(steps[0].name.props.children).toEqual('Other Prompts');
expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
});
diff --git a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx
deleted file mode 100644
index b681a402bf..0000000000
--- a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import React from 'react';
-
-function PreviewStep() {
- return Preview of selected values will appear here
;
-}
-
-export default PreviewStep;
diff --git a/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js b/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js
new file mode 100644
index 0000000000..0559eefc1f
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js
@@ -0,0 +1,9 @@
+export default function getSurveyValues(values) {
+ const surveyValues = {};
+ Object.keys(values).forEach(key => {
+ if (key.startsWith('survey_')) {
+ surveyValues[key.substr(7)] = values[key];
+ }
+ });
+ return surveyValues;
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js
index f324f23f6b..261c02a875 100644
--- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js
+++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js
@@ -8,4 +8,12 @@ export default function mergeExtraVars(extraVars, survey = {}) {
};
}
-// TODO: "safe" version that obscures passwords for preview step
+export function maskPasswords(vars, passwordKeys) {
+ const updated = { ...vars };
+ passwordKeys.forEach(key => {
+ if (typeof updated[key] !== 'undefined') {
+ updated[key] = '········';
+ }
+ });
+ return updated;
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js
index 55f37088bb..bd696ab9e5 100644
--- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js
+++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js
@@ -1,4 +1,4 @@
-import mergeExtraVars from './mergeExtraVars';
+import mergeExtraVars, { maskPasswords } from './mergeExtraVars';
describe('mergeExtraVars', () => {
test('should handle yaml string', () => {
@@ -31,4 +31,32 @@ describe('mergeExtraVars', () => {
bar: 'baz',
});
});
+
+ describe('maskPasswords', () => {
+ test('should mask password fields', () => {
+ const vars = {
+ one: 'alpha',
+ two: 'bravo',
+ three: 'charlie',
+ };
+
+ expect(maskPasswords(vars, ['one', 'three'])).toEqual({
+ one: '········',
+ two: 'bravo',
+ three: '········',
+ });
+ });
+
+ test('should mask empty strings', () => {
+ const vars = {
+ one: '',
+ two: 'bravo',
+ };
+
+ expect(maskPasswords(vars, ['one', 'three'])).toEqual({
+ one: '········',
+ two: 'bravo',
+ });
+ });
+ });
});
diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx
similarity index 96%
rename from awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx
index a389db0cff..5288e5f8cc 100644
--- a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx
@@ -12,15 +12,19 @@ import CredentialChip from '@components/CredentialChip';
import ContentError from '@components/ContentError';
import { getQSConfig, parseQueryString } from '@util/qs';
import useRequest from '@util/useRequest';
+import { required } from '@util/validators';
-const QS_CONFIG = getQSConfig('inventory', {
+const QS_CONFIG = getQSConfig('credential', {
page: 1,
page_size: 5,
order_by: 'name',
});
function CredentialsStep({ i18n }) {
- const [field, , helpers] = useField('credentials');
+ const [field, , helpers] = useField({
+ name: 'credentials',
+ validate: required(null, i18n),
+ });
const [selectedType, setSelectedType] = useState(null);
const history = useHistory();
diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx
similarity index 100%
rename from awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx
diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx
similarity index 93%
rename from awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx
index e34dc40664..d892e0c91b 100644
--- a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx
@@ -9,6 +9,7 @@ import useRequest from '@util/useRequest';
import OptionsList from '@components/OptionsList';
import ContentLoading from '@components/ContentLoading';
import ContentError from '@components/ContentError';
+import { required } from '@util/validators';
const QS_CONFIG = getQSConfig('inventory', {
page: 1,
@@ -17,7 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
});
function InventoryStep({ i18n }) {
- const [field, , helpers] = useField('inventory');
+ const [field, , helpers] = useField({
+ name: 'inventory',
+ validate: required(null, i18n),
+ });
const history = useHistory();
const {
diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx
similarity index 100%
rename from awx/ui_next/src/components/LaunchPrompt/InventoryStep.test.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx
diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx
similarity index 100%
rename from awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx
diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx
similarity index 100%
rename from awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx
new file mode 100644
index 0000000000..e8df1ea8ff
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { useFormikContext } from 'formik';
+import yaml from 'js-yaml';
+import PromptDetail from '@components/PromptDetail';
+import mergeExtraVars, { maskPasswords } from '../mergeExtraVars';
+import getSurveyValues from '../getSurveyValues';
+
+function PreviewStep({ resource, config, survey, formErrors }) {
+ const { values } = useFormikContext();
+ const surveyValues = getSurveyValues(values);
+ const passwordFields = survey.spec
+ .filter(q => q.type === 'password')
+ .map(q => q.variable);
+ const masked = maskPasswords(surveyValues, passwordFields);
+ return (
+ <>
+
+ {formErrors && (
+
+ {Object.keys(formErrors).map(
+ field => `${field}: ${formErrors[field]}`
+ )}
+
+ )}
+ >
+ );
+}
+
+export default PreviewStep;
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx
new file mode 100644
index 0000000000..28bf5f0414
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import styled from 'styled-components';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Tooltip } from '@patternfly/react-core';
+import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
+
+const AlertText = styled.div`
+ color: var(--pf-global--danger-color--200);
+ font-weight: var(--pf-global--FontWeight--bold);
+`;
+
+const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
+ margin-left: 10px;
+`;
+
+function StepName({ hasErrors, children, i18n }) {
+ if (!hasErrors) {
+ return children;
+ }
+ return (
+ <>
+
+ {children}
+
+
+
+
+ >
+ );
+}
+
+export default withI18n()(StepName);
diff --git a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx
similarity index 59%
rename from awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx
rename to awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx
index c7b7d9da64..ba33b8ec11 100644
--- a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx
@@ -1,7 +1,6 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
-import { Formik, useField } from 'formik';
-import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
+import { useField } from 'formik';
import {
Form,
FormGroup,
@@ -11,9 +10,6 @@ import {
} from '@patternfly/react-core';
import FormField, { FieldTooltip } from '@components/FormField';
import AnsibleSelect from '@components/AnsibleSelect';
-import ContentLoading from '@components/ContentLoading';
-import ContentError from '@components/ContentError';
-import useRequest from '@util/useRequest';
import {
required,
minMaxValue,
@@ -22,54 +18,9 @@ import {
integer,
combine,
} from '@util/validators';
+import { Survey } from '@types';
-function SurveyStep({ template, i18n }) {
- const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
- useCallback(async () => {
- const { data } =
- template.type === 'workflow_job_template'
- ? await WorkflowJobTemplatesAPI.readSurvey(template.id)
- : await JobTemplatesAPI.readSurvey(template.id);
- return data;
- }, [template])
- );
- useEffect(() => {
- fetchSurvey();
- }, [fetchSurvey]);
-
- if (error) {
- return ;
- }
- if (isLoading || !survey) {
- return ;
- }
-
- const initialValues = {};
- survey.spec.forEach(question => {
- if (question.type === 'multiselect') {
- initialValues[question.variable] = question.default.split('\n');
- } else {
- initialValues[question.variable] = question.default;
- }
- });
-
- return (
-
- );
-}
-
-// This is a nested Formik form to perform validation on individual
-// survey questions. When changes to the inner form occur (onBlur), the
-// values for all questions are added to the outer form's `survey` field
-// as a single object.
-function SurveySubForm({ survey, initialValues, i18n }) {
- const [, , surveyFieldHelpers] = useField('survey');
- useEffect(() => {
- // set survey initial values to parent form
- surveyFieldHelpers.setValue(initialValues);
- /* eslint-disable-next-line react-hooks/exhaustive-deps */
- }, []);
-
+function SurveyStep({ survey, i18n }) {
const fieldTypes = {
text: TextField,
textarea: TextField,
@@ -80,21 +31,19 @@ function SurveySubForm({ survey, initialValues, i18n }) {
float: NumberField,
};
return (
-
- {({ values }) => (
-
- )}
-
+
);
}
+SurveyStep.propTypes = {
+ survey: Survey.isRequired,
+};
function TextField({ question, i18n }) {
const validators = [
@@ -105,7 +54,7 @@ function TextField({ question, i18n }) {
return (
{
+ return {};
+ };
+
+ return {
+ step: getStep(config, i18n),
+ initialValues: getInitialValues(config, resource),
+ validate,
+ isReady: true,
+ error: null,
+ setTouched: setFieldsTouched => {
+ setFieldsTouched({
+ credentials: true,
+ });
+ },
+ };
+}
+
+function getStep(config, i18n) {
+ if (!config.ask_credential_on_launch) {
+ return null;
+ }
+ return {
+ id: STEP_ID,
+ name: i18n._(t`Credentials`),
+ component: ,
+ };
+}
+
+function getInitialValues(config, resource) {
+ if (!config.ask_credential_on_launch) {
+ return {};
+ }
+ return {
+ credentials: resource?.summary_fields?.credentials || [],
+ };
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx
new file mode 100644
index 0000000000..e0d0ea009b
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx
@@ -0,0 +1,54 @@
+import React, { useState } from 'react';
+import { t } from '@lingui/macro';
+import InventoryStep from './InventoryStep';
+import StepName from './StepName';
+
+const STEP_ID = 'inventory';
+
+export default function useInventoryStep(config, resource, visitedSteps, i18n) {
+ const [stepErrors, setStepErrors] = useState({});
+
+ const validate = values => {
+ const errors = {};
+ if (!values.inventory) {
+ errors.inventory = i18n._(t`An inventory must be selected`);
+ }
+ setStepErrors(errors);
+ return errors;
+ };
+
+ const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
+
+ return {
+ step: getStep(config, hasErrors, i18n),
+ initialValues: getInitialValues(config, resource),
+ validate,
+ isReady: true,
+ error: null,
+ setTouched: setFieldsTouched => {
+ setFieldsTouched({
+ inventory: true,
+ });
+ },
+ };
+}
+
+function getStep(config, hasErrors, i18n) {
+ if (!config.ask_inventory_on_launch) {
+ return null;
+ }
+ return {
+ id: STEP_ID,
+ name: {i18n._(t`Inventory`)},
+ component: ,
+ };
+}
+
+function getInitialValues(config, resource) {
+ if (!config.ask_inventory_on_launch) {
+ return {};
+ }
+ return {
+ inventory: resource?.summary_fields?.inventory || null,
+ };
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx
new file mode 100644
index 0000000000..516238ca7a
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx
@@ -0,0 +1,93 @@
+import React, { useState } from 'react';
+import { t } from '@lingui/macro';
+import OtherPromptsStep from './OtherPromptsStep';
+import StepName from './StepName';
+
+const STEP_ID = 'other';
+
+export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
+ const [stepErrors, setStepErrors] = useState({});
+
+ const validate = values => {
+ const errors = {};
+ if (config.ask_job_type_on_launch && !values.job_type) {
+ errors.job_type = i18n._(t`This field must not be blank`);
+ }
+ setStepErrors(errors);
+ return errors;
+ };
+
+ const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
+
+ return {
+ step: getStep(config, hasErrors, i18n),
+ initialValues: getInitialValues(config, resource),
+ validate,
+ isReady: true,
+ error: null,
+ setTouched: setFieldsTouched => {
+ setFieldsTouched({
+ job_type: true,
+ limit: true,
+ verbosity: true,
+ diff_mode: true,
+ job_tags: true,
+ skip_tags: true,
+ extra_vars: true,
+ });
+ },
+ };
+}
+
+function getStep(config, hasErrors, i18n) {
+ if (!shouldShowPrompt(config)) {
+ return null;
+ }
+ return {
+ id: STEP_ID,
+ name: {i18n._(t`Other Prompts`)},
+ component: ,
+ };
+}
+
+function shouldShowPrompt(config) {
+ return (
+ config.ask_job_type_on_launch ||
+ config.ask_limit_on_launch ||
+ config.ask_verbosity_on_launch ||
+ config.ask_tags_on_launch ||
+ config.ask_skip_tags_on_launch ||
+ config.ask_variables_on_launch ||
+ config.ask_scm_branch_on_launch ||
+ config.ask_diff_mode_on_launch
+ );
+}
+
+function getInitialValues(config, resource) {
+ const initialValues = {};
+ if (config.ask_job_type_on_launch) {
+ initialValues.job_type = resource.job_type || '';
+ }
+ if (config.ask_limit_on_launch) {
+ initialValues.limit = resource.limit || '';
+ }
+ if (config.ask_verbosity_on_launch) {
+ initialValues.verbosity = resource.verbosity || 0;
+ }
+ if (config.ask_tags_on_launch) {
+ initialValues.job_tags = resource.job_tags || '';
+ }
+ if (config.ask_skip_tags_on_launch) {
+ initialValues.skip_tags = resource.skip_tags || '';
+ }
+ if (config.ask_variables_on_launch) {
+ initialValues.extra_vars = resource.extra_vars || '---';
+ }
+ if (config.ask_scm_branch_on_launch) {
+ initialValues.scm_branch = resource.scm_branch || '';
+ }
+ if (config.ask_diff_mode_on_launch) {
+ initialValues.diff_mode = resource.diff_mode || false;
+ }
+ return initialValues;
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx
new file mode 100644
index 0000000000..a7ae2c61d1
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { t } from '@lingui/macro';
+import PreviewStep from './PreviewStep';
+
+const STEP_ID = 'preview';
+
+export default function usePreviewStep(
+ config,
+ resource,
+ survey,
+ formErrors,
+ i18n
+) {
+ return {
+ step: {
+ id: STEP_ID,
+ name: i18n._(t`Preview`),
+ component: (
+
+ ),
+ enableNext: Object.keys(formErrors).length === 0,
+ nextButtonText: i18n._(t`Launch`),
+ },
+ initialValues: {},
+ validate: () => ({}),
+ isReady: true,
+ error: null,
+ setTouched: () => {},
+ };
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx
new file mode 100644
index 0000000000..8b4014b251
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx
@@ -0,0 +1,119 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { t } from '@lingui/macro';
+import useRequest from '@util/useRequest';
+import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
+import SurveyStep from './SurveyStep';
+import StepName from './StepName';
+
+const STEP_ID = 'survey';
+
+export default function useSurveyStep(config, resource, visitedSteps, i18n) {
+ const [stepErrors, setStepErrors] = useState({});
+
+ const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
+ useCallback(async () => {
+ if (!config.survey_enabled) {
+ return {};
+ }
+ const { data } =
+ resource.type === 'workflow_job_template'
+ ? await WorkflowJobTemplatesAPI.readSurvey(resource.id)
+ : await JobTemplatesAPI.readSurvey(resource.id);
+ return data;
+ }, [config.survey_enabled, resource])
+ );
+
+ useEffect(() => {
+ fetchSurvey();
+ }, [fetchSurvey]);
+
+ const validate = values => {
+ if (!config.survey_enabled || !survey || !survey.spec) {
+ return {};
+ }
+ const errors = {};
+ survey.spec.forEach(question => {
+ const errMessage = validateField(
+ question,
+ values[`survey_${question.variable}`],
+ i18n
+ );
+ if (errMessage) {
+ errors[`survey_${question.variable}`] = errMessage;
+ }
+ });
+ setStepErrors(errors);
+ return errors;
+ };
+
+ const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
+
+ return {
+ step: getStep(config, survey, hasErrors, i18n),
+ initialValues: getInitialValues(config, survey),
+ validate,
+ survey,
+ isReady: !isLoading && !!survey,
+ error,
+ setTouched: setFieldsTouched => {
+ if (!survey) {
+ return;
+ }
+ const fields = {};
+ survey.spec.forEach(question => {
+ fields[`survey_${question.variable}`] = true;
+ });
+ setFieldsTouched(fields);
+ },
+ };
+}
+
+function validateField(question, value, i18n) {
+ const isTextField = ['text', 'textarea'].includes(question.type);
+ const isNumeric = ['integer', 'float'].includes(question.type);
+ if (isTextField && (value || value === 0)) {
+ if (question.min && value.length < question.min) {
+ return i18n._(t`This field must be at least ${question.min} characters`);
+ }
+ if (question.max && value.length > question.max) {
+ return i18n._(t`This field must not exceed ${question.max} characters`);
+ }
+ }
+ if (isNumeric && (value || value === 0)) {
+ if (value < question.min || value > question.max) {
+ return i18n._(
+ t`This field must be a number and have a value between ${question.min} and ${question.max}`
+ );
+ }
+ }
+ if (question.required && !value && value !== 0) {
+ return i18n._(t`This field must not be blank`);
+ }
+ return null;
+}
+
+function getStep(config, survey, hasErrors, i18n) {
+ if (!config.survey_enabled) {
+ return null;
+ }
+ return {
+ id: STEP_ID,
+ name: {i18n._(t`Survey`)},
+ component: ,
+ };
+}
+
+function getInitialValues(config, survey) {
+ if (!config.survey_enabled || !survey) {
+ return {};
+ }
+ const values = {};
+ survey.spec.forEach(question => {
+ if (question.type === 'multiselect') {
+ values[`survey_${question.variable}`] = question.default.split('\n');
+ } else {
+ values[`survey_${question.variable}`] = question.default;
+ }
+ });
+ return values;
+}
diff --git a/awx/ui_next/src/components/LaunchPrompt/useSteps.js b/awx/ui_next/src/components/LaunchPrompt/useSteps.js
new file mode 100644
index 0000000000..ed61a01804
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchPrompt/useSteps.js
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import useInventoryStep from './steps/useInventoryStep';
+import useCredentialsStep from './steps/useCredentialsStep';
+import useOtherPromptsStep from './steps/useOtherPromptsStep';
+import useSurveyStep from './steps/useSurveyStep';
+import usePreviewStep from './steps/usePreviewStep';
+
+export default function useSteps(config, resource, i18n) {
+ const [visited, setVisited] = useState({});
+ const steps = [
+ useInventoryStep(config, resource, visited, i18n),
+ useCredentialsStep(config, resource, visited, i18n),
+ useOtherPromptsStep(config, resource, visited, i18n),
+ useSurveyStep(config, resource, visited, i18n),
+ ];
+ steps.push(
+ usePreviewStep(
+ config,
+ resource,
+ steps[3].survey,
+ {}, // TODO: formErrors ?
+ i18n
+ )
+ );
+
+ const pfSteps = steps.map(s => s.step).filter(s => s != null);
+ const initialValues = steps.reduce((acc, cur) => {
+ return {
+ ...acc,
+ ...cur.initialValues,
+ };
+ }, {});
+ const isReady = !steps.some(s => !s.isReady);
+ const stepWithError = steps.find(s => s.error);
+ const contentError = stepWithError ? stepWithError.error : null;
+
+ const validate = values => {
+ const errors = steps.reduce((acc, cur) => {
+ return {
+ ...acc,
+ ...cur.validate(values),
+ };
+ }, {});
+ if (Object.keys(errors).length) {
+ return errors;
+ }
+ return false;
+ };
+
+ return {
+ steps: pfSteps,
+ initialValues,
+ isReady,
+ validate,
+ visitStep: stepId => setVisited({ ...visited, [stepId]: true }),
+ visitAllSteps: setFieldsTouched => {
+ setVisited({
+ inventory: true,
+ credentials: true,
+ other: true,
+ survey: true,
+ preview: true,
+ });
+ steps.forEach(s => s.setTouched(setFieldsTouched));
+ },
+ contentError,
+ };
+}
diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
index 53e0dc092f..9e32e60ba2 100644
--- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
+++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
@@ -77,81 +77,7 @@ function omitOverrides(resource, overrides) {
return clonedResource;
}
-// TODO: When prompting is hooked up, update function
-// to filter based on prompt overrides
-function partitionPromptDetails(resource, launchConfig) {
- const { defaults = {} } = launchConfig;
- const overrides = {};
-
- if (launchConfig.ask_credential_on_launch) {
- let isEqual;
- const defaultCreds = defaults.credentials;
- const currentCreds = resource?.summary_fields?.credentials;
-
- if (defaultCreds?.length === currentCreds?.length) {
- isEqual = currentCreds.every(cred => {
- return defaultCreds.some(item => item.id === cred.id);
- });
- } else {
- isEqual = false;
- }
-
- if (!isEqual) {
- overrides.credentials = resource?.summary_fields?.credentials;
- }
- }
- if (launchConfig.ask_diff_mode_on_launch) {
- if (defaults.diff_mode !== resource.diff_mode) {
- overrides.diff_mode = resource.diff_mode;
- }
- }
- if (launchConfig.ask_inventory_on_launch) {
- if (defaults.inventory.id !== resource.inventory) {
- overrides.inventory = resource?.summary_fields?.inventory;
- }
- }
- if (launchConfig.ask_job_type_on_launch) {
- if (defaults.job_type !== resource.job_type) {
- overrides.job_type = resource.job_type;
- }
- }
- if (launchConfig.ask_limit_on_launch) {
- if (defaults.limit !== resource.limit) {
- overrides.limit = resource.limit;
- }
- }
- if (launchConfig.ask_scm_branch_on_launch) {
- if (defaults.scm_branch !== resource.scm_branch) {
- overrides.scm_branch = resource.scm_branch;
- }
- }
- if (launchConfig.ask_skip_tags_on_launch) {
- if (defaults.skip_tags !== resource.skip_tags) {
- overrides.skip_tags = resource.skip_tags;
- }
- }
- if (launchConfig.ask_tags_on_launch) {
- if (defaults.job_tags !== resource.job_tags) {
- overrides.job_tags = resource.job_tags;
- }
- }
- if (launchConfig.ask_variables_on_launch) {
- if (defaults.extra_vars !== resource.extra_vars) {
- overrides.extra_vars = resource.extra_vars;
- }
- }
- if (launchConfig.ask_verbosity_on_launch) {
- if (defaults.verbosity !== resource.verbosity) {
- overrides.verbosity = resource.verbosity;
- }
- }
-
- const withoutOverrides = omitOverrides(resource, overrides);
-
- return [withoutOverrides, overrides];
-}
-
-function PromptDetail({ i18n, resource, launchConfig = {} }) {
+function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
const VERBOSITY = {
0: i18n._(t`0 (Normal)`),
1: i18n._(t`1 (Verbose)`),
@@ -160,7 +86,7 @@ function PromptDetail({ i18n, resource, launchConfig = {} }) {
4: i18n._(t`4 (Connection Debug)`),
};
- const [details, overrides] = partitionPromptDetails(resource, launchConfig);
+ const details = omitOverrides(resource, overrides);
const hasOverrides = Object.keys(overrides).length > 0;
return (
diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
index 17ecfd4e30..ece4fe211d 100644
--- a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
+++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
@@ -67,7 +67,7 @@ describe('PromptDetail', () => {
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
- expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values');
+ expect(wrapper.find('PromptDetail h2')).toHaveLength(0);
assertDetail('Name', 'Mock JT');
assertDetail('Description', 'Mock JT Description');
assertDetail('Type', 'Job Template');
@@ -143,4 +143,74 @@ describe('PromptDetail', () => {
expect(overrideDetails.find('VariablesDetail').length).toBe(0);
});
});
+
+ describe('with overrides', () => {
+ let wrapper;
+ const overrides = {
+ extra_vars: '---one: two\nbar: baz',
+ inventory: {
+ name: 'Override inventory',
+ },
+ };
+
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should render overridden details', () => {
+ function assertDetail(label, value) {
+ expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
+ expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
+ }
+
+ expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values');
+ assertDetail('Name', 'Mock JT');
+ assertDetail('Description', 'Mock JT Description');
+ assertDetail('Type', 'Job Template');
+ assertDetail('Job Type', 'Run');
+ assertDetail('Inventory', 'Override inventory');
+ assertDetail('Source Control Branch', 'Foo branch');
+ assertDetail('Limit', 'alpha:beta');
+ assertDetail('Verbosity', '3 (Debug)');
+ assertDetail('Show Changes', 'Off');
+ expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
+ '---one: two\nbar: baz'
+ );
+ expect(
+ wrapper
+ .find('Detail[label="Credentials"]')
+ .containsAllMatchingElements([
+
+ SSH:Credential 1
+ ,
+
+ Awx:Credential 2
+ ,
+ ])
+ ).toEqual(true);
+ expect(
+ wrapper
+ .find('Detail[label="Job Tags"]')
+ .containsAnyMatchingElements([T_100, T_200])
+ ).toEqual(true);
+ expect(
+ wrapper
+ .find('Detail[label="Skip Tags"]')
+ .containsAllMatchingElements([S_100, S_200])
+ ).toEqual(true);
+ });
+ });
});
diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js
index e46021a5b6..52e07d53cb 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -306,3 +306,21 @@ export const Schedule = shape({
timezone: string,
until: string,
});
+
+export const SurveyQuestion = shape({
+ question_name: string,
+ question_description: string,
+ required: bool,
+ type: string,
+ variable: string,
+ min: number,
+ max: number,
+ default: string,
+ choices: string,
+});
+
+export const Survey = shape({
+ name: string,
+ description: string,
+ spec: arrayOf(SurveyQuestion),
+});