1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 15:21:13 +03:00

Add inventory source subforms

This commit is contained in:
Marliana Lara 2020-06-08 13:44:46 -04:00
parent 97dbfee162
commit 6ed611c27c
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
36 changed files with 1769 additions and 117 deletions

View File

@ -126,7 +126,7 @@ SUMMARIZABLE_FK_FIELDS = {
'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
'inventory_source': ('source', 'last_updated', 'status'),
'custom_inventory_script': DEFAULT_SUMMARY_FIELDS,
'source_script': ('name', 'description'),
'source_script': DEFAULT_SUMMARY_FIELDS,
'role': ('id', 'role_field'),
'notification_template': DEFAULT_SUMMARY_FIELDS,
'instance_group': ('id', 'name', 'controller_id', 'is_containerized'),

View File

@ -8,6 +8,7 @@ import Groups from './models/Groups';
import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories';
import InventoryScripts from './models/InventoryScripts';
import InventorySources from './models/InventorySources';
import InventoryUpdates from './models/InventoryUpdates';
import JobTemplates from './models/JobTemplates';
@ -41,6 +42,7 @@ const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories();
const InventoryScriptsAPI = new InventoryScripts();
const InventorySourcesAPI = new InventorySources();
const InventoryUpdatesAPI = new InventoryUpdates();
const JobTemplatesAPI = new JobTemplates();
@ -75,6 +77,7 @@ export {
HostsAPI,
InstanceGroupsAPI,
InventoriesAPI,
InventoryScriptsAPI,
InventorySourcesAPI,
InventoryUpdatesAPI,
JobTemplatesAPI,

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class InventoryScripts extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/inventory_scripts/';
}
}
export default InventoryScripts;

View File

@ -28,6 +28,7 @@ function CredentialLookup({
required,
credentialTypeId,
credentialTypeKind,
credentialTypeNamespace,
value,
history,
i18n,
@ -46,15 +47,27 @@ function CredentialLookup({
const typeKindParams = credentialTypeKind
? { credential_type__kind: credentialTypeKind }
: {};
const typeNamespaceParams = credentialTypeNamespace
? { credential_type__namespace: credentialTypeNamespace }
: {};
const { data } = await CredentialsAPI.read(
mergeParams(params, { ...typeIdParams, ...typeKindParams })
mergeParams(params, {
...typeIdParams,
...typeKindParams,
...typeNamespaceParams,
})
);
return {
count: data.count,
credentials: data.results,
};
}, [credentialTypeId, credentialTypeKind, history.location.search]),
}, [
credentialTypeId,
credentialTypeKind,
credentialTypeNamespace,
history.location.search,
]),
{
count: 0,
credentials: [],

View File

@ -0,0 +1,137 @@
import React, { useCallback, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { func, bool, number, node, string, oneOfType } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage';
import OptionsList from '../OptionsList';
import { InventoriesAPI, InventoryScriptsAPI } from '../../api';
import { InventoryScript } from '../../types';
import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
const QS_CONFIG = getQSConfig('inventory_scripts', {
order_by: 'name',
page: 1,
page_size: 5,
role_level: 'admin_role',
});
function InventoryScriptLookup({
helperTextInvalid,
history,
i18n,
inventoryId,
isValid,
onBlur,
onChange,
required,
value,
}) {
const {
result: { count, inventoryScripts },
error,
request: fetchInventoryScripts,
} = useRequest(
useCallback(async () => {
const parsedParams = parseQueryString(QS_CONFIG, history.location.search);
const {
data: { organization },
} = await InventoriesAPI.readDetail(inventoryId);
const { data } = await InventoryScriptsAPI.read(
mergeParams(parsedParams, { organization })
);
return {
count: data.count,
inventoryScripts: data.results,
};
}, [history.location.search, inventoryId]),
{
count: 0,
inventoryScripts: [],
}
);
useEffect(() => {
fetchInventoryScripts();
}, [fetchInventoryScripts]);
return (
<FormGroup
fieldId="inventory-script"
helperTextInvalid={helperTextInvalid}
isRequired={required}
isValid={isValid}
label={i18n._(t`Inventory script`)}
>
<Lookup
id="inventory-script-lookup"
header={i18n._(t`Inventory script`)}
value={value}
onChange={onChange}
onBlur={onBlur}
required={required}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
header={i18n._(t`Inventory script`)}
multiple={state.multiple}
name="inventory-script"
optionCount={count}
options={inventoryScripts}
qsConfig={QS_CONFIG}
readOnly={!canDelete}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
value={state.selectedItems}
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',
},
]}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
}
InventoryScriptLookup.propTypes = {
helperTextInvalid: node,
inventoryId: oneOfType([number, string]).isRequired,
isValid: bool,
onBlur: func,
onChange: func.isRequired,
required: bool,
value: InventoryScript,
};
InventoryScriptLookup.defaultProps = {
helperTextInvalid: '',
isValid: true,
onBlur: () => {},
required: false,
value: null,
};
export default withI18n()(withRouter(InventoryScriptLookup));

