Merge pull request #6001 from mabashian/4967-jt-prompt-on-launch

Adds prompt on launch support to the rest of the relevant jt fields

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-02-27 09:13:29 +00:00
committed by GitHub
9 changed files with 593 additions and 439 deletions

View File

@@ -1,42 +1,64 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { string, bool } from 'prop-types'; import { string, bool } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik'; import { useField } from 'formik';
import styled from 'styled-components';
import { Split, SplitItem } from '@patternfly/react-core'; import { Split, SplitItem } from '@patternfly/react-core';
import { CheckboxField } from '@components/FormField';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml'; import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
import CodeMirrorInput from './CodeMirrorInput'; import CodeMirrorInput from './CodeMirrorInput';
import YamlJsonToggle from './YamlJsonToggle'; import YamlJsonToggle from './YamlJsonToggle';
import { JSON_MODE, YAML_MODE } from './constants'; 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 [field, meta, helpers] = useField(name);
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE); const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
return ( return (
<> <div className="pf-c-form__group">
<Split gutter="sm"> <FieldHeader>
<SplitItem> <Split gutter="sm">
<label htmlFor={id} className="pf-c-form__label"> <SplitItem>
<span className="pf-c-form__label-text">{label}</span> <label htmlFor={id} className="pf-c-form__label">
</label> <span className="pf-c-form__label-text">{label}</span>
</SplitItem> </label>
<SplitItem> </SplitItem>
<YamlJsonToggle <SplitItem>
mode={mode} <YamlJsonToggle
onChange={newMode => { mode={mode}
try { onChange={newMode => {
const newVal = try {
newMode === YAML_MODE const newVal =
? jsonToYaml(field.value) newMode === YAML_MODE
: yamlToJson(field.value); ? jsonToYaml(field.value)
helpers.setValue(newVal); : yamlToJson(field.value);
setMode(newMode); helpers.setValue(newVal);
} catch (err) { setMode(newMode);
helpers.setError(err.message); } catch (err) {
} helpers.setError(err.message);
}} }
}}
/>
</SplitItem>
</Split>
{promptId && (
<StyledCheckboxField
id="template-ask-variables-on-launch"
label={i18n._(t`Prompt On Launch`)}
name="ask_variables_on_launch"
/> />
</SplitItem> )}
</Split> </FieldHeader>
<CodeMirrorInput <CodeMirrorInput
mode={mode} mode={mode}
readOnly={readOnly} readOnly={readOnly}
@@ -51,7 +73,7 @@ function VariablesField({ id, name, label, readOnly }) {
{meta.error} {meta.error}
</div> </div>
) : null} ) : null}
</> </div>
); );
} }
VariablesField.propTypes = { VariablesField.propTypes = {
@@ -59,9 +81,11 @@ VariablesField.propTypes = {
name: string.isRequired, name: string.isRequired,
label: string.isRequired, label: string.isRequired,
readOnly: bool, readOnly: bool,
promptId: string,
}; };
VariablesField.defaultProps = { VariablesField.defaultProps = {
readOnly: false, readOnly: false,
promptId: null,
}; };
export default VariablesField; export default withI18n()(VariablesField);

View File

@@ -1,13 +1,11 @@
import React, { useState, useEffect } from 'react'; 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 { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import { InventoriesAPI } from '@api'; import { InventoriesAPI } from '@api';
import { Inventory } from '@types'; import { Inventory } from '@types';
import Lookup from '@components/Lookup'; import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField';
import { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
import OptionsList from './shared/OptionsList'; import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
@@ -18,17 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', {
order_by: 'name', order_by: 'name',
}); });
function InventoryLookup({ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
value,
tooltip,
onChange,
onBlur,
required,
isValid,
helperTextInvalid,
i18n,
history,
}) {
const [inventories, setInventories] = useState([]); const [inventories, setInventories] = useState([]);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -47,14 +35,7 @@ function InventoryLookup({
}, [history.location]); }, [history.location]);
return ( return (
<FormGroup <>
label={i18n._(t`Inventory`)}
isRequired={required}
fieldId="inventory-lookup"
isValid={isValid}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup <Lookup
id="inventory-lookup" id="inventory-lookup"
header={i18n._(t`Inventory`)} header={i18n._(t`Inventory`)}
@@ -100,20 +81,18 @@ function InventoryLookup({
)} )}
/> />
<LookupErrorMessage error={error} /> <LookupErrorMessage error={error} />
</FormGroup> </>
); );
} }
InventoryLookup.propTypes = { InventoryLookup.propTypes = {
value: Inventory, value: Inventory,
tooltip: string,
onChange: func.isRequired, onChange: func.isRequired,
required: bool, required: bool,
}; };
InventoryLookup.defaultProps = { InventoryLookup.defaultProps = {
value: null, value: null,
tooltip: '',
required: false, required: false,
}; };

View File

