Adds prompt on launch support to the rest of the relevant fields in the Job template form. Adds extra variables field to the job template form. Removes the advanced section in favor of a straight form.

This commit is contained in:
mabashian 2020-02-19 15:23:11 -05:00
parent 26dcb000f6
commit 0582079606
9 changed files with 1270 additions and 56 deletions

View File

@ -65,3 +65,106 @@ VariablesField.defaultProps = {
};
export default VariablesField;
/*
import React, { useState } from 'react';
import { string, bool } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Field, useFormikContext } from 'formik';
import { Split, SplitItem } from '@patternfly/react-core';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
import { CheckboxField } from '@components/FormField';
import styled from 'styled-components';
import CodeMirrorInput from './CodeMirrorInput';
import YamlJsonToggle from './YamlJsonToggle';
import { JSON_MODE, YAML_MODE } from './constants';
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 { values, setFieldError, setFieldValue } = useFormikContext();
const value = values[name];
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
return (
<div className="pf-c-form__group">
<FieldHeader>
<Split gutter="sm">
<SplitItem>
<label htmlFor={id} className="pf-c-form__label">
<span className="pf-c-form__label-text">{label}</span>
</label>
</SplitItem>
<SplitItem>
<YamlJsonToggle
mode={mode}
onChange={newMode => {
try {
const newVal =
newMode === YAML_MODE
? jsonToYaml(value)
: yamlToJson(value);
setFieldValue(name, newVal);
setMode(newMode);
} catch (err) {
setFieldError(name, err.message);
}
}}
/>
</SplitItem>
</Split>
{promptId && (
<StyledCheckboxField
id="template-ask-variables-on-launch"
label={i18n._(t`Prompt On Launch`)}
name="ask_variables_on_launch"
/>
)}
</FieldHeader>
<Field name={name}>
{({ field, form }) => (
<>
<CodeMirrorInput
mode={mode}
readOnly={readOnly}
{...field}
onChange={newVal => {
form.setFieldValue(name, newVal);
}}
hasErrors={!!form.errors[field.name]}
/>
{form.errors[field.name] ? (
<div
className="pf-c-form__helper-text pf-m-error"
aria-live="polite"
>
{form.errors[field.name]}
</div>
) : null}
</>
)}
</Field>
</div>
);
}
VariablesField.propTypes = {
id: string.isRequired,
name: string.isRequired,
label: string.isRequired,
readOnly: bool,
};
VariablesField.defaultProps = {
readOnly: false,
};
export default withI18n()(VariablesField);
*/

View File