View File

@ -1,14 +1,7 @@
import React, { useState } from 'react';
import { func, string } from 'prop-types';
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
function arrayToString(tags) {
return tags.join(',');
}
function stringToArray(value) {
return value.split(',').filter(val => !!val);
}
import { arrayToString, stringToArray } from '../../util/strings';
function TagMultiSelect({ onChange, value }) {
const selections = stringToArray(value);

View File

@ -26,7 +26,13 @@ function InventorySourceAdd() {
}, [result, history]);
const handleSubmit = async form => {
const { credential, source_path, source_project, ...remainingForm } = form;
const {
credential,
source_path,
source_project,
source_script,
...remainingForm
} = form;
const sourcePath = {};
const sourceProject = {};
@ -39,6 +45,7 @@ function InventorySourceAdd() {
await request({
credential: credential?.id || null,
inventory: id,
source_script: source_script?.id || null,
...sourcePath,
...sourceProject,
...remainingForm,

View File

@ -115,6 +115,7 @@ describe('<InventorySourceAdd />', () => {
...invSourceData,
credential: 222,
source_project: 999,
source_script: null,
});
});

View File

@ -29,7 +29,13 @@ function InventorySourceEdit({ source }) {
}, [result, detailsUrl, history]);
const handleSubmit = async form => {
const { credential, source_path, source_project, ...remainingForm } = form;
const {
credential,
source_path,
source_project,
source_script,
...remainingForm
} = form;
const sourcePath = {};
const sourceProject = {};
@ -38,9 +44,11 @@ function InventorySourceEdit({ source }) {
source_path === '/ (project root)' ? '' : source_path;
sourceProject.source_project = source_project.id;
}
await request({
credential: credential?.id || null,
inventory: id,
source_script: source_script?.id || null,
...sourcePath,
...sourceProject,
...remainingForm,

View File

@ -22,10 +22,34 @@ import {
SubFormLayout,
} from '../../../components/FormLayout';
import SCMSubForm from './InventorySourceSubForms';
import {
AzureSubForm,
CloudFormsSubForm,
CustomScriptSubForm,
EC2SubForm,
GCESubForm,
OpenStackSubForm,
SCMSubForm,
SatelliteSubForm,
TowerSubForm,
VMwareSubForm,
VirtualizationSubForm,
} from './InventorySourceSubForms';
const buildSourceChoiceOptions = options => {
const sourceChoices = options.actions.GET.source.choices.map(
([choice, label]) => ({ label, key: choice, value: choice })
);
return sourceChoices.filter(({ key }) => key !== 'file');
};
const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
const { values, initialValues, resetForm } = useFormikContext();
const {
values,
initialValues,
resetForm,
setFieldValue,
} = useFormikContext();
const [sourceField, sourceMeta] = useField({
name: 'source',
validate: required(i18n._(t`Set a value for this field`), i18n),
@ -39,15 +63,38 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
};
const resetSubFormFields = sourceType => {
resetForm({
values: {
...initialValues,
name: values.name,
description: values.description,
custom_virtualenv: values.custom_virtualenv,
if (sourceType === initialValues.source) {
resetForm({
values: {
...initialValues,
name: values.name,
description: values.description,
custom_virtualenv: values.custom_virtualenv,
source: sourceType,
},
});
} else {
const defaults = {
credential: null,
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source: sourceType,
},
});
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: false,
update_on_project_update: false,
verbosity: 1,
};
Object.keys(defaults).forEach(label => {
setFieldValue(label, defaults[label]);
});
}
};
return (
@ -83,7 +130,7 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
label: i18n._(t`Choose a source`),
isDisabled: true,
},
...sourceOptions,
...buildSourceChoiceOptions(sourceOptions),
]}
onChange={(event, value) => {
resetSubFormFields(value);
@ -112,14 +159,23 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
/>
</FormGroup>
)}
{sourceField.value !== '' && (
<SubFormLayout>
<Title size="md">{i18n._(t`Source details`)}</Title>
<FormColumnLayout>
{
{
azure_rm: <AzureSubForm sourceOptions={sourceOptions} />,
cloudforms: <CloudFormsSubForm />,
custom: <CustomScriptSubForm />,
ec2: <EC2SubForm sourceOptions={sourceOptions} />,
gce: <GCESubForm sourceOptions={sourceOptions} />,
openstack: <OpenStackSubForm />,
rhv: <VirtualizationSubForm />,
satellite6: <SatelliteSubForm />,
scm: <SCMSubForm />,
tower: <TowerSubForm />,
vmware: <VMwareSubForm sourceOptions={sourceOptions} />,
}[sourceField.value]
}
</FormColumnLayout>
@ -140,12 +196,16 @@ const InventorySourceForm = ({
credential: source?.summary_fields?.credential || null,
custom_virtualenv: source?.custom_virtualenv || '',
description: source?.description || '',
group_by: source?.group_by || '',
instance_filters: source?.instance_filters || '',
name: source?.name || '',
overwrite: source?.overwrite || false,
overwrite_vars: source?.overwrite_vars || false,
source: source?.source || '',
source_path: source?.source_path === '' ? '/ (project root)' : '',
source_project: source?.summary_fields?.source_project || null,
source_regions: source?.source_regions || '',
source_script: source?.summary_fields?.source_script || null,
source_vars: source?.source_vars || '---\n',
update_cache_timeout: source?.update_cache_timeout || 0,
update_on_launch: source?.update_on_launch || false,
@ -161,17 +221,7 @@ const InventorySourceForm = ({
} = useRequest(
useCallback(async () => {
const { data } = await InventorySourcesAPI.readOptions();
const sourceChoices = Object.assign(
...data.actions.GET.source.choices.map(([key, val]) => ({ [key]: val }))
);
delete sourceChoices.file;
return Object.keys(sourceChoices).map(choice => {
return {
value: choice,
key: choice,
label: sourceChoices[choice],
};
});
return data;
}, []),
null
);

View File

@ -0,0 +1,44 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import {
OptionsField,
RegionsField,
SourceVarsField,
VerbosityField,
} from './SharedFields';
const AzureSubForm = ({ i18n, sourceOptions }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="azure_rm"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<RegionsField
regionOptions={
sourceOptions?.actions?.POST?.source_regions?.azure_rm_region_choices
}
/>
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(AzureSubForm);

View File

@ -0,0 +1,81 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import AzureSubForm from './AzureSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
const mockSourceOptions = {
actions: {
POST: {
source_regions: {
azure_rm_region_choices: [],
},
},
},
};
describe('<AzureSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<AzureSubForm sourceOptions={mockSourceOptions} />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Regions"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'azure_rm',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields';
const CloudFormsSubForm = ({ i18n }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="cloudforms"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(CloudFormsSubForm);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import CloudFormsSubForm from './CloudFormsSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
describe('<CloudFormsSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<CloudFormsSubForm />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'cloudforms',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,43 @@
import React from 'react';
import { useField } from 'formik';
import { useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import InventoryScriptLookup from '../../../../components/Lookup/InventoryScriptLookup';
import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields';
const CustomScriptSubForm = ({ i18n }) => {
const { id } = useParams();
const [credentialField, , credentialHelpers] = useField('credential');
const [scriptField, scriptMeta, scriptHelpers] = useField('source_script');
return (
<>
<CredentialLookup
credentialTypeNamespace="cloud"
label={i18n._(t`Credential`)}
value={credentialField.value}
onChange={value => {
credentialHelpers.setValue(value);
}}
/>
<InventoryScriptLookup
helperTextInvalid={scriptMeta.error}
isValid={!scriptMeta.touched || !scriptMeta.error}
onBlur={() => scriptHelpers.setTouched()}
onChange={value => {
scriptHelpers.setValue(value);
}}
inventoryId={id}
value={scriptField.value}
required
/>
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(CustomScriptSubForm);

View File

@ -0,0 +1,100 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import CustomScriptSubForm from './CustomScriptSubForm';
import {
CredentialsAPI,
InventoriesAPI,
InventoryScriptsAPI,
} from '../../../../api';
jest.mock('../../../../api/models/Credentials');
jest.mock('../../../../api/models/Inventories');
jest.mock('../../../../api/models/InventoryScripts');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 789,
}),
}));
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
describe('<CustomScriptSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
InventoriesAPI.readDetail.mockResolvedValue({
data: { organization: 123 },
});
InventoryScriptsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<CustomScriptSubForm />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Inventory script"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'cloud',
order_by: 'name',
page: 1,
page_size: 5,
});
expect(InventoriesAPI.readDetail).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.readDetail).toHaveBeenCalledWith(789);
expect(InventoryScriptsAPI.read).toHaveBeenCalledTimes(1);
expect(InventoryScriptsAPI.read).toHaveBeenCalledWith({
organization: 123,
role_level: 'admin_role',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,48 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import {
GroupByField,
InstanceFiltersField,
OptionsField,
RegionsField,
SourceVarsField,
VerbosityField,
} from './SharedFields';
const EC2SubForm = ({ i18n, sourceOptions }) => {
const [credentialField, , credentialHelpers] = useField('credential');
const groupByOptionsObj = Object.assign(
{},
...sourceOptions?.actions?.POST?.group_by?.ec2_group_by_choices.map(
([key, val]) => ({ [key]: val })
)
);
return (
<>
<CredentialLookup
credentialTypeNamespace="aws"
label={i18n._(t`Credential`)}
value={credentialField.value}
onChange={value => {
credentialHelpers.setValue(value);
}}
/>
<RegionsField
regionOptions={
sourceOptions?.actions?.POST?.source_regions?.ec2_region_choices
}
/>
<InstanceFiltersField />
<GroupByField fixedOptions={groupByOptionsObj} />
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(EC2SubForm);

View File

@ -0,0 +1,86 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import EC2SubForm from './EC2SubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
const mockSourceOptions = {
actions: {
POST: {
source_regions: {
ec2_region_choices: [],
},
group_by: {
ec2_group_by_choices: [],
},
},
},
};
describe('<EC2SubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<EC2SubForm sourceOptions={mockSourceOptions} />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Regions"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Instance filters"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Only group by"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'aws',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,38 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { OptionsField, RegionsField, VerbosityField } from './SharedFields';
const GCESubForm = ({ i18n, sourceOptions }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="gce"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<RegionsField
regionOptions={
sourceOptions?.actions?.POST?.source_regions?.gce_region_choices
}
/>
<VerbosityField />
<OptionsField />
</>
);
};
export default withI18n()(GCESubForm);

View File

@ -0,0 +1,78 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import GCESubForm from './GCESubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
const mockSourceOptions = {
actions: {
POST: {
source_regions: {
gce_region_choices: [],
},
},
},
};
describe('<GCESubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<GCESubForm sourceOptions={mockSourceOptions} />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Regions"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'gce',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields';
const OpenStackSubForm = ({ i18n }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="openstack"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(OpenStackSubForm);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import OpenStackSubForm from './OpenStackSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
describe('<OpenStackSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<OpenStackSubForm />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'openstack',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -117,7 +117,7 @@ const SCMSubForm = ({ i18n }) => {
/>
</FormGroup>
<VerbosityField />
<OptionsField />
<OptionsField showProjectUpdate />
<SourceVarsField />
</>
);

View File

@ -11,13 +11,17 @@ jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: false,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
@ -68,7 +72,10 @@ describe('<SCMSubForm />', () => {
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Environment variables"]')
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields';
const SatelliteSubForm = ({ i18n }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="satellite6"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(SatelliteSubForm);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import SatelliteSubForm from './SatelliteSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
describe('<SatelliteSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<SatelliteSubForm />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'satellite6',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -1,9 +1,16 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import { useField } from 'formik';
import { FormGroup } from '@patternfly/react-core';
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core';
import { arrayToString, stringToArray } from '../../../../util/strings';
import { minMaxValue } from '../../../../util/validators';
import { BrandName } from '../../../../variables';
import AnsibleSelect from '../../../../components/AnsibleSelect';
import { VariablesField } from '../../../../components/CodeMirrorInput';
import FormField, {
@ -20,11 +27,197 @@ export const SourceVarsField = withI18n()(({ i18n }) => (
<VariablesField
id="source_vars"
name="source_vars"
label={i18n._(t`Environment variables`)}
label={i18n._(t`Source variables`)}
/>
</FormFullWidthLayout>
));
export const RegionsField = withI18n()(({ i18n, regionOptions }) => {
const [field, meta, helpers] = useField('source_regions');
const [isOpen, setIsOpen] = useState(false);
const options = Object.assign(
{},
...regionOptions.map(([key, val]) => ({ [key]: val }))
);
const selected = stringToArray(field?.value)
.filter(i => options[i])
.map(val => options[val]);
return (
<FormGroup
fieldId="regions"
helperTextInvalid={meta.error}
validated="default"
label={i18n._(t`Regions`)}
>
<FieldTooltip
content={
<Trans>
Click on the regions field to see a list of regions for your cloud
provider. You can select multiple regions, or choose
<em> All</em> to include all regions. Only Hosts associated with the
selected regions will be updated.
</Trans>
}
/>
<Select
variant={SelectVariant.typeaheadMulti}
id="regions"
onToggle={setIsOpen}
onClear={() => helpers.setValue('')}
onSelect={(event, option) => {
let selectedValues;
if (selected.includes(option)) {
selectedValues = selected.filter(o => o !== option);
} else {
selectedValues = selected.concat(option);
}
const selectedKeys = selectedValues.map(val =>
Object.keys(options).find(key => options[key] === val)
);
helpers.setValue(arrayToString(selectedKeys));
}}
isExpanded={isOpen}
placeholderText={i18n._(t`Select a region`)}
selections={selected}
>
{regionOptions.map(([key, val]) => (
<SelectOption key={key} value={val} />
))}
</Select>
</FormGroup>
);
});
export const GroupByField = withI18n()(
({ i18n, fixedOptions, isCreatable = false }) => {
const [field, meta, helpers] = useField('group_by');
const fixedOptionLabels = fixedOptions && Object.values(fixedOptions);
const selections = fixedOptions
? stringToArray(field.value).map(o => fixedOptions[o])
: stringToArray(field.value);
const [options, setOptions] = useState(selections);
const [isOpen, setIsOpen] = useState(false);
const renderOptions = opts => {
return opts.map(option => (
<SelectOption key={option} value={option}>
{option}
</SelectOption>
));
};
const handleFilter = event => {
const str = event.target.value.toLowerCase();
let matches;
if (fixedOptions) {
matches = fixedOptionLabels.filter(o => o.toLowerCase().includes(str));
} else {
matches = options.filter(o => o.toLowerCase().includes(str));
}
return renderOptions(matches);
};
const handleSelect = (e, option) => {
let selectedValues;
if (selections.includes(option)) {
selectedValues = selections.filter(o => o !== option);
} else {
selectedValues = selections.concat(option);
}
if (fixedOptions) {
selectedValues = selectedValues.map(val =>
Object.keys(fixedOptions).find(key => fixedOptions[key] === val)
);
}
helpers.setValue(arrayToString(selectedValues));
};
return (
<FormGroup
fieldId="group-by"
helperTextInvalid={meta.error}
validated="default"
label={i18n._(t`Only group by`)}
>
<FieldTooltip
content={
<Trans>
Select which groups to create automatically. AWX will create group
names similar to the following examples based on the options
selected:
<br />
<br />
<ul>
<li>
Availability Zone: <strong>zones &raquo; us-east-1b</strong>
</li>
<li>
Image ID: <strong>images &raquo; ami-b007ab1e</strong>
</li>
<li>
Instance ID: <strong>instances &raquo; i-ca11ab1e </strong>
</li>
<li>
Instance Type: <strong>types &raquo; type_m1_medium</strong>
</li>
<li>
Key Name: <strong>keys &raquo; key_testing</strong>
</li>
<li>
Region: <strong>regions &raquo; us-east-1</strong>
</li>
<li>
Security Group:{' '}
<strong>
security_groups &raquo; security_group_default
</strong>
</li>
<li>
Tags: <strong>tags &raquo; tag_Name_host1</strong>
</li>
<li>
VPC ID: <strong>vpcs &raquo; vpc-5ca1ab1e</strong>
</li>
<li>
Tag None: <strong>tags &raquo; tag_none</strong>
</li>
</ul>
<br />
If blank, all groups above are created except <em>Instance ID</em>
.
</Trans>
}
/>
<Select
variant={SelectVariant.typeaheadMulti}
id="group-by"
onToggle={setIsOpen}
onClear={() => helpers.setValue('')}
isCreatable={isCreatable}
createText={i18n._(t`Create`)}
onCreateOption={name => {
name = name.trim();
if (!options.find(opt => opt === name)) {
setOptions(options.concat(name));
}
return name;
}}
onFilter={handleFilter}
onSelect={handleSelect}
isExpanded={isOpen}
placeholderText={i18n._(t`Select a group`)}
selections={selections}
>
{fixedOptions
? renderOptions(fixedOptionLabels)
: renderOptions(options)}
</Select>
</FormGroup>
);
}
);
export const VerbosityField = withI18n()(({ i18n }) => {
const [field, meta, helpers] = useField('verbosity');
const isValid = !(meta.touched && meta.error);
@ -53,98 +246,148 @@ export const VerbosityField = withI18n()(({ i18n }) => {
);
});
export const OptionsField = withI18n()(({ i18n }) => {
const [updateOnLaunchField] = useField('update_on_launch');
const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout');
export const OptionsField = withI18n()(
({ i18n, showProjectUpdate = false }) => {
const [updateOnLaunchField] = useField('update_on_launch');
const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout');
useEffect(() => {
if (!updateOnLaunchField.value) {
updateCacheTimeoutHelper.setValue(0);
}
}, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!updateOnLaunchField.value) {
updateCacheTimeoutHelper.setValue(0);
}
}, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<FormFullWidthLayout>
<FormGroup
fieldId="option-checkboxes"
label={i18n._(t`Update options`)}
>
<FormCheckboxLayout>
<CheckboxField
id="overwrite"
name="overwrite"
label={i18n._(t`Overwrite`)}
tooltip={
<>
{i18n._(t`If checked, any hosts and groups that were
return (
<>
<FormFullWidthLayout>
<FormGroup
fieldId="option-checkboxes"
label={i18n._(t`Update options`)}
>
<FormCheckboxLayout>
<CheckboxField
id="overwrite"
name="overwrite"
label={i18n._(t`Overwrite`)}
tooltip={
<>
{i18n._(t`If checked, any hosts and groups that were
previously present on the external source but are now removed
will be removed from the Tower inventory. Hosts and groups
that were not managed by the inventory source will be promoted
to the next manually created group or if there is no manually
created group to promote them into, they will be left in the "all"
default group for the inventory.`)}
<br />
<br />
{i18n._(t`When not checked, local child
<br />
<br />
{i18n._(t`When not checked, local child
hosts and groups not found on the external source will remain
untouched by the inventory update process.`)}
</>
}
/>
<CheckboxField
id="overwrite_vars"
name="overwrite_vars"
label={i18n._(t`Overwrite variables`)}
tooltip={
<>
{i18n._(t`If checked, all variables for child groups
</>
}
/>
<CheckboxField
id="overwrite_vars"
name="overwrite_vars"
label={i18n._(t`Overwrite variables`)}
tooltip={
<>
{i18n._(t`If checked, all variables for child groups
and hosts will be removed and replaced by those found
on the external source.`)}
<br />
<br />
{i18n._(t`When not checked, a merge will be performed,
<br />
<br />
{i18n._(t`When not checked, a merge will be performed,
combining local variables with those found on the
external source.`)}
</>
}
/>
<CheckboxField
id="update_on_launch"
name="update_on_launch"
label={i18n._(t`Update on launch`)}
tooltip={i18n._(t`Each time a job runs using this inventory,
</>
}
/>
<CheckboxField
id="update_on_launch"
name="update_on_launch"
label={i18n._(t`Update on launch`)}
tooltip={i18n._(t`Each time a job runs using this inventory,
refresh the inventory from the selected source before
executing job tasks.`)}
/>
<CheckboxField
id="update_on_project_update"
name="update_on_project_update"
label={i18n._(t`Update on project update`)}
tooltip={i18n._(t`After every project update where the SCM revision
changes, refresh the inventory from the selected source
before executing job tasks. This is intended for static content,
like the Ansible inventory .ini file format.`)}
/>
</FormCheckboxLayout>
</FormGroup>
</FormFullWidthLayout>
{updateOnLaunchField.value && (
<FormField
id="cache-timeout"
name="update_cache_timeout"
type="number"
min="0"
max="2147483647"
validate={minMaxValue(0, 2147483647, i18n)}
label={i18n._(t`Cache timeout (seconds)`)}
tooltip={i18n._(t`Time in seconds to consider an inventory sync
/>
{showProjectUpdate && (
<CheckboxField
id="update_on_project_update"
name="update_on_project_update"
label={i18n._(t`Update on project update`)}
tooltip={i18n._(t`After every project update where the SCM revision
changes, refresh the inventory from the selected source
before executing job tasks. This is intended for static content,
like the Ansible inventory .ini file format.`)}
/>
)}
</FormCheckboxLayout>
</FormGroup>
</FormFullWidthLayout>
{updateOnLaunchField.value && (
<FormField
id="cache-timeout"
name="update_cache_timeout"
type="number"
min="0"
max="2147483647"
validate={minMaxValue(0, 2147483647, i18n)}
label={i18n._(t`Cache timeout (seconds)`)}
tooltip={i18n._(t`Time in seconds to consider an inventory sync
to be current. During job runs and callbacks the task system will
evaluate the timestamp of the latest sync. If it is older than
Cache Timeout, it is not considered current, and a new
inventory sync will be performed.`)}
/>
)}
</>
/>
)}
</>
);
}
);
export const InstanceFiltersField = withI18n()(({ i18n }) => {
// Setting BrandName to a variable here is necessary to get the jest tests
// passing. Attempting to use BrandName in the template literal results
// in failing tests.
const brandName = BrandName;
return (
<FormField
id="instance-filters"
label={i18n._(t`Instance filters`)}
name="instance_filters"
type="text"
tooltip={
<Trans>
Provide a comma-separated list of filter expressions. Hosts are
imported to {brandName} when <em>ANY</em> of the filters match.
<br />
<br />
Limit to hosts having a tag:
<br />
tag-key=TowerManaged
<br />
<br />
Limit to hosts using either key pair:
<br />
key-name=staging, key-name=production
<br />
<br />
Limit to hosts where the Name tag begins with <em>test</em>:<br />
tag:Name=test*
<br />
<br />
View the
<a
href="http://docs.aws.amazon.com/AWSEC2/latest/APIReference/ApiReference-query-DescribeInstances.html\"
target="_blank\"
>
{' '}
Describe Instances documentation{' '}
</a>
for a complete list of supported filters.
</Trans>
}
/>
);
});

View File

@ -0,0 +1,38 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import {
InstanceFiltersField,
OptionsField,
VerbosityField,
} from './SharedFields';
const TowerSubForm = ({ i18n }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="tower"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<InstanceFiltersField />
<VerbosityField />
<OptionsField />
</>
);
};
export default withI18n()(TowerSubForm);

View File

@ -0,0 +1,68 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import TowerSubForm from './TowerSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
describe('<TowerSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<TowerSubForm />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Instance filters"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'tower',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,42 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import {
InstanceFiltersField,
GroupByField,
OptionsField,
SourceVarsField,
VerbosityField,
} from './SharedFields';
const VMwareSubForm = ({ i18n }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="vmware"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<InstanceFiltersField />
<GroupByField isCreatable />
<VerbosityField />
<OptionsField />
<SourceVarsField />
</>
);
};
export default withI18n()(VMwareSubForm);

View File

@ -0,0 +1,82 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import VMwareSubForm from './VMwareSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
const mockSourceOptions = {
actions: {
POST: {
source_regions: {
gce_region_choices: [],
},
},
},
};
describe('<VMwareSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<VMwareSubForm sourceOptions={mockSourceOptions} />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Instance filters"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Only group by"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
expect(
wrapper.find('VariablesField[label="Source variables"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'vmware',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -0,0 +1,33 @@
import React from 'react';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { OptionsField, VerbosityField } from './SharedFields';
const VirtualizationSubForm = ({ i18n }) => {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
return (
<>
<CredentialLookup
credentialTypeNamespace="rhv"
label={i18n._(t`Credential`)}
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
/>
<VerbosityField />
<OptionsField />
</>
);
};
export default withI18n()(VirtualizationSubForm);

View File

@ -0,0 +1,67 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import VirtualizationSubForm from './VirtualizationSubForm';
import { CredentialsAPI } from '../../../../api';
jest.mock('../../../../api/models/Credentials');
const initialValues = {
credential: null,
custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false,
overwrite_vars: false,
source_path: '',
source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
describe('<VirtualizationSubForm />', () => {
let wrapper;
CredentialsAPI.read.mockResolvedValue({
data: { count: 0, results: [] },
});
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={initialValues}>
<VirtualizationSubForm />
</Formik>
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render subform fields', () => {
expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect(
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
).toHaveLength(1);
});
test('should make expected api calls', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type__namespace: 'rhv',
order_by: 'name',
page: 1,
page_size: 5,
});
});
});

View File

@ -1 +1,11 @@
export { default } from './SCMSubForm';
export { default as AzureSubForm } from './AzureSubForm';
export { default as CloudFormsSubForm } from './CloudFormsSubForm';
export { default as CustomScriptSubForm } from './CustomScriptSubForm';
export { default as EC2SubForm } from './EC2SubForm';
export { default as GCESubForm } from './GCESubForm';
export { default as OpenStackSubForm } from './OpenStackSubForm';
export { default as SCMSubForm } from './SCMSubForm';
export { default as SatelliteSubForm } from './SatelliteSubForm';
export { default as TowerSubForm } from './TowerSubForm';
export { default as VMwareSubForm } from './VMwareSubForm';
export { default as VirtualizationSubForm } from './VirtualizationSubForm';

View File

@ -107,6 +107,12 @@ export const Inventory = shape({
total_inventory_sources: number,
});
export const InventoryScript = shape({
description: string,
id: number.isRequired,
name: string,
});
export const InstanceGroup = shape({
id: number.isRequired,
name: string.isRequired,

View File

@ -17,3 +17,7 @@ export const toTitleCase = string => {
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
export const arrayToString = value => value.join(',');
export const stringToArray = value => value.split(',').filter(val => !!val);