@@ -3,10 +3,9 @@ import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup, ToolbarItem } from '@patternfly/react-core'; import { ToolbarItem } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from '@api'; import { CredentialsAPI, CredentialTypesAPI } from '@api';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import { FieldTooltip } from '@components/FormField';
import CredentialChip from '@components/CredentialChip'; import CredentialChip from '@components/CredentialChip';
import { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup'; import Lookup from './Lookup';
@@ -31,7 +30,7 @@ async function loadCredentials(params, selectedCredentialTypeId) {
} }
function MultiCredentialsLookup(props) { function MultiCredentialsLookup(props) {
const { tooltip, value, onChange, onError, history, i18n } = props; const { value, onChange, onError, history, i18n } = props;
const [credentialTypes, setCredentialTypes] = useState([]); const [credentialTypes, setCredentialTypes] = useState([]);
const [selectedType, setSelectedType] = useState(null); const [selectedType, setSelectedType] = useState(null);
const [credentials, setCredentials] = useState([]); const [credentials, setCredentials] = useState([]);
@@ -81,99 +80,95 @@ function MultiCredentialsLookup(props) {
const isMultiple = selectedType && selectedType.kind === 'vault'; const isMultiple = selectedType && selectedType.kind === 'vault';
return ( return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential"> <Lookup
{tooltip && <FieldTooltip content={tooltip} />} id="multiCredential"
<Lookup header={i18n._(t`Credentials`)}
id="multiCredential" value={value}
header={i18n._(t`Credentials`)} multiple
value={value} onChange={onChange}
multiple qsConfig={QS_CONFIG}
onChange={onChange} renderItemChip={renderChip}
qsConfig={QS_CONFIG} renderOptionsList={({ state, dispatch, canDelete }) => {
renderItemChip={renderChip} return (
renderOptionsList={({ state, dispatch, canDelete }) => { <Fragment>
return ( {credentialTypes && credentialTypes.length > 0 && (
<Fragment> <ToolbarItem css=" display: flex; align-items: center;">
{credentialTypes && credentialTypes.length > 0 && ( <div css="flex: 0 0 25%; margin-right: 32px">
<ToolbarItem css=" display: flex; align-items: center;"> {i18n._(t`Selected Category`)}
<div css="flex: 0 0 25%; margin-right: 32px"> </div>
{i18n._(t`Selected Category`)} <AnsibleSelect
</div> css="flex: 1 1 75%;"
<AnsibleSelect id="multiCredentialsLookUp-select"
css="flex: 1 1 75%;" label={i18n._(t`Selected Category`)}
id="multiCredentialsLookUp-select" data={credentialTypes.map(type => ({
label={i18n._(t`Selected Category`)} key: type.id,
data={credentialTypes.map(type => ({ value: type.id,
key: type.id, label: type.name,
value: type.id, isDisabled: false,
label: type.name, }))}
isDisabled: false, value={selectedType && selectedType.id}
}))} onChange={(e, id) => {
value={selectedType && selectedType.id} setSelectedType(
onChange={(e, id) => { credentialTypes.find(o => o.id === parseInt(id, 10))
setSelectedType( );
credentialTypes.find(o => o.id === parseInt(id, 10)) }}
); />
}} </ToolbarItem>
/> )}
</ToolbarItem> <OptionsList
)} value={state.selectedItems}
<OptionsList options={credentials}
value={state.selectedItems} optionCount={credentialsCount}
options={credentials} searchColumns={[
optionCount={credentialsCount} {
searchColumns={[ name: i18n._(t`Name`),
{ key: 'name',
name: i18n._(t`Name`), isDefault: true,
key: 'name', },
isDefault: true, {
}, name: i18n._(t`Created By (Username)`),
{ key: 'created_by__username',
name: i18n._(t`Created By (Username)`), },
key: 'created_by__username', {
}, name: i18n._(t`Modified By (Username)`),
{ key: 'modified_by__username',
name: i18n._(t`Modified By (Username)`), },
key: 'modified_by__username', ]}
}, sortColumns={[
]} {
sortColumns={[ name: i18n._(t`Name`),
{ key: 'name',
name: i18n._(t`Name`), },
key: 'name', ]}
}, multiple={isMultiple}
]} header={i18n._(t`Credentials`)}
multiple={isMultiple} name="credentials"
header={i18n._(t`Credentials`)} qsConfig={QS_CONFIG}
name="credentials" readOnly={!canDelete}
qsConfig={QS_CONFIG} selectItem={item => {
readOnly={!canDelete} if (isMultiple) {
selectItem={item => { return dispatch({ type: 'SELECT_ITEM', item });
if (isMultiple) { }
return dispatch({ type: 'SELECT_ITEM', item }); const selectedItems = state.selectedItems.filter(
} i => i.kind !== item.kind
const selectedItems = state.selectedItems.filter( );
i => i.kind !== item.kind selectedItems.push(item);
); return dispatch({
selectedItems.push(item); type: 'SET_SELECTED_ITEMS',
return dispatch({ selectedItems,
type: 'SET_SELECTED_ITEMS', });
selectedItems, }}
}); deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
}} renderItemChip={renderChip}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} />
renderItemChip={renderChip} </Fragment>
/> );
</Fragment> }}
); />
}}
/>
</FormGroup>
); );
} }
MultiCredentialsLookup.propTypes = { MultiCredentialsLookup.propTypes = {
tooltip: PropTypes.string,
value: PropTypes.arrayOf( value: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
@@ -188,7 +183,6 @@ MultiCredentialsLookup.propTypes = {
}; };
MultiCredentialsLookup.defaultProps = { MultiCredentialsLookup.defaultProps = {
tooltip: '',
value: [], value: [],
}; };

View File

@@ -6,9 +6,12 @@ import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; 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 FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput'; import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators'; import { required } from '@util/validators';
@@ -23,7 +26,7 @@ function HostFormFields({ host, i18n }) {
const hostAddMatch = useRouteMatch('/hosts/add'); const hostAddMatch = useRouteMatch('/hosts/add');
const inventoryFieldArr = useField({ const inventoryFieldArr = useField({
name: 'inventory', 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 inventoryMeta = inventoryFieldArr[1];
const inventoryHelpers = inventoryFieldArr[2]; const inventoryHelpers = inventoryFieldArr[2];
@@ -45,22 +48,35 @@ function HostFormFields({ host, i18n }) {
label={i18n._(t`Description`)} label={i18n._(t`Description`)}
/> />
{hostAddMatch && ( {hostAddMatch && (
<InventoryLookup <FormGroup
value={inventory} label={i18n._(t`Inventory`)}
onBlur={() => inventoryHelpers.setTouched()} isRequired
tooltip={i18n._( fieldId="inventory-lookup"
t`Select the inventory that this host will belong to.`
)}
isValid={!inventoryMeta.touched || !inventoryMeta.error} isValid={!inventoryMeta.touched || !inventoryMeta.error}
helperTextInvalid={inventoryMeta.error} helperTextInvalid={inventoryMeta.error}
onChange={value => { >
inventoryHelpers.setValuealue(value.id); <FieldTooltip
setInventory(value); content={i18n._(
}} t`Select the inventory that this host will belong to.`
required )}
touched={inventoryMeta.touched} />
error={inventoryMeta.error} <InventoryLookup
/> value={inventory}
onBlur={() => 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}
/>
</FormGroup>
)} )}
<FormFullWidthLayout> <FormFullWidthLayout>
<VariablesField <VariablesField

View File

@@ -10,9 +10,20 @@ jest.mock('@api');
const jobTemplateData = { const jobTemplateData = {
allow_callbacks: false, allow_callbacks: false,
allow_simultaneous: false, allow_simultaneous: false,
ask_credential_on_launch: false,
ask_diff_mode_on_launch: false,
ask_inventory_on_launch: false,
ask_job_type_on_launch: false, ask_job_type_on_launch: false,
description: 'Baz', ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_skip_tags_on_launch: false,
ask_tags_on_launch: false,
ask_variables_on_launch: false,
ask_verbosity_on_launch: false,
become_enabled: false,
description: '',
diff_mode: false, diff_mode: false,
extra_vars: '---\n',
forks: 0, forks: 0,
host_config_key: '', host_config_key: '',
inventory: 1, inventory: 1,
@@ -20,9 +31,9 @@ const jobTemplateData = {
job_tags: '', job_tags: '',
job_type: 'run', job_type: 'run',
limit: '', limit: '',
name: 'Foo', name: '',
playbook: 'Bar', playbook: '',
project: 2, project: 1,
scm_branch: '', scm_branch: '',
skip_tags: '', skip_tags: '',
timeout: 0, timeout: 0,
@@ -103,13 +114,12 @@ describe('<JobTemplateAdd />', () => {
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
act(() => { act(() => {
wrapper.find('input#template-name').simulate('change', { wrapper.find('input#template-name').simulate('change', {
target: { value: 'Foo', name: 'name' }, target: { value: 'Bar', 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')({ wrapper.find('ProjectLookup').invoke('onChange')({
id: 2, id: 2,
name: 'project', name: 'project',
@@ -119,18 +129,28 @@ describe('<JobTemplateAdd />', () => {
.find('PlaybookSelect') .find('PlaybookSelect')
.prop('field') .prop('field')
.onChange({ .onChange({
target: { value: 'Bar', name: 'playbook' }, target: { value: 'Baz', name: 'playbook' },
}); });
}); });
wrapper.update(); wrapper.update();
act(() => {
wrapper.find('InventoryLookup').invoke('onChange')({
id: 2,
organization: 1,
});
});
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('form').simulate('submit'); wrapper.find('form').simulate('submit');
}); });
wrapper.update(); wrapper.update();
expect(JobTemplatesAPI.create).toHaveBeenCalledWith({ expect(JobTemplatesAPI.create).toHaveBeenCalledWith({
...jobTemplateData, ...jobTemplateData,
description: '', name: 'Bar',
become_enabled: false, job_type: 'check',
project: 2,
playbook: 'Baz',
inventory: 2,
}); });
}); });
@@ -154,11 +174,10 @@ describe('<JobTemplateAdd />', () => {
wrapper.find('input#template-name').simulate('change', { wrapper.find('input#template-name').simulate('change', {
target: { value: 'Foo', name: 'name' }, target: { value: 'Foo', name: 'name' },
}); });
wrapper.find('AnsibleSelect#template-job-type').invoke('onChange')('run'); wrapper.find('AnsibleSelect#template-job-type').prop('onChange')(
wrapper.find('InventoryLookup').invoke('onChange')({ null,
id: 1, 'check'
organization: 1, );
});
wrapper.find('ProjectLookup').invoke('onChange')({ wrapper.find('ProjectLookup').invoke('onChange')({
id: 2, id: 2,
name: 'project', name: 'project',
@@ -172,6 +191,13 @@ describe('<JobTemplateAdd />', () => {
}); });
}); });
wrapper.update(); wrapper.update();
act(() => {
wrapper.find('InventoryLookup').invoke('onChange')({
id: 1,
organization: 1,
});
});
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('form').simulate('submit'); wrapper.find('form').simulate('submit');
}); });

View File

@@ -22,6 +22,7 @@ import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import DeleteButton from '@components/DeleteButton'; import DeleteButton from '@components/DeleteButton';
import ErrorDetail from '@components/ErrorDetail'; import ErrorDetail from '@components/ErrorDetail';
import LaunchButton from '@components/LaunchButton'; import LaunchButton from '@components/LaunchButton';
import { VariablesDetail } from '@components/CodeMirrorInput';
import { JobTemplatesAPI } from '@api'; import { JobTemplatesAPI } from '@api';
const MissingDetail = styled(Detail)` const MissingDetail = styled(Detail)`
@@ -38,6 +39,7 @@ function JobTemplateDetail({ i18n, template }) {
created, created,
description, description,
diff_mode, diff_mode,
extra_vars,
forks, forks,
host_config_key, host_config_key,
job_slice_count, job_slice_count,
@@ -302,6 +304,11 @@ function JobTemplateDetail({ i18n, template }) {
} }
/> />
)} )}
<VariablesDetail
value={extra_vars}
rows={4}
label={i18n._(t`Variables`)}
/>
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{summary_fields.user_capabilities && {summary_fields.user_capabilities &&

View File

@@ -11,9 +11,20 @@ jest.mock('@api');
const mockJobTemplate = { const mockJobTemplate = {
allow_callbacks: false, allow_callbacks: false,
allow_simultaneous: 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_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', description: 'Bar',
diff_mode: false, diff_mode: false,
extra_vars: '---',
forks: 0, forks: 0,
host_config_key: '', host_config_key: '',
id: 1, id: 1,
@@ -192,6 +203,7 @@ describe('<JobTemplateEdit />', () => {
); );
}); });
const updatedTemplateData = { const updatedTemplateData = {
job_type: 'check',
name: 'new name', name: 'new name',
inventory: 1, inventory: 1,
}; };
@@ -206,14 +218,18 @@ describe('<JobTemplateEdit />', () => {
wrapper.find('input#template-name').simulate('change', { wrapper.find('input#template-name').simulate('change', {
target: { value: 'new name', name: 'name' }, target: { value: 'new name', name: 'name' },
}); });
wrapper.find('AnsibleSelect#template-job-type').invoke('onChange')( wrapper.find('AnsibleSelect#template-job-type').prop('onChange')(
null,
'check' 'check'
); );
wrapper.find('LabelSelect').invoke('onChange')(labels);
});
wrapper.update();
act(() => {
wrapper.find('InventoryLookup').invoke('onChange')({ wrapper.find('InventoryLookup').invoke('onChange')({
id: 1, id: 1,
organization: 1, organization: 1,
}); });
wrapper.find('LabelSelect').invoke('onChange')(labels);
}); });
wrapper.update(); wrapper.update();
await act(async () => { await act(async () => {
@@ -224,7 +240,6 @@ describe('<JobTemplateEdit />', () => {
const expected = { const expected = {
...mockJobTemplate, ...mockJobTemplate,
...updatedTemplateData, ...updatedTemplateData,
become_enabled: false,
}; };
delete expected.summary_fields; delete expected.summary_fields;
delete expected.id; delete expected.id;

View File

@@ -27,7 +27,7 @@ import {
FormFullWidthLayout, FormFullWidthLayout,
FormCheckboxLayout, FormCheckboxLayout,
} from '@components/FormLayout'; } from '@components/FormLayout';
import CollapsibleSection from '@components/CollapsibleSection'; import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators'; import { required } from '@util/validators';
import { JobTemplate } from '@types'; import { JobTemplate } from '@types';
import { import {
@@ -202,8 +202,6 @@ class JobTemplateForm extends Component {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
} }
const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
return ( return (
<Form autoComplete="off" onSubmit={handleSubmit}> <Form autoComplete="off" onSubmit={handleSubmit}>
<FormColumnLayout> <FormColumnLayout>
@@ -245,34 +243,57 @@ class JobTemplateForm extends Component {
id="template-job-type" id="template-job-type"
data={jobTypeOptions} data={jobTypeOptions}
{...field} {...field}
onChange={(event, value) => {
form.setFieldValue('job_type', value);
}}
/> />
); );
}} }}
</Field> </Field>
</FieldWithPrompt> </FieldWithPrompt>
<Field <FieldWithPrompt
name="inventory" fieldId="template-inventory"
validate={required(i18n._(t`Select a value for this field`), i18n)} isRequired
label={i18n._(t`Inventory`)}
promptId="template-ask-inventory-on-launch"
promptName="ask_inventory_on_launch"
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
> >
{({ form }) => ( <Field name="inventory">
<InventoryLookup {({ form }) => (
value={inventory} <>
onBlur={() => form.setFieldTouched('inventory')} <InventoryLookup
tooltip={i18n._(t`Select the inventory containing the hosts value={inventory}
you want this job to manage.`)} onBlur={() => {
isValid={!form.touched.inventory || !form.errors.inventory} form.setFieldTouched('inventory');
helperTextInvalid={form.errors.inventory} }}
onChange={value => { onChange={value => {
form.setFieldValue('inventory', value.id); form.setValues({
form.setFieldValue('organizationId', value.organization); ...form.values,
this.setState({ inventory: value }); inventory: value.id,
}} organizationId: value.organization,
required });
touched={form.touched.inventory} this.setState({ inventory: value });
error={form.errors.inventory} }}
/> required
)} touched={form.touched.inventory}
</Field> error={form.errors.inventory}
/>
{(form.touched.inventory ||
form.touched.ask_inventory_on_launch) &&
form.errors.inventory && (
<div
className="pf-c-form__helper-text pf-m-error"
aria-live="polite"
>
{form.errors.inventory}
</div>
)}
</>
)}
</Field>
</FieldWithPrompt>
<Field name="project" validate={this.handleProjectValidation()}> <Field name="project" validate={this.handleProjectValidation()}>
{({ form }) => ( {({ form }) => (
<ProjectLookup <ProjectLookup
@@ -288,12 +309,26 @@ class JobTemplateForm extends Component {
)} )}
</Field> </Field>
{project && project.allow_override && ( {project && project.allow_override && (
<FormField <FieldWithPrompt
id="scm_branch" fieldId="template-scm-branch"
name="scm_branch"
type="text"
label={i18n._(t`SCM Branch`)} label={i18n._(t`SCM Branch`)}
/> promptId="template-ask-scm-branch-on-launch"
promptName="ask_scm_branch_on_launch"
>
<Field name="scm_branch">
{({ field }) => {
return (
<TextInput
id="template-scm-branch"
{...field}
onChange={(value, event) => {
field.onChange(event);
}}
/>
);
}}
</Field>
</FieldWithPrompt>
)} )}
<Field <Field
name="playbook" name="playbook"
@@ -328,6 +363,29 @@ class JobTemplateForm extends Component {
}} }}
</Field> </Field>
<FormFullWidthLayout> <FormFullWidthLayout>
<FieldWithPrompt
fieldId="template-credentials"
label={i18n._(t`Credentials`)}
promptId="template-ask-credential-on-launch"
promptName="ask_credential_on_launch"
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.`
)}
>
<Field name="credentials" fieldId="template-credentials">
{({ field }) => {
return (
<MultiCredentialsLookup
value={field.value}
onChange={newCredentials =>
setFieldValue('credentials', newCredentials)
}
onError={this.setContentError}
/>
);
}}
</Field>
</FieldWithPrompt>
<Field name="labels"> <Field name="labels">
{({ field }) => ( {({ field }) => (
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels"> <FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
@@ -344,243 +402,248 @@ class JobTemplateForm extends Component {
</FormGroup> </FormGroup>
)} )}
</Field> </Field>
<Field name="credentials" fieldId="template-credentials"> <VariablesField
{({ field }) => ( id="template-variables"
<MultiCredentialsLookup name="extra_vars"
value={field.value} label={i18n._(t`Variables`)}
onChange={newCredentials => promptId="template-ask-variables-on-launch"
setFieldValue('credentials', newCredentials) />
} <FormColumnLayout>
onError={this.setContentError} <FormField
tooltip={i18n._( id="template-forks"
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.` name="forks"
)} type="number"
/> min="0"
)} label={i18n._(t`Forks`)}
</Field> tooltip={
<AdvancedFieldsWrapper label="Advanced"> <span>
<FormColumnLayout> {i18n._(t`The number of parallel or simultaneous
<FormField processes to use while executing the playbook. An empty value,
id="template-forks" or a value less than 1 will use the Ansible default which is
name="forks" usually 5. The default number of forks can be overwritten
type="number" with a change to`)}{' '}
min="0" <code>ansible.cfg</code>.{' '}
label={i18n._(t`Forks`)} {i18n._(t`Refer to the Ansible documentation for details
tooltip={ about the configuration file.`)}
<span> </span>
{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 <FieldWithPrompt
usually 5. The default number of forks can be overwritten fieldId="template-limit"
with a change to`)}{' '} label={i18n._(t`Limit`)}
<code>ansible.cfg</code>.{' '} promptId="template-ask-limit-on-launch"
{i18n._(t`Refer to the Ansible documentation for details promptName="ask_limit_on_launch"
about the configuration file.`)} tooltip={i18n._(t`Provide a host pattern to further constrain
</span> 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.`)}
<FormField >
id="template-limit" <Field name="limit">
name="limit" {({ form, field }) => {
type="text" return (
label={i18n._(t`Limit`)} <TextInput
tooltip={i18n._(t`Provide a host pattern to further constrain id="template-limit"
the list of hosts that will be managed or affected by the {...field}
playbook. Multiple patterns are allowed. Refer to Ansible isValid={
documentation for more information and examples on patterns.`)} !form.touched.job_type || !form.errors.job_type
/> }
onChange={(value, event) => {
field.onChange(event);
}}
/>
);
}}
</Field>
</FieldWithPrompt>
<FieldWithPrompt
fieldId="template-verbosity"
label={i18n._(t`Verbosity`)}
promptId="template-ask-verbosity-on-launch"
promptName="ask_verbosity_on_launch"
tooltip={i18n._(t`Control the level of output ansible will
produce as the playbook executes.`)}
>
<Field name="verbosity"> <Field name="verbosity">
{({ field }) => ( {({ field }) => (
<FormGroup <AnsibleSelect
fieldId="template-verbosity" id="template-verbosity"
label={i18n._(t`Verbosity`)} data={verbosityOptions}
> {...field}
<FieldTooltip />
content={i18n._(t`Control the level of output ansible will
produce as the playbook executes.`)}
/>
<AnsibleSelect
id="template-verbosity"
data={verbosityOptions}
{...field}
/>
</FormGroup>
)} )}
</Field> </Field>
<FormField </FieldWithPrompt>
id="template-job-slicing" <FormField
name="job_slice_count" id="template-job-slicing"
type="number" name="job_slice_count"
min="1" type="number"
label={i18n._(t`Job Slicing`)} min="1"
tooltip={i18n._(t`Divide the work done by this job template label={i18n._(t`Job Slicing`)}
into the specified number of job slices, each running the tooltip={i18n._(t`Divide the work done by this job template
same tasks against a portion of the inventory.`)} into the specified number of job slices, each running the
/> same tasks against a portion of the inventory.`)}
<FormField />
id="template-timeout" <FormField
name="timeout" id="template-timeout"
type="number" name="timeout"
min="0" type="number"
label={i18n._(t`Timeout`)} min="0"
tooltip={i18n._(t`The amount of time (in seconds) to run label={i18n._(t`Timeout`)}
before the task is canceled. Defaults to 0 for no job tooltip={i18n._(t`The amount of time (in seconds) to run
timeout.`)} before the task is canceled. Defaults to 0 for no job
/> timeout.`)}
/>
<FieldWithPrompt
fieldId="template-diff-mode"
label={i18n._(t`Show Changes`)}
promptId="template-ask-diff-mode-on-launch"
promptName="ask_diff_mode_on_launch"
tooltip={i18n._(t`If enabled, show the changes made by
Ansible tasks, where supported. This is equivalent
to Ansible&#x2019s --diff mode.`)}
>
<Field name="diff_mode"> <Field name="diff_mode">
{({ field, form }) => ( {({ form, field }) => {
<FormGroup return (
fieldId="template-show-changes" <Switch
label={i18n._(t`Show Changes`)} id="template-show-changes"
> label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
<FieldTooltip isChecked={field.value}
content={i18n._(t`If enabled, show the changes made by onChange={checked =>
Ansible tasks, where supported. This is equivalent form.setFieldValue(field.name, checked)
to Ansible&#x2019s --diff mode.`)} }
/> />
<div> );
<Switch }}
id="template-show-changes" </Field>
label={field.value ? i18n._(t`On`) : i18n._(t`Off`)} </FieldWithPrompt>
isChecked={field.value} <FormFullWidthLayout>
onChange={checked => <Field name="instanceGroups">
form.setFieldValue(field.name, checked) {({ field, form }) => (
} <InstanceGroupsLookup
/> value={field.value}
</div> onChange={value => form.setFieldValue(field.name, value)}
</FormGroup> tooltip={i18n._(t`Select the Instance Groups for this Organization
to run on.`)}
/>
)} )}
</Field> </Field>
<FormFullWidthLayout> <FieldWithPrompt
<Field name="instanceGroups"> fieldId="template-tags"
label={i18n._(t`Job Tags`)}
promptId="template-ask-tags-on-launch"
promptName="ask_tags_on_launch"
tooltip={i18n._(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 Tower documentation for details on
the usage of tags.`)}
>
<Field name="job_tags">
{({ field, form }) => ( {({ field, form }) => (
<InstanceGroupsLookup <TagMultiSelect
value={field.value} value={field.value}
onChange={value => onChange={value =>
form.setFieldValue(field.name, value) form.setFieldValue(field.name, value)
} }
tooltip={i18n._(t`Select the Instance Groups for this Organization
to run on.`)}
/> />
)} )}
</Field> </Field>
<Field name="job_tags"> </FieldWithPrompt>
{({ field, form }) => ( <FieldWithPrompt
<FormGroup fieldId="template-skip-tags"
label={i18n._(t`Job Tags`)} label={i18n._(t`Skip Tags`)}
fieldId="template-job-tags" promptId="template-ask-skip-tags-on-launch"
> promptName="ask_skip_tags_on_launch"
<FieldTooltip tooltip={i18n._(t`Skip tags are useful when you have a
content={i18n._(t`Tags are useful when you have a large large playbook, and you want to skip specific parts of a
playbook, and you want to run a specific part of a play or task. Use commas to separate multiple tags. Refer
play or task. Use commas to separate multiple tags. to Ansible Tower documentation for details on the usage
Refer to Ansible Tower documentation for details on of tags.`)}
the usage of tags.`)} >
/>
<TagMultiSelect
value={field.value}
onChange={value =>
form.setFieldValue(field.name, value)
}
/>
</FormGroup>
)}
</Field>
<Field name="skip_tags"> <Field name="skip_tags">
{({ field, form }) => ( {({ field, form }) => (
<FormGroup <TagMultiSelect
label={i18n._(t`Skip Tags`)} value={field.value}
fieldId="template-skip-tags" onChange={value =>
> form.setFieldValue(field.name, value)
<FieldTooltip }
content={i18n._(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 Tower documentation for details on the usage
of tags.`)}
/>
<TagMultiSelect
value={field.value}
onChange={value =>
form.setFieldValue(field.name, value)
}
/>
</FormGroup>
)} )}
</Field> </Field>
<FormGroup </FieldWithPrompt>
fieldId="template-option-checkboxes" <FormGroup
label={i18n._(t`Options`)} fieldId="template-option-checkboxes"
> label={i18n._(t`Options`)}
<FormCheckboxLayout> >
<CheckboxField <FormCheckboxLayout>
id="option-privilege-escalation" <CheckboxField
name="become_enabled" id="option-privilege-escalation"
label={i18n._(t`Privilege Escalation`)} name="become_enabled"
tooltip={i18n._(t`If enabled, run this playbook as an label={i18n._(t`Privilege Escalation`)}
administrator.`)} tooltip={i18n._(t`If enabled, run this playbook as an
/> administrator.`)}
<Checkbox
aria-label={i18n._(t`Provisioning Callbacks`)}
label={
<span>
{i18n._(t`Provisioning Callbacks`)}
&nbsp;
<FieldTooltip
content={i18n._(t`Enables creation of a provisioning
callback URL. Using the URL a host can contact BRAND_NAME
and request a configuration update using this job
template.`)}
/>
</span>
}
id="option-callbacks"
isChecked={allowCallbacks}
onChange={checked => {
this.setState({ allowCallbacks: checked });
}}
/>
<CheckboxField
id="option-concurrent"
name="allow_simultaneous"
label={i18n._(t`Concurrent Jobs`)}
tooltip={i18n._(t`If enabled, simultaneous runs of this job
template will be allowed.`)}
/>
<CheckboxField
id="option-fact-cache"
name="use_fact_cache"
label={i18n._(t`Fact Cache`)}
tooltip={i18n._(t`If enabled, use cached facts if available
and store discovered facts in the cache.`)}
/>
</FormCheckboxLayout>
</FormGroup>
</FormFullWidthLayout>
{allowCallbacks && (
<>
{callbackUrl && (
<FormGroup
label={i18n._(t`Provisioning Callback URL`)}
fieldId="template-callback-url"
>
<TextInput
id="template-callback-url"
isDisabled
value={callbackUrl}
/>
</FormGroup>
)}
<FormField
id="template-host-config-key"
name="host_config_key"
label={i18n._(t`Host Config Key`)}
validate={allowCallbacks ? required(null, i18n) : null}
/> />
</> <Checkbox
)} aria-label={i18n._(t`Provisioning Callbacks`)}
</FormColumnLayout> label={
</AdvancedFieldsWrapper> <span>
{i18n._(t`Provisioning Callbacks`)}
&nbsp;
<FieldTooltip
content={i18n._(t`Enables creation of a provisioning
callback URL. Using the URL a host can contact BRAND_NAME
and request a configuration update using this job
template.`)}
/>
</span>
}
id="option-callbacks"
isChecked={allowCallbacks}
onChange={checked => {
this.setState({ allowCallbacks: checked });
}}
/>
<CheckboxField
id="option-concurrent"
name="allow_simultaneous"
label={i18n._(t`Concurrent Jobs`)}
tooltip={i18n._(t`If enabled, simultaneous runs of this job
template will be allowed.`)}
/>
<CheckboxField
id="option-fact-cache"
name="use_fact_cache"
label={i18n._(t`Fact Cache`)}
tooltip={i18n._(t`If enabled, use cached facts if available
and store discovered facts in the cache.`)}
/>
</FormCheckboxLayout>
</FormGroup>
</FormFullWidthLayout>
{allowCallbacks && (
<>
{callbackUrl && (
<FormGroup
label={i18n._(t`Provisioning Callback URL`)}
fieldId="template-callback-url"
>
<TextInput
id="template-callback-url"
isDisabled
value={callbackUrl}
/>
</FormGroup>
)}
<FormField
id="template-host-config-key"
name="host_config_key"
label={i18n._(t`Host Config Key`)}
validate={allowCallbacks ? required(null, i18n) : null}
/>
</>
)}
</FormColumnLayout>
</FormFullWidthLayout> </FormFullWidthLayout>
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} /> <FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
@@ -603,7 +666,16 @@ const FormikApp = withFormik({
? summary_fields.inventory.organization_id ? summary_fields.inventory.organization_id
: null; : null;
return { 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_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 || '', name: template.name || '',
description: template.description || '', description: template.description || '',
job_type: template.job_type || 'run', job_type: template.job_type || 'run',
@@ -629,6 +701,7 @@ const FormikApp = withFormik({
initialInstanceGroups: [], initialInstanceGroups: [],
instanceGroups: [], instanceGroups: [],
credentials: summary_fields.credentials || [], credentials: summary_fields.credentials || [],
extra_vars: template.extra_vars || '---\n',
}; };
}, },
handleSubmit: async (values, { props, setErrors }) => { handleSubmit: async (values, { props, setErrors }) => {
@@ -638,6 +711,20 @@ const FormikApp = withFormik({
setErrors(errors); 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); })(JobTemplateForm);
export { JobTemplateForm as _JobTemplateForm }; export { JobTemplateForm as _JobTemplateForm };

View File

@@ -140,13 +140,10 @@ describe('<JobTemplateForm />', () => {
wrapper.find('input#template-description').simulate('change', { wrapper.find('input#template-description').simulate('change', {
target: { value: 'new bar', name: 'description' }, target: { value: 'new bar', name: 'description' },
}); });
wrapper.find('AnsibleSelect[name="job_type"]').simulate('change', { wrapper.find('AnsibleSelect#template-job-type').prop('onChange')(
target: { value: 'new job type', name: 'job_type' }, null,
}); 'check'
wrapper.find('InventoryLookup').invoke('onChange')({ );
id: 3,
name: 'inventory',
});
wrapper.find('ProjectLookup').invoke('onChange')({ wrapper.find('ProjectLookup').invoke('onChange')({
id: 4, id: 4,
name: 'project', name: 'project',
@@ -155,7 +152,14 @@ describe('<JobTemplateForm />', () => {
}); });
wrapper.update(); wrapper.update();
await act(async () => { 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' }, target: { value: 'devel', name: 'scm_branch' },
}); });
wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', { wrapper.find('AnsibleSelect[name="playbook"]').simulate('change', {
@@ -179,7 +183,7 @@ describe('<JobTemplateForm />', () => {
); );
expect( expect(
wrapper.find('AnsibleSelect[name="job_type"]').prop('value') wrapper.find('AnsibleSelect[name="job_type"]').prop('value')
).toEqual('new job type'); ).toEqual('check');
expect(wrapper.find('InventoryLookup').prop('value')).toEqual({ expect(wrapper.find('InventoryLookup').prop('value')).toEqual({
id: 3, id: 3,
name: 'inventory', name: 'inventory',
@@ -189,7 +193,9 @@ describe('<JobTemplateForm />', () => {
name: 'project', name: 'project',
allow_override: true, 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( expect(
wrapper.find('AnsibleSelect[name="playbook"]').prop('value') wrapper.find('AnsibleSelect[name="playbook"]').prop('value')
).toEqual('new baz type'); ).toEqual('new baz type');