Merge pull request #7287 from marshmalien/6899-inv-src-subform

Hook up all inventory source subforms

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-06-17 18:44:30 +00:00
committed by GitHub
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'), 'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
'inventory_source': ('source', 'last_updated', 'status'), 'inventory_source': ('source', 'last_updated', 'status'),
'custom_inventory_script': DEFAULT_SUMMARY_FIELDS, 'custom_inventory_script': DEFAULT_SUMMARY_FIELDS,
'source_script': ('name', 'description'), 'source_script': DEFAULT_SUMMARY_FIELDS,
'role': ('id', 'role_field'), 'role': ('id', 'role_field'),
'notification_template': DEFAULT_SUMMARY_FIELDS, 'notification_template': DEFAULT_SUMMARY_FIELDS,
'instance_group': ('id', 'name', 'controller_id', 'is_containerized'), '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 Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups'; import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories'; import Inventories from './models/Inventories';
import InventoryScripts from './models/InventoryScripts';
import InventorySources from './models/InventorySources'; import InventorySources from './models/InventorySources';
import InventoryUpdates from './models/InventoryUpdates'; import InventoryUpdates from './models/InventoryUpdates';
import JobTemplates from './models/JobTemplates'; import JobTemplates from './models/JobTemplates';
@@ -41,6 +42,7 @@ const GroupsAPI = new Groups();
const HostsAPI = new Hosts(); const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups(); const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories(); const InventoriesAPI = new Inventories();
const InventoryScriptsAPI = new InventoryScripts();
const InventorySourcesAPI = new InventorySources(); const InventorySourcesAPI = new InventorySources();
const InventoryUpdatesAPI = new InventoryUpdates(); const InventoryUpdatesAPI = new InventoryUpdates();
const JobTemplatesAPI = new JobTemplates(); const JobTemplatesAPI = new JobTemplates();
@@ -75,6 +77,7 @@ export {
HostsAPI, HostsAPI,
InstanceGroupsAPI, InstanceGroupsAPI,
InventoriesAPI, InventoriesAPI,
InventoryScriptsAPI,
InventorySourcesAPI, InventorySourcesAPI,
InventoryUpdatesAPI, InventoryUpdatesAPI,
JobTemplatesAPI, 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, required,
credentialTypeId, credentialTypeId,
credentialTypeKind, credentialTypeKind,
credentialTypeNamespace,
value, value,
history, history,
i18n, i18n,
@@ -46,15 +47,27 @@ function CredentialLookup({
const typeKindParams = credentialTypeKind const typeKindParams = credentialTypeKind
? { credential_type__kind: credentialTypeKind } ? { credential_type__kind: credentialTypeKind }
: {}; : {};
const typeNamespaceParams = credentialTypeNamespace
? { credential_type__namespace: credentialTypeNamespace }
: {};
const { data } = await CredentialsAPI.read( const { data } = await CredentialsAPI.read(
mergeParams(params, { ...typeIdParams, ...typeKindParams }) mergeParams(params, {
...typeIdParams,
...typeKindParams,
...typeNamespaceParams,
})
); );
return { return {
count: data.count, count: data.count,
credentials: data.results, credentials: data.results,
}; };
}, [credentialTypeId, credentialTypeKind, history.location.search]), }, [
credentialTypeId,
credentialTypeKind,
credentialTypeNamespace,
history.location.search,
]),
{ {
count: 0, count: 0,
credentials: [], 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 React, { useState } from 'react';
import { func, string } from 'prop-types'; import { func, string } from 'prop-types';
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
import { arrayToString, stringToArray } from '../../util/strings';
function arrayToString(tags) {
return tags.join(',');
}
function stringToArray(value) {
return value.split(',').filter(val => !!val);
}
function TagMultiSelect({ onChange, value }) { function TagMultiSelect({ onChange, value }) {
const selections = stringToArray(value); const selections = stringToArray(value);

View File

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

View File

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

View File

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

View File

@@ -22,10 +22,34 @@ import {
SubFormLayout, SubFormLayout,
} from '../../../components/FormLayout'; } 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 InventorySourceFormFields = ({ sourceOptions, i18n }) => {
const { values, initialValues, resetForm } = useFormikContext(); const {
values,
initialValues,
resetForm,
setFieldValue,
} = useFormikContext();
const [sourceField, sourceMeta] = useField({ const [sourceField, sourceMeta] = useField({
name: 'source', name: 'source',
validate: required(i18n._(t`Set a value for this field`), i18n), validate: required(i18n._(t`Set a value for this field`), i18n),
@@ -39,15 +63,38 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
}; };
const resetSubFormFields = sourceType => { const resetSubFormFields = sourceType => {
resetForm({ if (sourceType === initialValues.source) {
values: { resetForm({
...initialValues, values: {
name: values.name, ...initialValues,
description: values.description, name: values.name,
custom_virtualenv: values.custom_virtualenv, 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: 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 ( return (
@@ -83,7 +130,7 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
label: i18n._(t`Choose a source`), label: i18n._(t`Choose a source`),
isDisabled: true, isDisabled: true,
}, },
...sourceOptions, ...buildSourceChoiceOptions(sourceOptions),
]} ]}
onChange={(event, value) => { onChange={(event, value) => {
resetSubFormFields(value); resetSubFormFields(value);
@@ -112,14 +159,23 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
/> />
</FormGroup> </FormGroup>
)} )}
{sourceField.value !== '' && ( {sourceField.value !== '' && (
<SubFormLayout> <SubFormLayout>
<Title size="md">{i18n._(t`Source details`)}</Title> <Title size="md">{i18n._(t`Source details`)}</Title>
<FormColumnLayout> <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 />, scm: <SCMSubForm />,
tower: <TowerSubForm />,
vmware: <VMwareSubForm sourceOptions={sourceOptions} />,
}[sourceField.value] }[sourceField.value]
} }
</FormColumnLayout> </FormColumnLayout>
@@ -140,12 +196,16 @@ const InventorySourceForm = ({
credential: source?.summary_fields?.credential || null, credential: source?.summary_fields?.credential || null,
custom_virtualenv: source?.custom_virtualenv || '', custom_virtualenv: source?.custom_virtualenv || '',
description: source?.description || '', description: source?.description || '',
group_by: source?.group_by || '',
instance_filters: source?.instance_filters || '',
name: source?.name || '', name: source?.name || '',
overwrite: source?.overwrite || false, overwrite: source?.overwrite || false,
overwrite_vars: source?.overwrite_vars || false, overwrite_vars: source?.overwrite_vars || false,
source: source?.source || '', source: source?.source || '',
source_path: source?.source_path === '' ? '/ (project root)' : '', source_path: source?.source_path === '' ? '/ (project root)' : '',
source_project: source?.summary_fields?.source_project || null, 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', source_vars: source?.source_vars || '---\n',
update_cache_timeout: source?.update_cache_timeout || 0, update_cache_timeout: source?.update_cache_timeout || 0,
update_on_launch: source?.update_on_launch || false, update_on_launch: source?.update_on_launch || false,
@@ -161,17 +221,7 @@ const InventorySourceForm = ({
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await InventorySourcesAPI.readOptions(); const { data } = await InventorySourcesAPI.readOptions();
const sourceChoices = Object.assign( return data;
...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],
};
});
}, []), }, []),
null 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> </FormGroup>
<VerbosityField /> <VerbosityField />
<OptionsField /> <OptionsField showProjectUpdate />
<SourceVarsField /> <SourceVarsField />
</> </>
); );

View File

@@ -11,13 +11,17 @@ jest.mock('../../../../api/models/Credentials');
const initialValues = { const initialValues = {
credential: null, credential: null,
custom_virtualenv: '', custom_virtualenv: '',
group_by: '',
instance_filters: '',
overwrite: false, overwrite: false,
overwrite_vars: false, overwrite_vars: false,
source_path: '', source_path: '',
source_project: null, source_project: null,
source_regions: '',
source_script: null,
source_vars: '---\n', source_vars: '---\n',
update_cache_timeout: 0, update_cache_timeout: 0,
update_on_launch: false, update_on_launch: true,
update_on_project_update: false, update_on_project_update: false,
verbosity: 1, verbosity: 1,
}; };
@@ -68,7 +72,10 @@ describe('<SCMSubForm />', () => {
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
expect( 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); ).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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import { useField } from 'formik'; 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 { minMaxValue } from '../../../../util/validators';
import { BrandName } from '../../../../variables';
import AnsibleSelect from '../../../../components/AnsibleSelect'; import AnsibleSelect from '../../../../components/AnsibleSelect';
import { VariablesField } from '../../../../components/CodeMirrorInput'; import { VariablesField } from '../../../../components/CodeMirrorInput';
import FormField, { import FormField, {
@@ -20,11 +27,197 @@ export const SourceVarsField = withI18n()(({ i18n }) => (
<VariablesField <VariablesField
id="source_vars" id="source_vars"
name="source_vars" name="source_vars"
label={i18n._(t`Environment variables`)} label={i18n._(t`Source variables`)}
/> />
</FormFullWidthLayout> </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 }) => { export const VerbosityField = withI18n()(({ i18n }) => {
const [field, meta, helpers] = useField('verbosity'); const [field, meta, helpers] = useField('verbosity');
const isValid = !(meta.touched && meta.error); const isValid = !(meta.touched && meta.error);
@@ -53,98 +246,148 @@ export const VerbosityField = withI18n()(({ i18n }) => {
); );
}); });
export const OptionsField = withI18n()(({ i18n }) => { export const OptionsField = withI18n()(
const [updateOnLaunchField] = useField('update_on_launch'); ({ i18n, showProjectUpdate = false }) => {
const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout'); const [updateOnLaunchField] = useField('update_on_launch');
const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout');
useEffect(() => { useEffect(() => {
if (!updateOnLaunchField.value) { if (!updateOnLaunchField.value) {
updateCacheTimeoutHelper.setValue(0); updateCacheTimeoutHelper.setValue(0);
} }
}, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps }, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<> <>
<FormFullWidthLayout> <FormFullWidthLayout>
<FormGroup <FormGroup
fieldId="option-checkboxes" fieldId="option-checkboxes"
label={i18n._(t`Update options`)} label={i18n._(t`Update options`)}
> >
<FormCheckboxLayout> <FormCheckboxLayout>
<CheckboxField <CheckboxField
id="overwrite" id="overwrite"
name="overwrite" name="overwrite"
label={i18n._(t`Overwrite`)} label={i18n._(t`Overwrite`)}
tooltip={ tooltip={
<> <>
{i18n._(t`If checked, any hosts and groups that were {i18n._(t`If checked, any hosts and groups that were
previously present on the external source but are now removed previously present on the external source but are now removed
will be removed from the Tower inventory. Hosts and groups will be removed from the Tower inventory. Hosts and groups
that were not managed by the inventory source will be promoted that were not managed by the inventory source will be promoted
to the next manually created group or if there is no manually 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" created group to promote them into, they will be left in the "all"
default group for the inventory.`)} default group for the inventory.`)}
<br /> <br />
<br /> <br />
{i18n._(t`When not checked, local child {i18n._(t`When not checked, local child
hosts and groups not found on the external source will remain hosts and groups not found on the external source will remain
untouched by the inventory update process.`)} untouched by the inventory update process.`)}
</> </>
} }
/> />
<CheckboxField <CheckboxField
id="overwrite_vars" id="overwrite_vars"
name="overwrite_vars" name="overwrite_vars"
label={i18n._(t`Overwrite variables`)} label={i18n._(t`Overwrite variables`)}
tooltip={ tooltip={
<> <>
{i18n._(t`If checked, all variables for child groups {i18n._(t`If checked, all variables for child groups
and hosts will be removed and replaced by those found and hosts will be removed and replaced by those found
on the external source.`)} on the external source.`)}
<br /> <br />
<br /> <br />
{i18n._(t`When not checked, a merge will be performed, {i18n._(t`When not checked, a merge will be performed,
combining local variables with those found on the combining local variables with those found on the
external source.`)} external source.`)}
</> </>
} }
/> />
<CheckboxField <CheckboxField
id="update_on_launch" id="update_on_launch"
name="update_on_launch" name="update_on_launch"
label={i18n._(t`Update on launch`)} label={i18n._(t`Update on launch`)}
tooltip={i18n._(t`Each time a job runs using this inventory, tooltip={i18n._(t`Each time a job runs using this inventory,
refresh the inventory from the selected source before refresh the inventory from the selected source before
executing job tasks.`)} executing job tasks.`)}
/> />
<CheckboxField {showProjectUpdate && (
id="update_on_project_update" <CheckboxField
name="update_on_project_update" id="update_on_project_update"
label={i18n._(t`Update on project update`)} name="update_on_project_update"
tooltip={i18n._(t`After every project update where the SCM revision label={i18n._(t`Update on project update`)}
changes, refresh the inventory from the selected source tooltip={i18n._(t`After every project update where the SCM revision
before executing job tasks. This is intended for static content, changes, refresh the inventory from the selected source
like the Ansible inventory .ini file format.`)} before executing job tasks. This is intended for static content,
/> like the Ansible inventory .ini file format.`)}
</FormCheckboxLayout> />
</FormGroup> )}
</FormFullWidthLayout> </FormCheckboxLayout>
{updateOnLaunchField.value && ( </FormGroup>
<FormField </FormFullWidthLayout>
id="cache-timeout" {updateOnLaunchField.value && (
name="update_cache_timeout" <FormField
type="number" id="cache-timeout"
min="0" name="update_cache_timeout"
max="2147483647" type="number"
validate={minMaxValue(0, 2147483647, i18n)} min="0"
label={i18n._(t`Cache timeout (seconds)`)} max="2147483647"
tooltip={i18n._(t`Time in seconds to consider an inventory sync 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 to be current. During job runs and callbacks the task system will
evaluate the timestamp of the latest sync. If it is older than evaluate the timestamp of the latest sync. If it is older than
Cache Timeout, it is not considered current, and a new Cache Timeout, it is not considered current, and a new
inventory sync will be performed.`)} 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, total_inventory_sources: number,
}); });
export const InventoryScript = shape({
description: string,
id: number.isRequired,
name: string,
});
export const InstanceGroup = shape({ export const InstanceGroup = shape({
id: number.isRequired, id: number.isRequired,
name: string.isRequired, name: string.isRequired,

View File

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