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,6 +63,7 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
}; };
const resetSubFormFields = sourceType => { const resetSubFormFields = sourceType => {
if (sourceType === initialValues.source) {
resetForm({ resetForm({
values: { values: {
...initialValues, ...initialValues,
@@ -48,6 +73,28 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
source: sourceType, 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 ( 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,7 +246,8 @@ export const VerbosityField = withI18n()(({ i18n }) => {
); );
}); });
export const OptionsField = withI18n()(({ i18n }) => { export const OptionsField = withI18n()(
({ i18n, showProjectUpdate = false }) => {
const [updateOnLaunchField] = useField('update_on_launch'); const [updateOnLaunchField] = useField('update_on_launch');
const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout'); const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout');
@@ -117,6 +311,7 @@ export const OptionsField = withI18n()(({ i18n }) => {
refresh the inventory from the selected source before refresh the inventory from the selected source before
executing job tasks.`)} executing job tasks.`)}
/> />
{showProjectUpdate && (
<CheckboxField <CheckboxField
id="update_on_project_update" id="update_on_project_update"
name="update_on_project_update" name="update_on_project_update"
@@ -126,6 +321,7 @@ export const OptionsField = withI18n()(({ i18n }) => {
before executing job tasks. This is intended for static content, before executing job tasks. This is intended for static content,
like the Ansible inventory .ini file format.`)} like the Ansible inventory .ini file format.`)}
/> />
)}
</FormCheckboxLayout> </FormCheckboxLayout>
</FormGroup> </FormGroup>
</FormFullWidthLayout> </FormFullWidthLayout>
@@ -147,4 +343,51 @@ export const OptionsField = withI18n()(({ i18n }) => {
)} )}
</> </>
); );
}
);
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);