diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx index 91818e84c7..336a3fa996 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx @@ -1,42 +1,64 @@ import React, { useState } from 'react'; import { string, bool } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; import { useField } from 'formik'; +import styled from 'styled-components'; import { Split, SplitItem } from '@patternfly/react-core'; +import { CheckboxField } from '@components/FormField'; import { yamlToJson, jsonToYaml, isJson } from '@util/yaml'; import CodeMirrorInput from './CodeMirrorInput'; import YamlJsonToggle from './YamlJsonToggle'; import { JSON_MODE, YAML_MODE } from './constants'; -function VariablesField({ id, name, label, readOnly }) { +const FieldHeader = styled.div` + display: flex; + justify-content: space-between; +`; + +const StyledCheckboxField = styled(CheckboxField)` + --pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize); +`; + +function VariablesField({ i18n, id, name, label, readOnly, promptId }) { const [field, meta, helpers] = useField(name); const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE); return ( - <> - - - - - - { - try { - const newVal = - newMode === YAML_MODE - ? jsonToYaml(field.value) - : yamlToJson(field.value); - helpers.setValue(newVal); - setMode(newMode); - } catch (err) { - helpers.setError(err.message); - } - }} +
+ + + + + + + { + try { + const newVal = + newMode === YAML_MODE + ? jsonToYaml(field.value) + : yamlToJson(field.value); + helpers.setValue(newVal); + setMode(newMode); + } catch (err) { + helpers.setError(err.message); + } + }} + /> + + + {promptId && ( + - - + )} + ) : null} - +
); } VariablesField.propTypes = { @@ -59,9 +81,11 @@ VariablesField.propTypes = { name: string.isRequired, label: string.isRequired, readOnly: bool, + promptId: string, }; VariablesField.defaultProps = { readOnly: false, + promptId: null, }; -export default VariablesField; +export default withI18n()(VariablesField); diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 938ab80273..c80b0040fe 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -1,13 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { string, func, bool } from 'prop-types'; +import { func, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup } from '@patternfly/react-core'; import { InventoriesAPI } from '@api'; import { Inventory } from '@types'; import Lookup from '@components/Lookup'; -import { FieldTooltip } from '@components/FormField'; import { getQSConfig, parseQueryString } from '@util/qs'; import OptionsList from './shared/OptionsList'; import LookupErrorMessage from './shared/LookupErrorMessage'; @@ -18,17 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', { order_by: 'name', }); -function InventoryLookup({ - value, - tooltip, - onChange, - onBlur, - required, - isValid, - helperTextInvalid, - i18n, - history, -}) { +function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { const [inventories, setInventories] = useState([]); const [count, setCount] = useState(0); const [error, setError] = useState(null); @@ -47,14 +35,7 @@ function InventoryLookup({ }, [history.location]); return ( - - {tooltip && } + <> - + ); } InventoryLookup.propTypes = { value: Inventory, - tooltip: string, onChange: func.isRequired, required: bool, }; InventoryLookup.defaultProps = { value: null, - tooltip: '', required: false, }; diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index cf05f48e89..9a24316c74 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -3,10 +3,9 @@ import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup, ToolbarItem } from '@patternfly/react-core'; +import { ToolbarItem } from '@patternfly/react-core'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; import AnsibleSelect from '@components/AnsibleSelect'; -import { FieldTooltip } from '@components/FormField'; import CredentialChip from '@components/CredentialChip'; import { getQSConfig, parseQueryString } from '@util/qs'; import Lookup from './Lookup'; @@ -31,7 +30,7 @@ async function loadCredentials(params, selectedCredentialTypeId) { } function MultiCredentialsLookup(props) { - const { tooltip, value, onChange, onError, history, i18n } = props; + const { value, onChange, onError, history, i18n } = props; const [credentialTypes, setCredentialTypes] = useState([]); const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); @@ -81,99 +80,95 @@ function MultiCredentialsLookup(props) { const isMultiple = selectedType && selectedType.kind === 'vault'; return ( - - {tooltip && } - { - return ( - - {credentialTypes && credentialTypes.length > 0 && ( - -
- {i18n._(t`Selected Category`)} -
- ({ - key: type.id, - value: type.id, - label: type.name, - isDisabled: false, - }))} - value={selectedType && selectedType.id} - onChange={(e, id) => { - setSelectedType( - credentialTypes.find(o => o.id === parseInt(id, 10)) - ); - }} - /> -
- )} - { - if (isMultiple) { - return dispatch({ type: 'SELECT_ITEM', item }); - } - const selectedItems = state.selectedItems.filter( - i => i.kind !== item.kind - ); - selectedItems.push(item); - return dispatch({ - type: 'SET_SELECTED_ITEMS', - selectedItems, - }); - }} - deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} - renderItemChip={renderChip} - /> -
- ); - }} - /> -
+ { + return ( + + {credentialTypes && credentialTypes.length > 0 && ( + +
+ {i18n._(t`Selected Category`)} +
+ ({ + key: type.id, + value: type.id, + label: type.name, + isDisabled: false, + }))} + value={selectedType && selectedType.id} + onChange={(e, id) => { + setSelectedType( + credentialTypes.find(o => o.id === parseInt(id, 10)) + ); + }} + /> +
+ )} + { + if (isMultiple) { + return dispatch({ type: 'SELECT_ITEM', item }); + } + const selectedItems = state.selectedItems.filter( + i => i.kind !== item.kind + ); + selectedItems.push(item); + return dispatch({ + type: 'SET_SELECTED_ITEMS', + selectedItems, + }); + }} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + renderItemChip={renderChip} + /> +
+ ); + }} + /> ); } MultiCredentialsLookup.propTypes = { - tooltip: PropTypes.string, value: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.number, @@ -188,7 +183,6 @@ MultiCredentialsLookup.propTypes = { }; MultiCredentialsLookup.defaultProps = { - tooltip: '', value: [], }; diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx index af5fb0a2ec..7df23e70c8 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx +++ b/awx/ui_next/src/screens/Host/shared/HostForm.jsx @@ -6,9 +6,12 @@ import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Form } from '@patternfly/react-core'; +import { Form, FormGroup } from '@patternfly/react-core'; -import FormField, { FormSubmitError } from '@components/FormField'; +import FormField, { + FormSubmitError, + FieldTooltip, +} from '@components/FormField'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import { VariablesField } from '@components/CodeMirrorInput'; import { required } from '@util/validators'; @@ -23,7 +26,7 @@ function HostFormFields({ host, i18n }) { const hostAddMatch = useRouteMatch('/hosts/add'); const inventoryFieldArr = useField({ name: 'inventory', - validate: required(i18n._(t`Select aƄ value for this field`), i18n), + validate: required(i18n._(t`Select a value for this field`), i18n), }); const inventoryMeta = inventoryFieldArr[1]; const inventoryHelpers = inventoryFieldArr[2]; @@ -45,22 +48,35 @@ function HostFormFields({ host, i18n }) { label={i18n._(t`Description`)} /> {hostAddMatch && ( - inventoryHelpers.setTouched()} - tooltip={i18n._( - t`Select the inventory that this host will belong to.` - )} + { - inventoryHelpers.setValuealue(value.id); - setInventory(value); - }} - required - touched={inventoryMeta.touched} - error={inventoryMeta.error} - /> + > + + inventoryHelpers.setTouched()} + tooltip={i18n._( + t`Select the inventory that this host will belong to.` + )} + isValid={!inventoryMeta.touched || !inventoryMeta.error} + helperTextInvalid={inventoryMeta.error} + onChange={value => { + inventoryHelpers.setValue(value.id); + setInventory(value); + }} + required + touched={inventoryMeta.touched} + error={inventoryMeta.error} + /> + )} ', () => { await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); act(() => { wrapper.find('input#template-name').simulate('change', { - target: { value: 'Foo', name: 'name' }, - }); - wrapper.find('AnsibleSelect#template-job-type').invoke('onChange')('run'); - wrapper.find('InventoryLookup').invoke('onChange')({ - id: 1, - organization: 1, + target: { value: 'Bar', name: 'name' }, }); + wrapper.find('AnsibleSelect#template-job-type').prop('onChange')( + null, + 'check' + ); wrapper.find('ProjectLookup').invoke('onChange')({ id: 2, name: 'project', @@ -119,18 +129,28 @@ describe('', () => { .find('PlaybookSelect') .prop('field') .onChange({ - target: { value: 'Bar', name: 'playbook' }, + target: { value: 'Baz', name: 'playbook' }, }); }); wrapper.update(); + act(() => { + wrapper.find('InventoryLookup').invoke('onChange')({ + id: 2, + organization: 1, + }); + }); + wrapper.update(); await act(async () => { wrapper.find('form').simulate('submit'); }); wrapper.update(); expect(JobTemplatesAPI.create).toHaveBeenCalledWith({ ...jobTemplateData, - description: '', - become_enabled: false, + name: 'Bar', + job_type: 'check', + project: 2, + playbook: 'Baz', + inventory: 2, }); }); @@ -154,11 +174,10 @@ describe('', () => { wrapper.find('input#template-name').simulate('change', { target: { value: 'Foo', name: 'name' }, }); - wrapper.find('AnsibleSelect#template-job-type').invoke('onChange')('run'); - wrapper.find('InventoryLookup').invoke('onChange')({ - id: 1, - organization: 1, - }); + wrapper.find('AnsibleSelect#template-job-type').prop('onChange')( + null, + 'check' + ); wrapper.find('ProjectLookup').invoke('onChange')({ id: 2, name: 'project', @@ -172,6 +191,13 @@ describe('', () => { }); }); wrapper.update(); + act(() => { + wrapper.find('InventoryLookup').invoke('onChange')({ + id: 1, + organization: 1, + }); + }); + wrapper.update(); await act(async () => { wrapper.find('form').simulate('submit'); }); diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx index 999af636e2..0b8a118f4a 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx @@ -22,6 +22,7 @@ import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; import DeleteButton from '@components/DeleteButton'; import ErrorDetail from '@components/ErrorDetail'; import LaunchButton from '@components/LaunchButton'; +import { VariablesDetail } from '@components/CodeMirrorInput'; import { JobTemplatesAPI } from '@api'; const MissingDetail = styled(Detail)` @@ -38,6 +39,7 @@ function JobTemplateDetail({ i18n, template }) { created, description, diff_mode, + extra_vars, forks, host_config_key, job_slice_count, @@ -302,6 +304,11 @@ function JobTemplateDetail({ i18n, template }) { } /> )} + {summary_fields.user_capabilities && diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx index 8798249169..92c47ecf05 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -11,9 +11,20 @@ jest.mock('@api'); const mockJobTemplate = { allow_callbacks: false, allow_simultaneous: false, + ask_scm_branch_on_launch: false, + ask_diff_mode_on_launch: false, + ask_variables_on_launch: false, + ask_limit_on_launch: false, + ask_tags_on_launch: false, + ask_skip_tags_on_launch: false, ask_job_type_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + become_enabled: false, description: 'Bar', diff_mode: false, + extra_vars: '---', forks: 0, host_config_key: '', id: 1, @@ -192,6 +203,7 @@ describe('', () => { ); }); const updatedTemplateData = { + job_type: 'check', name: 'new name', inventory: 1, }; @@ -206,14 +218,18 @@ describe('', () => { wrapper.find('input#template-name').simulate('change', { target: { value: 'new name', name: 'name' }, }); - wrapper.find('AnsibleSelect#template-job-type').invoke('onChange')( + wrapper.find('AnsibleSelect#template-job-type').prop('onChange')( + null, 'check' ); + wrapper.find('LabelSelect').invoke('onChange')(labels); + }); + wrapper.update(); + act(() => { wrapper.find('InventoryLookup').invoke('onChange')({ id: 1, organization: 1, }); - wrapper.find('LabelSelect').invoke('onChange')(labels); }); wrapper.update(); await act(async () => { @@ -224,7 +240,6 @@ describe('', () => { const expected = { ...mockJobTemplate, ...updatedTemplateData, - become_enabled: false, }; delete expected.summary_fields; delete expected.id; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 82ceeb3313..f9f68507cb 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -27,7 +27,7 @@ import { FormFullWidthLayout, FormCheckboxLayout, } from '@components/FormLayout'; -import CollapsibleSection from '@components/CollapsibleSection'; +import { VariablesField } from '@components/CodeMirrorInput'; import { required } from '@util/validators'; import { JobTemplate } from '@types'; import { @@ -202,8 +202,6 @@ class JobTemplateForm extends Component { return ; } - const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div'; - return (
@@ -245,34 +243,57 @@ class JobTemplateForm extends Component { id="template-job-type" data={jobTypeOptions} {...field} + onChange={(event, value) => { + form.setFieldValue('job_type', value); + }} /> ); }} - - {({ form }) => ( - form.setFieldTouched('inventory')} - tooltip={i18n._(t`Select the inventory containing the hosts - you want this job to manage.`)} - isValid={!form.touched.inventory || !form.errors.inventory} - helperTextInvalid={form.errors.inventory} - onChange={value => { - form.setFieldValue('inventory', value.id); - form.setFieldValue('organizationId', value.organization); - this.setState({ inventory: value }); - }} - required - touched={form.touched.inventory} - error={form.errors.inventory} - /> - )} - + + {({ form }) => ( + <> + { + form.setFieldTouched('inventory'); + }} + onChange={value => { + form.setValues({ + ...form.values, + inventory: value.id, + organizationId: value.organization, + }); + this.setState({ inventory: value }); + }} + required + touched={form.touched.inventory} + error={form.errors.inventory} + /> + {(form.touched.inventory || + form.touched.ask_inventory_on_launch) && + form.errors.inventory && ( +
+ {form.errors.inventory} +
+ )} + + )} +
+ {({ form }) => ( {project && project.allow_override && ( - + promptId="template-ask-scm-branch-on-launch" + promptName="ask_scm_branch_on_launch" + > + + {({ field }) => { + return ( + { + field.onChange(event); + }} + /> + ); + }} + + )} + + + {({ field }) => { + return ( + + setFieldValue('credentials', newCredentials) + } + onError={this.setContentError} + /> + ); + }} + + {({ field }) => ( @@ -344,243 +402,248 @@ class JobTemplateForm extends Component { )} - - {({ field }) => ( - - setFieldValue('credentials', newCredentials) - } - onError={this.setContentError} - tooltip={i18n._( - t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.` - )} - /> - )} - - - - - {i18n._(t`The number of parallel or simultaneous - processes to use while executing the playbook. An empty value, - or a value less than 1 will use the Ansible default which is - usually 5. The default number of forks can be overwritten - with a change to`)}{' '} - ansible.cfg.{' '} - {i18n._(t`Refer to the Ansible documentation for details - about the configuration file.`)} - - } - /> - + + + + {i18n._(t`The number of parallel or simultaneous + processes to use while executing the playbook. An empty value, + or a value less than 1 will use the Ansible default which is + usually 5. The default number of forks can be overwritten + with a change to`)}{' '} + ansible.cfg.{' '} + {i18n._(t`Refer to the Ansible documentation for details + about the configuration file.`)} + + } + /> + + + {({ form, field }) => { + return ( + { + field.onChange(event); + }} + /> + ); + }} + + + {({ field }) => ( - - - - + )} - - + + + + - {({ field, form }) => ( - - { + return ( + + form.setFieldValue(field.name, checked) + } /> -
- - form.setFieldValue(field.name, checked) - } - /> -
-
+ ); + }} +
+
+ + + {({ field, form }) => ( + form.setFieldValue(field.name, value)} + tooltip={i18n._(t`Select the Instance Groups for this Organization + to run on.`)} + /> )} - - + + {({ field, form }) => ( - form.setFieldValue(field.name, value) } - tooltip={i18n._(t`Select the Instance Groups for this Organization - to run on.`)} /> )} - - {({ field, form }) => ( - - - - form.setFieldValue(field.name, value) - } - /> - - )} - + + {({ field, form }) => ( - - - - form.setFieldValue(field.name, value) - } - /> - + + form.setFieldValue(field.name, value) + } + /> )} - - - - - {i18n._(t`Provisioning Callbacks`)} -   - - - } - id="option-callbacks" - isChecked={allowCallbacks} - onChange={checked => { - this.setState({ allowCallbacks: checked }); - }} - /> - - - - - - {allowCallbacks && ( - <> - {callbackUrl && ( - - - - )} - + + + - - )} -
-
+ + {i18n._(t`Provisioning Callbacks`)} +   + + + } + id="option-callbacks" + isChecked={allowCallbacks} + onChange={checked => { + this.setState({ allowCallbacks: checked }); + }} + /> + + + + +
+ {allowCallbacks && ( + <> + {callbackUrl && ( + + + + )} + + + )} +
@@ -603,7 +666,16 @@ const FormikApp = withFormik({ ? summary_fields.inventory.organization_id : null; return { + ask_credential_on_launch: template.ask_credential_on_launch || false, + ask_diff_mode_on_launch: template.ask_diff_mode_on_launch || false, + ask_inventory_on_launch: template.ask_inventory_on_launch || false, ask_job_type_on_launch: template.ask_job_type_on_launch || false, + ask_limit_on_launch: template.ask_limit_on_launch || false, + ask_scm_branch_on_launch: template.ask_scm_branch_on_launch || false, + ask_skip_tags_on_launch: template.ask_skip_tags_on_launch || false, + ask_tags_on_launch: template.ask_tags_on_launch || false, + ask_variables_on_launch: template.ask_variables_on_launch || false, + ask_verbosity_on_launch: template.ask_verbosity_on_launch || false, name: template.name || '', description: template.description || '', job_type: template.job_type || 'run', @@ -629,6 +701,7 @@ const FormikApp = withFormik({ initialInstanceGroups: [], instanceGroups: [], credentials: summary_fields.credentials || [], + extra_vars: template.extra_vars || '---\n', }; }, handleSubmit: async (values, { props, setErrors }) => { @@ -638,6 +711,20 @@ const FormikApp = withFormik({ setErrors(errors); } }, + validate: (values, { i18n }) => { + const errors = {}; + + if ( + (!values.inventory || values.inventory === '') && + !values.ask_inventory_on_launch + ) { + errors.inventory = i18n._( + t`Please select an Inventory or check the Prompt on Launch option.` + ); + } + + return errors; + }, })(JobTemplateForm); export { JobTemplateForm as _JobTemplateForm }; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx index 95a02c7edb..338af6f714 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx @@ -140,13 +140,10 @@ describe('', () => { wrapper.find('input#template-description').simulate('change', { target: { value: 'new bar', name: 'description' }, }); - wrapper.find('AnsibleSelect[name="job_type"]').simulate('change', { - target: { value: 'new job type', name: 'job_type' }, - }); - wrapper.find('InventoryLookup').invoke('onChange')({ - id: 3, - name: 'inventory', - }); + wrapper.find('AnsibleSelect#template-job-type').prop('onChange')( + null, + 'check' + ); wrapper.find('ProjectLookup').invoke('onChange')({ id: 4, name: 'project', @@ -155,7 +152,14 @@ describe('', () => { }); wrapper.update(); await act(async () => { - wrapper.find('input#scm_branch').simulate('change', { + wrapper.find('InventoryLookup').invoke('onChange')({ + id: 3, + name: 'inventory', + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('input#template-scm-branch').simulate('change', { target: { value: 'devel', name: 'scm_branch' }, }); wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', { @@ -179,7 +183,7 @@ describe('', () => { ); expect( wrapper.find('AnsibleSelect[name="job_type"]').prop('value') - ).toEqual('new job type'); + ).toEqual('check'); expect(wrapper.find('InventoryLookup').prop('value')).toEqual({ id: 3, name: 'inventory', @@ -189,7 +193,9 @@ describe('', () => { name: 'project', allow_override: true, }); - expect(wrapper.find('input#scm_branch').prop('value')).toEqual('devel'); + expect(wrapper.find('input#template-scm-branch').prop('value')).toEqual( + 'devel' + ); expect( wrapper.find('AnsibleSelect[name="playbook"]').prop('value') ).toEqual('new baz type');