@ -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,9 @@ 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 }) {
// some stuff was stripped out of this component - need to propagate those changes
// out to other forms that use this lookup
const [inventories, setInventories] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
@ -47,14 +37,7 @@ function InventoryLookup({
}, [history.location]);
return (
<FormGroup
label={i18n._(t`Inventory`)}
isRequired={required}
fieldId="inventory-lookup"
isValid={isValid}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<>
<Lookup
id="inventory-lookup"
header={i18n._(t`Inventory`)}
@ -100,20 +83,18 @@ function InventoryLookup({
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
</>
);
}
InventoryLookup.propTypes = {
value: Inventory,
tooltip: string,
onChange: func.isRequired,
required: bool,
};
InventoryLookup.defaultProps = {
value: null,
tooltip: '',
required: false,
};

View File

@ -194,3 +194,197 @@ MultiCredentialsLookup.defaultProps = {
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(withRouter(MultiCredentialsLookup));
/*
import React, { Fragment, useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { ToolbarItem } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from '@api';
import AnsibleSelect from '@components/AnsibleSelect';
import CredentialChip from '@components/CredentialChip';
import VerticalSeperator from '@components/VerticalSeparator';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
page_size: 5,
order_by: 'name',
});
async function loadCredentialTypes() {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
return data.results.filter(type => acceptableTypes.includes(type.kind));
}
async function loadCredentials(params, selectedCredentialTypeId) {
params.credential_type = selectedCredentialTypeId || 1;
const { data } = await CredentialsAPI.read(params);
return data;
}
function MultiCredentialsLookup(props) {
const { value, onChange, onError, history, i18n } = props;
const [credentialTypes, setCredentialTypes] = useState([]);
const [selectedType, setSelectedType] = useState(null);
const [credentials, setCredentials] = useState([]);
const [credentialsCount, setCredentialsCount] = useState(0);
useEffect(() => {
(async () => {
try {
const types = await loadCredentialTypes();
setCredentialTypes(types);
const match = types.find(type => type.kind === 'ssh') || types[0];
setSelectedType(match);
} catch (err) {
onError(err);
}
})();
}, [onError]);
useEffect(() => {
(async () => {
if (!selectedType) {
return;
}
try {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { results, count } = await loadCredentials(
params,
selectedType.id
);
setCredentials(results);
setCredentialsCount(count);
} catch (err) {
onError(err);
}
})();
}, [selectedType, history.location.search, onError]);
const renderChip = ({ item, removeItem, canDelete }) => (
<CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
);
const isMultiple = selectedType && selectedType.kind === 'vault';
return (
<Lookup
id="multiCredential"
header={i18n._(t`Credentials`)}
value={value}
multiple
onChange={onChange}
qsConfig={QS_CONFIG}
renderItemChip={renderChip}
renderOptionsList={({ state, dispatch, canDelete }) => {
return (
<Fragment>
{credentialTypes && credentialTypes.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
<VerticalSeperator />
<AnsibleSelect
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label={i18n._(t`Selected Category`)}
data={credentialTypes.map(type => ({
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))
);
}}
/>
</ToolbarItem>
)}
<OptionsList
value={state.selectedItems}
options={credentials}
optionCount={credentialsCount}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
multiple={isMultiple}
header={i18n._(t`Credentials`)}
name="credentials"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => {
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}
/>
</Fragment>
);
}}
/>
);
}
MultiCredentialsLookup.propTypes = {
value: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
description: PropTypes.string,
kind: PropTypes.string,
clound: PropTypes.bool,
})
),
onChange: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
};
MultiCredentialsLookup.defaultProps = {
value: [],
};
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(withRouter(MultiCredentialsLookup));
*/

View File

@ -122,3 +122,139 @@ HostForm.defaultProps = {
export { HostForm as _HostForm };
export default withI18n()(HostForm);
/*
import React, { useState } from 'react';
import { func, shape } from 'prop-types';
import { useRouteMatch } from 'react-router-dom';
import { Formik, Field } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form, FormGroup } from '@patternfly/react-core';
import FormRow from '@components/FormRow';
import FormField, {
FormSubmitError,
FieldTooltip,
} from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators';
import { InventoryLookup } from '@components/Lookup';
function HostForm({ handleSubmit, handleCancel, host, submitError, i18n }) {
const [inventory, setInventory] = useState(
host ? host.summary_fields.inventory : ''
);
const hostAddMatch = useRouteMatch('/hosts/add');
return (
<Formik
initialValues={{
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="host-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="host-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
{hostAddMatch && (
<Field
name="inventory"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ form }) => (
<FormGroup
label={i18n._(t`Inventory`)}
isRequired
fieldId="inventory-lookup"
isValid={!form.touched.inventory || !form.errors.inventory}
helperTextInvalid={form.errors.inventory}
>
<FieldTooltip
content={i18n._(
t`Select the inventory that this host will belong to.`
)}
/>
<InventoryLookup
value={inventory}
onBlur={() => form.setFieldTouched('inventory')}
onChange={value => {
form.setFieldValue('inventory', value.id);
setInventory(value);
}}
required
touched={form.touched.inventory}
error={form.errors.inventory}
/>
</FormGroup>
)}
</Field>
)}
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
</Formik>
);
}
HostForm.propTypes = {
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
host: shape({}),
submitError: shape({}),
};
HostForm.defaultProps = {
host: {
name: '',
description: '',
inventory: undefined,
variables: '---\n',
summary_fields: {
inventory: null,
},
},
submitError: null,
};
export { HostForm as _HostForm };
export default withI18n()(HostForm);
*/

View File

@ -10,9 +10,20 @@ jest.mock('@api');
const jobTemplateData = {
allow_callbacks: 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,
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,
extra_vars: '---\n',
forks: 0,
host_config_key: '',
inventory: 1,
@ -20,9 +31,9 @@ const jobTemplateData = {
job_tags: '',
job_type: 'run',
limit: '',
name: 'Foo',
playbook: 'Bar',
project: 2,
name: '',
playbook: '',
project: 1,
scm_branch: '',
skip_tags: '',
timeout: 0,
@ -103,13 +114,12 @@ describe('<JobTemplateAdd />', () => {
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('<JobTemplateAdd />', () => {
.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('<JobTemplateAdd />', () => {
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('<JobTemplateAdd />', () => {
});
});
wrapper.update();
act(() => {
wrapper.find('InventoryLookup').invoke('onChange')({
id: 1,
organization: 1,
});
});
wrapper.update();
await act(async () => {
wrapper.find('form').simulate('submit');
});

View File

@ -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 }) {
}
/>
)}
<VariablesDetail
value={extra_vars}
rows={4}
label={i18n._(t`Variables`)}
/>
</DetailList>
<CardActionsRow>
{summary_fields.user_capabilities &&

View File

@ -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('<JobTemplateEdit />', () => {
);
});
const updatedTemplateData = {
job_type: 'check',
name: 'new name',
inventory: 1,
};
@ -206,14 +218,18 @@ describe('<JobTemplateEdit />', () => {
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('<JobTemplateEdit />', () => {
const expected = {
...mockJobTemplate,
...updatedTemplateData,
become_enabled: false,
};
delete expected.summary_fields;
delete expected.id;

View File

@ -642,3 +642,749 @@ const FormikApp = withFormik({
export { JobTemplateForm as _JobTemplateForm };
export default withI18n()(withRouter(FormikApp));
/*
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withFormik, Field } from 'formik';
import {
Form,
FormGroup,
Switch,
Checkbox,
TextInput,
} from '@patternfly/react-core';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import AnsibleSelect from '@components/AnsibleSelect';
import { TagMultiSelect } from '@components/MultiSelect';
import FormActionGroup from '@components/FormActionGroup';
import FormField, {
CheckboxField,
FieldTooltip,
FormSubmitError,
} from '@components/FormField';
import FieldWithPrompt from '@components/FieldWithPrompt';
import FormRow from '@components/FormRow';
import { required } from '@util/validators';
import styled from 'styled-components';
import { JobTemplate } from '@types';
import {
InventoryLookup,
InstanceGroupsLookup,
ProjectLookup,
MultiCredentialsLookup,
} from '@components/Lookup';
import { VariablesField } from '@components/CodeMirrorInput';
import { JobTemplatesAPI, ProjectsAPI } from '@api';
import LabelSelect from './LabelSelect';
import PlaybookSelect from './PlaybookSelect';
const GridFormGroup = styled(FormGroup)`
& > label {
grid-column: 1 / -1;
}
&& {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
`;
class JobTemplateForm extends Component {
static propTypes = {
template: JobTemplate,
handleCancel: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
submitError: PropTypes.shape({}),
};
static defaultProps = {
template: {
name: '',
description: '',
job_type: 'run',
inventory: undefined,
project: undefined,
playbook: '',
summary_fields: {
inventory: null,
labels: { results: [] },
project: null,
credentials: [],
},
isNew: true,
},
submitError: null,
};
constructor(props) {
super(props);
this.state = {
hasContentLoading: true,
contentError: false,
project: props.template.summary_fields.project,
inventory: props.template.summary_fields.inventory,
allowCallbacks: !!props.template.host_config_key,
};
this.handleProjectValidation = this.handleProjectValidation.bind(this);
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
this.handleProjectUpdate = this.handleProjectUpdate.bind(this);
this.setContentError = this.setContentError.bind(this);
this.fetchProject = this.fetchProject.bind(this);
}
componentDidMount() {
const { validateField } = this.props;
this.setState({ contentError: null, hasContentLoading: true });
// TODO: determine when LabelSelect has finished loading labels
Promise.all([this.loadRelatedInstanceGroups(), this.fetchProject()]).then(
() => {
this.setState({ hasContentLoading: false });
validateField('project');
}
);
}
async fetchProject() {
const { project } = this.state;
if (project && project.id) {
try {
const { data: projectData } = await ProjectsAPI.readDetail(project.id);
this.setState({ project: projectData });
} catch (err) {
this.setState({ contentError: err });
}
}
}
async loadRelatedInstanceGroups() {
const { setFieldValue, template } = this.props;
if (!template.id) {
return;
}
try {
const { data } = await JobTemplatesAPI.readInstanceGroups(template.id);
setFieldValue('initialInstanceGroups', data.results);
setFieldValue('instanceGroups', [...data.results]);
} catch (err) {
this.setState({ contentError: err });
}
}
handleProjectValidation() {
const { i18n, touched } = this.props;
const { project } = this.state;
return () => {
if (!project && touched.project) {
return i18n._(t`Select a value for this field`);
}
if (project && project.status === 'never updated') {
return i18n._(t`This project needs to be updated`);
}
return undefined;
};
}
handleProjectUpdate(project) {
const { setFieldValue } = this.props;
setFieldValue('project', project.id);
setFieldValue('playbook', 0);
setFieldValue('scm_branch', '');
this.setState({ project });
}
setContentError(contentError) {
this.setState({ contentError });
}
render() {
const {
contentError,
hasContentLoading,
inventory,
project,
allowCallbacks,
} = this.state;
const {
handleCancel,
handleSubmit,
handleBlur,
setFieldValue,
template,
submitError,
i18n,
} = this.props;
const jobTypeOptions = [
{
value: '',
key: '',
label: i18n._(t`Choose a job type`),
isDisabled: true,
},
{ value: 'run', key: 'run', label: i18n._(t`Run`), isDisabled: false },
{
value: 'check',
key: 'check',
label: i18n._(t`Check`),
isDisabled: false,
},
];
const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
{ value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) },
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
];
let callbackUrl;
if (template && template.related) {
const { origin } = document.location;
const path = template.related.callback || `${template.url}callback`;
callbackUrl = `${origin}${path}`;
}
if (hasContentLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<Form autoComplete="off" onSubmit={handleSubmit}>
<FormRow>
<FormField
id="template-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="template-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
<FieldWithPrompt
fieldId="template-job-type"
isRequired
label={i18n._(t`Job Type`)}
promptId="template-ask-job-type-on-launch"
promptName="ask_job_type_on_launch"
tooltip={i18n._(t`For job templates, select run to execute
the playbook. Select check to only check playbook syntax,
test environment setup, and report problems without
executing the playbook.`)}
>
<Field
name="job_type"
validate={required(null, i18n)}
onBlur={handleBlur}
>
{({ form, field }) => {
const isValid = !form.touched.job_type || !form.errors.job_type;
return (
<AnsibleSelect
isValid={isValid}
id="template-job-type"
data={jobTypeOptions}
{...field}
onChange={(event, value) => {
form.setFieldValue('job_type', value);
}}
/>
);
}}
</Field>
</FieldWithPrompt>
<FieldWithPrompt
fieldId="template-inventory"
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.`)}
>
<Field name="inventory">
{({ form }) => (
<>
<InventoryLookup
value={inventory}
onBlur={() => {
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 && (
<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()}>
{({ form }) => (
<ProjectLookup
value={project}
onBlur={() => form.setFieldTouched('project')}
tooltip={i18n._(t`Select the project containing the playbook
you want this job to execute.`)}
isValid={!form.touched.project || !form.errors.project}
helperTextInvalid={form.errors.project}
onChange={this.handleProjectUpdate}
required
/>
)}
</Field>
{project && project.allow_override && (
<FieldWithPrompt
fieldId="template-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
name="playbook"
validate={required(i18n._(t`Select a value for this field`), i18n)}
onBlur={handleBlur}
>
{({ field, form }) => {
const isValid = !form.touched.playbook || !form.errors.playbook;
return (
<FormGroup
fieldId="template-playbook"
helperTextInvalid={form.errors.playbook}
isRequired
isValid={isValid}
label={i18n._(t`Playbook`)}
>
<FieldTooltip
content={i18n._(
t`Select the playbook to be executed by this job.`
)}
/>
<PlaybookSelect
projectId={form.values.project}
isValid={isValid}
form={form}
field={field}
onBlur={() => form.setFieldTouched('playbook')}
onError={this.setContentError}
/>
</FormGroup>
);
}}
</Field>
</FormRow>
<FormRow>
<Field name="labels">
{({ field }) => (
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
<FieldTooltip
content={i18n._(t`Optional labels that describe this job template,
such as 'dev' or 'test'. Labels can be used to group and filter
job templates and completed jobs.`)}
/>
<LabelSelect
value={field.value}
onChange={labels => setFieldValue('labels', labels)}
onError={this.setContentError}
/>
</FormGroup>
)}
</Field>
</FormRow>
<FormRow>
<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>
</FormRow>
<FormRow>
<FormField
id="template-forks"
name="forks"
type="number"
min="0"
label={i18n._(t`Forks`)}
tooltip={
<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
usually 5. The default number of forks can be overwritten
with a change to`)}{' '}
<code>ansible.cfg</code>.{' '}
{i18n._(t`Refer to the Ansible documentation for details
about the configuration file.`)}
</span>
}
/>
<FieldWithPrompt
fieldId="template-limit"
label={i18n._(t`Limit`)}
promptId="template-ask-limit-on-launch"
promptName="ask_limit_on_launch"
tooltip={i18n._(t`Provide a host pattern to further constrain
the list of hosts that will be managed or affected by the
playbook. Multiple patterns are allowed. Refer to Ansible
documentation for more information and examples on patterns.`)}
>
<Field name="limit">
{({ form, field }) => {
return (
<TextInput
id="template-limit"
{...field}
isValid={!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 }) => (
<AnsibleSelect
id="template-verbosity"
data={verbosityOptions}
{...field}
/>
)}
</Field>
</FieldWithPrompt>
<FormField
id="template-job-slicing"
name="job_slice_count"
type="number"
min="1"
label={i18n._(t`Job Slicing`)}
tooltip={i18n._(t`Divide the work done by this job template
into the specified number of job slices, each running the
same tasks against a portion of the inventory.`)}
/>
<FormField
id="template-timeout"
name="timeout"
type="number"
min="0"
label={i18n._(t`Timeout`)}
tooltip={i18n._(t`The amount of time (in seconds) to run
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">
{({ form, field }) => {
return (
<Switch
id="template-show-changes"
label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
isChecked={field.value}
onChange={checked =>
form.setFieldValue(field.name, checked)
}
/>
);
}}
</Field>
</FieldWithPrompt>
</FormRow>
<Field name="instanceGroups">
{({ field, form }) => (
<InstanceGroupsLookup
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
tooltip={i18n._(t`Select the Instance Groups for this Organization
to run on.`)}
/>
)}
</Field>
<FieldWithPrompt
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 }) => (
<TagMultiSelect
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
/>
)}
</Field>
</FieldWithPrompt>
<FieldWithPrompt
fieldId="template-skip-tags"
label={i18n._(t`Skip Tags`)}
promptId="template-ask-skip-tags-on-launch"
promptName="ask_skip_tags_on_launch"
tooltip={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.`)}
>
<Field name="skip_tags">
{({ field, form }) => (
<TagMultiSelect
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
/>
)}
</Field>
</FieldWithPrompt>
<GridFormGroup
fieldId="template-option-checkboxes"
isInline
label={i18n._(t`Options`)}
css="margin-top: 20px"
>
<CheckboxField
id="option-privilege-escalation"
name="become_enabled"
label={i18n._(t`Privilege Escalation`)}
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.`)}
/>
</GridFormGroup>
<div
css={`
${allowCallbacks ? '' : 'display: none'}
margin-top: 20px;
`}
>
<FormRow>
{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}
/>
</FormRow>
</div>
<FormRow>
<VariablesField
id="template-variables"
name="extra_vars"
label={i18n._(t`Variables`)}
promptId="template-ask-variables-on-launch"
/>
</FormRow>
<FormSubmitError error={submitError} />
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
</Form>
);
}
}
const FormikApp = withFormik({
mapPropsToValues(props) {
const { template = {} } = props;
const {
summary_fields = {
labels: { results: [] },
inventory: { organization: null },
},
} = template;
const hasInventory = summary_fields.inventory
? 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',
inventory: template.inventory || '',
project: template.project || '',
scm_branch: template.scm_branch || '',
playbook: template.playbook || '',
labels: summary_fields.labels.results || [],
forks: template.forks || 0,
limit: template.limit || '',
verbosity: template.verbosity || '0',
job_slice_count: template.job_slice_count || 1,
timeout: template.timeout || 0,
diff_mode: template.diff_mode || false,
job_tags: template.job_tags || '',
skip_tags: template.skip_tags || '',
become_enabled: template.become_enabled || false,
allow_callbacks: template.allow_callbacks || false,
allow_simultaneous: template.allow_simultaneous || false,
use_fact_cache: template.use_fact_cache || false,
host_config_key: template.host_config_key || '',
organizationId: hasInventory,
initialInstanceGroups: [],
instanceGroups: [],
credentials: summary_fields.credentials || [],
extra_vars: template.extra_vars || '---\n',
};
},
handleSubmit: async (values, { props, setErrors }) => {
try {
await props.handleSubmit(values);
} catch (errors) {
setErrors(errors);
}
},
validate: values => {
const errors = {};
if (
(!values.inventory || values.inventory === '') &&
!values.ask_inventory_on_launch
) {
errors.inventory =
'Please select an Inventory or check the Prompt on Launch option.';
}
return errors;
},
})(JobTemplateForm);
export { JobTemplateForm as _JobTemplateForm };
export default withI18n()(withRouter(FormikApp));
*/

View File

@ -140,13 +140,10 @@ describe('<JobTemplateForm />', () => {
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('<JobTemplateForm />', () => {
});
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('<JobTemplateForm />', () => {
);
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('<JobTemplateForm />', () => {
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');