Merge pull request #6385 from AlexSCorey/6317-ConvertJTFormstoFormikHooks

Uses formik hooks for JT Form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-04-02 15:52:35 +00:00
committed by GitHub
8 changed files with 164 additions and 249 deletions

View File

@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { CardBody } from '@components/Card'; import { CardBody } from '@components/Card';
import JobTemplateForm from '../shared/JobTemplateForm'; import JobTemplateForm from '../shared/JobTemplateForm';
import { JobTemplatesAPI, OrganizationsAPI } from '@api'; import { JobTemplatesAPI } from '@api';
function JobTemplateAdd() { function JobTemplateAdd() {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
@@ -12,7 +12,6 @@ function JobTemplateAdd() {
async function handleSubmit(values) { async function handleSubmit(values) {
const { const {
labels, labels,
organizationId,
instanceGroups, instanceGroups,
initialInstanceGroups, initialInstanceGroups,
credentials, credentials,
@@ -20,12 +19,13 @@ function JobTemplateAdd() {
} = values; } = values;
setFormSubmitError(null); setFormSubmitError(null);
remainingValues.project = remainingValues.project.id;
try { try {
const { const {
data: { id, type }, data: { id, type },
} = await JobTemplatesAPI.create(remainingValues); } = await JobTemplatesAPI.create(remainingValues);
await Promise.all([ await Promise.all([
submitLabels(id, labels, organizationId), submitLabels(id, labels, values.project.summary_fields.organization.id),
submitInstanceGroups(id, instanceGroups), submitInstanceGroups(id, instanceGroups),
submitCredentials(id, credentials), submitCredentials(id, credentials),
]); ]);
@@ -35,16 +35,7 @@ function JobTemplateAdd() {
} }
} }
async function submitLabels(templateId, labels = [], formOrg) { async function submitLabels(templateId, labels = [], orgId) {
let orgId = formOrg;
if (!orgId && labels.length > 0) {
const {
data: { results },
} = await OrganizationsAPI.read();
orgId = results[0].id;
}
const associationPromises = labels.map(label => const associationPromises = labels.map(label =>
JobTemplatesAPI.associateLabel(templateId, label, orgId) JobTemplatesAPI.associateLabel(templateId, label, orgId)
); );

View File

@@ -33,7 +33,7 @@ const jobTemplateData = {
limit: '', limit: '',
name: '', name: '',
playbook: '', playbook: '',
project: 1, project: { id: 1, summary_fields: { organization: { id: 1 } } },
scm_branch: '', scm_branch: '',
skip_tags: '', skip_tags: '',
timeout: 0, timeout: 0,
@@ -123,6 +123,7 @@ describe('<JobTemplateAdd />', () => {
wrapper.find('ProjectLookup').invoke('onChange')({ wrapper.find('ProjectLookup').invoke('onChange')({
id: 2, id: 2,
name: 'project', name: 'project',
summary_fields: { organization: { id: 1, name: 'Org Foo' } },
}); });
wrapper.update(); wrapper.update();
wrapper wrapper
@@ -161,6 +162,7 @@ describe('<JobTemplateAdd />', () => {
id: 1, id: 1,
type: 'job_template', type: 'job_template',
...jobTemplateData, ...jobTemplateData,
project: jobTemplateData.project.id,
}, },
}); });
let wrapper; let wrapper;
@@ -181,6 +183,7 @@ describe('<JobTemplateAdd />', () => {
wrapper.find('ProjectLookup').invoke('onChange')({ wrapper.find('ProjectLookup').invoke('onChange')({
id: 2, id: 2,
name: 'project', name: 'project',
summary_fields: { organization: { id: 1, name: 'Org Foo' } },
}); });
wrapper.update(); wrapper.update();
wrapper wrapper

View File

@@ -4,7 +4,7 @@ import { withRouter, Redirect } from 'react-router-dom';
import { CardBody } from '@components/Card'; import { CardBody } from '@components/Card';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
import { JobTemplatesAPI, OrganizationsAPI, ProjectsAPI } from '@api'; import { JobTemplatesAPI, ProjectsAPI } from '@api';
import { JobTemplate } from '@types'; import { JobTemplate } from '@types';
import { getAddedAndRemoved } from '@util/lists'; import { getAddedAndRemoved } from '@util/lists';
import JobTemplateForm from '../shared/JobTemplateForm'; import JobTemplateForm from '../shared/JobTemplateForm';
@@ -97,7 +97,6 @@ class JobTemplateEdit extends Component {
const { template, history } = this.props; const { template, history } = this.props;
const { const {
labels, labels,
organizationId,
instanceGroups, instanceGroups,
initialInstanceGroups, initialInstanceGroups,
credentials, credentials,
@@ -105,10 +104,14 @@ class JobTemplateEdit extends Component {
} = values; } = values;
this.setState({ formSubmitError: null }); this.setState({ formSubmitError: null });
remainingValues.project = values.project.id;
try { try {
await JobTemplatesAPI.update(template.id, remainingValues); await JobTemplatesAPI.update(template.id, remainingValues);
await Promise.all([ await Promise.all([
this.submitLabels(labels, organizationId), this.submitLabels(
labels,
values.project.summary_fields.organization.id
),
this.submitInstanceGroups(instanceGroups, initialInstanceGroups), this.submitInstanceGroups(instanceGroups, initialInstanceGroups),
this.submitCredentials(credentials), this.submitCredentials(credentials),
]); ]);
@@ -118,22 +121,14 @@ class JobTemplateEdit extends Component {
} }
} }
async submitLabels(labels = [], formOrgId) { async submitLabels(labels = [], orgId) {
const { template } = this.props; const { template } = this.props;
let orgId = formOrgId;
const { added, removed } = getAddedAndRemoved( const { added, removed } = getAddedAndRemoved(
template.summary_fields.labels.results, template.summary_fields.labels.results,
labels labels
); );
if (!orgId && added.length > 0) {
const {
data: { results },
} = await OrganizationsAPI.read();
orgId = results[0].id;
}
const disassociationPromises = removed.map(label => const disassociationPromises = removed.map(label =>
JobTemplatesAPI.disassociateLabel(template.id, label) JobTemplatesAPI.disassociateLabel(template.id, label)
); );

View File

@@ -35,7 +35,7 @@ const mockJobTemplate = {
limit: '', limit: '',
name: 'Foo', name: 'Foo',
playbook: 'Baz', playbook: 'Baz',
project: 3, project: { id: 3, summary_fields: { organization: { id: 1 } } },
scm_branch: '', scm_branch: '',
skip_tags: '', skip_tags: '',
summary_fields: { summary_fields: {
@@ -239,6 +239,7 @@ describe('<JobTemplateEdit />', () => {
const expected = { const expected = {
...mockJobTemplate, ...mockJobTemplate,
project: mockJobTemplate.project.id,
...updatedTemplateData, ...updatedTemplateData,
}; };
delete expected.summary_fields; delete expected.summary_fields;

View File

@@ -3,7 +3,7 @@ import PropTypes 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 { withFormik, Field } from 'formik'; import { withFormik, useField, useFormikContext } from 'formik';
import { import {
Form, Form,
FormGroup, FormGroup,
@@ -47,11 +47,9 @@ function JobTemplateForm({
validateField, validateField,
handleCancel, handleCancel,
handleSubmit, handleSubmit,
handleBlur,
setFieldValue, setFieldValue,
submitError, submitError,
i18n, i18n,
touched,
}) { }) {
const [contentError, setContentError] = useState(false); const [contentError, setContentError] = useState(false);
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
@@ -62,6 +60,35 @@ function JobTemplateForm({
Boolean(template?.host_config_key) Boolean(template?.host_config_key)
); );
const { values: formikValues } = useFormikContext();
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
name: 'job_type',
validate: required(null, i18n),
});
const [, inventoryMeta, inventoryHelpers] = useField('inventory');
const [projectField, projectMeta, projectHelpers] = useField({
name: 'project',
validate: () => handleProjectValidation(),
});
const [scmField, , scmHelpers] = useField('scm_branch');
const [playbookField, playbookMeta, playbookHelpers] = useField({
name: 'playbook',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [credentialField, , credentialHelpers] = useField('credentials');
const [labelsField, , labelsHelpers] = useField('labels');
const [limitField, limitMeta] = useField('limit');
const [verbosityField] = useField('verbosity');
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
const [instanceGroupsField, , instanceGroupsHelpers] = useField(
'instanceGroups'
);
const [jobTagsField, , jobTagsHelpers] = useField('job_tags');
const [skipTagsField, , skipTagsHelpers] = useField('skip_tags');
const { const {
request: fetchProject, request: fetchProject,
error: projectContentError, error: projectContentError,
@@ -100,7 +127,7 @@ function JobTemplateForm({
}, [loadRelatedInstanceGroups]); }, [loadRelatedInstanceGroups]);
const handleProjectValidation = () => { const handleProjectValidation = () => {
if (!project && touched.project) { if (!project && projectMeta.touched) {
return i18n._(t`Select a value for this field`); return i18n._(t`Select a value for this field`);
} }
if (project && project.status === 'never updated') { if (project && project.status === 'never updated') {
@@ -112,11 +139,11 @@ function JobTemplateForm({
const handleProjectUpdate = useCallback( const handleProjectUpdate = useCallback(
newProject => { newProject => {
setProject(newProject); setProject(newProject);
setFieldValue('project', newProject.id); projectHelpers.setValue(newProject);
setFieldValue('playbook', 0); playbookHelpers.setValue(0);
setFieldValue('scm_branch', ''); scmHelpers.setValue('');
}, },
[setFieldValue, setProject] [setProject, projectHelpers, playbookHelpers, scmHelpers]
); );
const jobTypeOptions = [ const jobTypeOptions = [
@@ -184,26 +211,15 @@ function JobTemplateForm({
test environment setup, and report problems without test environment setup, and report problems without
executing the playbook.`)} 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 <AnsibleSelect
isValid={isValid} {...jobTypeField}
isValid={!jobTypeMeta.touched || !jobTypeMeta.error}
id="template-job-type" id="template-job-type"
data={jobTypeOptions} data={jobTypeOptions}
{...field}
onChange={(event, value) => { onChange={(event, value) => {
form.setFieldValue('job_type', value); jobTypeHelpers.setValue(value);
}} }}
/> />
);
}}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<FieldWithPrompt <FieldWithPrompt
fieldId="template-inventory" fieldId="template-inventory"
@@ -214,108 +230,71 @@ function JobTemplateForm({
tooltip={i18n._(t`Select the inventory containing the hosts tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)} you want this job to manage.`)}
> >
<Field name="inventory">
{({ form }) => (
<>
<InventoryLookup <InventoryLookup
value={inventory} value={inventory}
onBlur={() => { onBlur={() => inventoryHelpers.setTouched()}
form.setFieldTouched('inventory');
}}
onChange={value => { onChange={value => {
form.setValues({ inventoryHelpers.setValue(value.id);
...form.values,
inventory: value.id,
organizationId: value.organization,
});
setInventory(value); setInventory(value);
}} }}
required required
touched={form.touched.inventory} touched={inventoryMeta.touched}
error={form.errors.inventory} error={inventoryMeta.error}
/> />
{(form.touched.inventory || {(inventoryMeta.touched || formikValues.ask_inventory_on_launch) &&
form.touched.ask_inventory_on_launch) && inventoryMeta.error && (
form.errors.inventory && (
<div <div
className="pf-c-form__helper-text pf-m-error" className="pf-c-form__helper-text pf-m-error"
aria-live="polite" aria-live="polite"
> >
{form.errors.inventory} {inventoryMeta.error}
</div> </div>
)} )}
</>
)}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<Field name="project" validate={handleProjectValidation}>
{({ form }) => (
<ProjectLookup <ProjectLookup
value={project} value={project}
onBlur={() => form.setFieldTouched('project')} onBlur={() => projectHelpers.setTouched()}
tooltip={i18n._(t`Select the project containing the playbook tooltip={i18n._(t`Select the project containing the playbook
you want this job to execute.`)} you want this job to execute.`)}
isValid={!form.touched.project || !form.errors.project} isValid={!projectMeta.touched || !projectMeta.error}
helperTextInvalid={form.errors.project} helperTextInvalid={projectMeta.error}
onChange={handleProjectUpdate} onChange={handleProjectUpdate}
required required
/> />
)} {project?.allow_override && (
</Field>
{project && project.allow_override && (
<FieldWithPrompt <FieldWithPrompt
fieldId="template-scm-branch" fieldId="template-scm-branch"
label={i18n._(t`SCM Branch`)} label={i18n._(t`SCM Branch`)}
promptId="template-ask-scm-branch-on-launch" promptId="template-ask-scm-branch-on-launch"
promptName="ask_scm_branch_on_launch" promptName="ask_scm_branch_on_launch"
> >
<Field name="scm_branch">
{({ field }) => {
return (
<TextInput <TextInput
id="template-scm-branch" id="template-scm-branch"
{...field} {...scmField}
onChange={(value, event) => { onChange={(value, event) => {
field.onChange(event); scmField.onChange(event);
}} }}
/> />
);
}}
</Field>
</FieldWithPrompt> </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 <FormGroup
fieldId="template-playbook" fieldId="template-playbook"
helperTextInvalid={form.errors.playbook} helperTextInvalid={playbookMeta.error}
isRequired isRequired
isValid={isValid}
label={i18n._(t`Playbook`)} label={i18n._(t`Playbook`)}
> >
<FieldTooltip <FieldTooltip
content={i18n._( content={i18n._(t`Select the playbook to be executed by this job.`)}
t`Select the playbook to be executed by this job.`
)}
/> />
<PlaybookSelect <PlaybookSelect
projectId={form.values.project} projectId={project?.id || projectField.value?.id}
isValid={isValid} isValid={!(playbookMeta.touched || playbookMeta.error)}
form={form} field={playbookField}
field={field} onBlur={() => playbookHelpers.setTouched()}
onBlur={() => form.setFieldTouched('playbook')}
onError={setContentError} onError={setContentError}
/> />
</FormGroup> </FormGroup>
);
}}
</Field>
<FormFullWidthLayout> <FormFullWidthLayout>
<FieldWithPrompt <FieldWithPrompt
fieldId="template-credentials" fieldId="template-credentials"
@@ -328,22 +307,14 @@ function JobTemplateForm({
credential at run time. If you select credentials and check "Prompt on launch", the selected 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.`)} credential(s) become the defaults that can be updated at run time.`)}
> >
<Field name="credentials" fieldId="template-credentials">
{({ field }) => {
return (
<MultiCredentialsLookup <MultiCredentialsLookup
value={field.value} value={credentialField.value}
onChange={newCredentials => onChange={newCredentials =>
setFieldValue('credentials', newCredentials) credentialHelpers.setValue(newCredentials)
} }
onError={setContentError} onError={setContentError}
/> />
);
}}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<Field name="labels">
{({ field }) => (
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels"> <FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
<FieldTooltip <FieldTooltip
content={i18n._(t`Optional labels that describe this job template, content={i18n._(t`Optional labels that describe this job template,
@@ -351,13 +322,11 @@ function JobTemplateForm({
job templates and completed jobs.`)} job templates and completed jobs.`)}
/> />
<LabelSelect <LabelSelect
value={field.value} value={labelsField.value}
onChange={labels => setFieldValue('labels', labels)} onChange={labels => labelsHelpers.setValue(labels)}
onError={setContentError} onError={setContentError}
/> />
</FormGroup> </FormGroup>
)}
</Field>
<VariablesField <VariablesField
id="template-variables" id="template-variables"
name="extra_vars" name="extra_vars"
@@ -394,20 +363,14 @@ function JobTemplateForm({
playbook. Multiple patterns are allowed. Refer to Ansible playbook. Multiple patterns are allowed. Refer to Ansible
documentation for more information and examples on patterns.`)} documentation for more information and examples on patterns.`)}
> >
<Field name="limit">
{({ form, field }) => {
return (
<TextInput <TextInput
id="template-limit" id="template-limit"
{...field} {...limitField}
isValid={!form.touched.job_type || !form.errors.job_type} isValid={!limitMeta.touched || !limitMeta.error}
onChange={(value, event) => { onChange={(value, event) => {
field.onChange(event); limitField.onChange(event);
}} }}
/> />
);
}}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<FieldWithPrompt <FieldWithPrompt
fieldId="template-verbosity" fieldId="template-verbosity"
@@ -417,15 +380,11 @@ function JobTemplateForm({
tooltip={i18n._(t`Control the level of output ansible will tooltip={i18n._(t`Control the level of output ansible will
produce as the playbook executes.`)} produce as the playbook executes.`)}
> >
<Field name="verbosity">
{({ field }) => (
<AnsibleSelect <AnsibleSelect
id="template-verbosity" id="template-verbosity"
data={verbosityOptions} data={verbosityOptions}
{...field} {...verbosityField}
/> />
)}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<FormField <FormField
id="template-job-slicing" id="template-job-slicing"
@@ -456,32 +415,20 @@ function JobTemplateForm({
Ansible tasks, where supported. This is equivalent Ansible tasks, where supported. This is equivalent
to Ansible&#x2019s --diff mode.`)} to Ansible&#x2019s --diff mode.`)}
> >
<Field name="diff_mode">
{({ form, field }) => {
return (
<Switch <Switch
id="template-show-changes" id="template-show-changes"
label={field.value ? i18n._(t`On`) : i18n._(t`Off`)} label={diffModeField.value ? i18n._(t`On`) : i18n._(t`Off`)}
isChecked={field.value} isChecked={diffModeField.value}
onChange={checked => onChange={checked => diffModeHelpers.setValue(checked)}
form.setFieldValue(field.name, checked)
}
/> />
);
}}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<FormFullWidthLayout> <FormFullWidthLayout>
<Field name="instanceGroups">
{({ field, form }) => (
<InstanceGroupsLookup <InstanceGroupsLookup
value={field.value} value={instanceGroupsField.value}
onChange={value => form.setFieldValue(field.name, value)} onChange={value => instanceGroupsHelpers.setValue(value)}
tooltip={i18n._(t`Select the Instance Groups for this Organization tooltip={i18n._(t`Select the Instance Groups for this Organization
to run on.`)} to run on.`)}
/> />
)}
</Field>
<FieldWithPrompt <FieldWithPrompt
fieldId="template-tags" fieldId="template-tags"
label={i18n._(t`Job Tags`)} label={i18n._(t`Job Tags`)}
@@ -493,14 +440,10 @@ function JobTemplateForm({
Refer to Ansible Tower documentation for details on Refer to Ansible Tower documentation for details on
the usage of tags.`)} the usage of tags.`)}
> >
<Field name="job_tags">
{({ field, form }) => (
<TagMultiSelect <TagMultiSelect
value={field.value} value={jobTagsField.value}
onChange={value => form.setFieldValue(field.name, value)} onChange={value => jobTagsHelpers.setValue(value)}
/> />
)}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<FieldWithPrompt <FieldWithPrompt
fieldId="template-skip-tags" fieldId="template-skip-tags"
@@ -513,14 +456,10 @@ function JobTemplateForm({
to Ansible Tower documentation for details on the usage to Ansible Tower documentation for details on the usage
of tags.`)} of tags.`)}
> >
<Field name="skip_tags">
{({ field, form }) => (
<TagMultiSelect <TagMultiSelect
value={field.value} value={skipTagsField.value}
onChange={value => form.setFieldValue(field.name, value)} onChange={value => skipTagsHelpers.setValue(value)}
/> />
)}
</Field>
</FieldWithPrompt> </FieldWithPrompt>
<FormGroup <FormGroup
fieldId="template-option-checkboxes" fieldId="template-option-checkboxes"
@@ -636,10 +575,6 @@ const FormikApp = withFormik({
}, },
} = template; } = template;
const hasInventory = summary_fields.inventory
? summary_fields.inventory.organization_id
: null;
return { return {
ask_credential_on_launch: template.ask_credential_on_launch || false, ask_credential_on_launch: template.ask_credential_on_launch || false,
ask_diff_mode_on_launch: template.ask_diff_mode_on_launch || false, ask_diff_mode_on_launch: template.ask_diff_mode_on_launch || false,
@@ -655,7 +590,7 @@ const FormikApp = withFormik({
description: template.description || '', description: template.description || '',
job_type: template.job_type || 'run', job_type: template.job_type || 'run',
inventory: template.inventory || null, inventory: template.inventory || null,
project: template.project || '', project: template.project || null,
scm_branch: template.scm_branch || '', scm_branch: template.scm_branch || '',
playbook: template.playbook || '', playbook: template.playbook || '',
labels: summary_fields.labels.results || [], labels: summary_fields.labels.results || [],
@@ -672,7 +607,6 @@ const FormikApp = withFormik({
allow_simultaneous: template.allow_simultaneous || false, allow_simultaneous: template.allow_simultaneous || false,
use_fact_cache: template.use_fact_cache || false, use_fact_cache: template.use_fact_cache || false,
host_config_key: template.host_config_key || '', host_config_key: template.host_config_key || '',
organizationId: hasInventory,
initialInstanceGroups: [], initialInstanceGroups: [],
instanceGroups: [], instanceGroups: [],
credentials: summary_fields.credentials || [], credentials: summary_fields.credentials || [],

View File

@@ -14,7 +14,7 @@ describe('<JobTemplateForm />', () => {
description: 'Bar', description: 'Bar',
job_type: 'run', job_type: 'run',
inventory: 2, inventory: 2,
project: 3, project: { id: 3, summary_fields: { organization: { id: 1 } } },
playbook: 'Baz', playbook: 'Baz',
type: 'job_template', type: 'job_template',
scm_branch: 'Foo', scm_branch: 'Foo',

View File

@@ -5,15 +5,7 @@ import { t } from '@lingui/macro';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
function PlaybookSelect({ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
projectId,
isValid,
form,
field,
onBlur,
onError,
i18n,
}) {
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
useEffect(() => { useEffect(() => {
@@ -47,7 +39,6 @@ function PlaybookSelect({
id="template-playbook" id="template-playbook"
data={options} data={options}
isValid={isValid} isValid={isValid}
form={form}
{...field} {...field}
onBlur={onBlur} onBlur={onBlur}
/> />

View File

@@ -70,7 +70,7 @@ export const JobTemplate = shape({
inventory: number, inventory: number,
job_type: oneOf(['run', 'check']), job_type: oneOf(['run', 'check']),
playbook: string, playbook: string,
project: number, project: shape({}),
}); });
export const Inventory = shape({ export const Inventory = shape({