summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsoftwarefactory-project-zuul[bot] <33884098+softwarefactory-project-zuul[bot]@users.noreply.github.com>2020-05-08 16:12:03 +0200
committerGitHub <noreply@github.com>2020-05-08 16:12:03 +0200
commit8c57a92a654c713a8c6b79493c6c497aaa749668 (patch)
treea4e5f68d0b3d2f660042a5c4efaceace29f484d1
parentMerge pull request #6926 from nixocio/ui_issue_6887 (diff)
parentAdd cache timeout and inventory file validation (diff)
downloadawx-8c57a92a654c713a8c6b79493c6c497aaa749668.tar.xz
awx-8c57a92a654c713a8c6b79493c6c497aaa749668.zip
Merge pull request #6898 from marshmalien/6575-inv-src-add
Add inventory source create form Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
-rw-r--r--awx/ui_next/src/api/models/Projects.js5
-rw-r--r--awx/ui_next/src/components/FormField/CheckboxField.jsx4
-rw-r--r--awx/ui_next/src/components/Lookup/CredentialLookup.jsx38
-rw-r--r--awx/ui_next/src/screens/Inventory/Inventories.jsx9
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx65
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx152
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js1
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx6
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx4
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx5
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx11
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx33
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx224
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx144
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx109
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx110
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx147
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js1
18 files changed, 1040 insertions, 28 deletions
diff --git a/awx/ui_next/src/api/models/Projects.js b/awx/ui_next/src/api/models/Projects.js
index 3761c61961..269ef18f8a 100644
--- a/awx/ui_next/src/api/models/Projects.js
+++ b/awx/ui_next/src/api/models/Projects.js
@@ -11,6 +11,7 @@ class Projects extends SchedulesMixin(
this.baseUrl = '/api/v2/projects/';
this.readAccessList = this.readAccessList.bind(this);
+ this.readInventories = this.readInventories.bind(this);
this.readPlaybooks = this.readPlaybooks.bind(this);
this.readSync = this.readSync.bind(this);
this.sync = this.sync.bind(this);
@@ -20,6 +21,10 @@ class Projects extends SchedulesMixin(
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
}
+ readInventories(id) {
+ return this.http.get(`${this.baseUrl}${id}/inventories/`);
+ }
+
readPlaybooks(id) {
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
}
diff --git a/awx/ui_next/src/components/FormField/CheckboxField.jsx b/awx/ui_next/src/components/FormField/CheckboxField.jsx
index a04a78b02c..6a46bcaed3 100644
--- a/awx/ui_next/src/components/FormField/CheckboxField.jsx
+++ b/awx/ui_next/src/components/FormField/CheckboxField.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { string, func } from 'prop-types';
+import { string, func, node } from 'prop-types';
import { useField } from 'formik';
import { Checkbox, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
@@ -40,7 +40,7 @@ CheckboxField.propTypes = {
name: string.isRequired,
label: string.isRequired,
validate: func,
- tooltip: string,
+ tooltip: node,
};
CheckboxField.defaultProps = {
validate: () => {},
diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
index 50cf3c27e9..bb4629c697 100644
--- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
@@ -26,6 +26,7 @@ function CredentialLookup({
onChange,
required,
credentialTypeId,
+ credentialTypeKind,
value,
history,
i18n,
@@ -34,13 +35,19 @@ function CredentialLookup({
const [credentials, setCredentials] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
-
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
+ const typeIdParams = credentialTypeId
+ ? { credential_type: credentialTypeId }
+ : {};
+ const typeKindParams = credentialTypeKind
+ ? { credential_type__kind: credentialTypeKind }
+ : {};
+
try {
const { data } = await CredentialsAPI.read(
- mergeParams(params, { credential_type: credentialTypeId })
+ mergeParams(params, { ...typeIdParams, ...typeKindParams })
);
setCredentials(data.results);
setCount(data.count);
@@ -50,7 +57,7 @@ function CredentialLookup({
}
}
})();
- }, [credentialTypeId, history.location.search]);
+ }, [credentialTypeId, credentialTypeKind, history.location.search]);
// TODO: replace credential type search with REST-based grabbing of cred types
@@ -111,8 +118,29 @@ function CredentialLookup({
);
}
+function idOrKind(props, propName, componentName) {
+ let error;
+ if (
+ !Object.prototype.hasOwnProperty.call(props, 'credentialTypeId') &&
+ !Object.prototype.hasOwnProperty.call(props, 'credentialTypeKind')
+ )
+ error = new Error(
+ `Either "credentialTypeId" or "credentialTypeKind" is required`
+ );
+ if (
+ !Object.prototype.hasOwnProperty.call(props, 'credentialTypeId') &&
+ typeof props[propName] !== 'string'
+ ) {
+ error = new Error(
+ `Invalid prop '${propName}' '${props[propName]}' supplied to '${componentName}'.`
+ );
+ }
+ return error;
+}
+
CredentialLookup.propTypes = {
- credentialTypeId: oneOfType([number, string]).isRequired,
+ credentialTypeId: oneOfType([number, string]),
+ credentialTypeKind: idOrKind,
helperTextInvalid: node,
isValid: bool,
label: string.isRequired,
@@ -123,6 +151,8 @@ CredentialLookup.propTypes = {
};
CredentialLookup.defaultProps = {
+ credentialTypeId: '',
+ credentialTypeKind: '',
helperTextInvalid: '',
isValid: true,
onBlur: () => {},
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index e93269cf93..a5cedf5897 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -37,8 +37,9 @@ class Inventories extends Component {
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`;
- const inventoryHostsPath = `/inventories/${inventoryKind}/${inventory.id}/hosts`;
- const inventoryGroupsPath = `/inventories/${inventoryKind}/${inventory.id}/groups`;
+ const inventoryHostsPath = `${inventoryPath}/hosts`;
+ const inventoryGroupsPath = `${inventoryPath}/groups`;
+ const inventorySourcesPath = `${inventoryPath}/sources`;
const breadcrumbConfig = {
'/inventories': i18n._(t`Inventories`),
@@ -50,7 +51,6 @@ class Inventories extends Component {
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`),
[`${inventoryPath}/details`]: i18n._(t`Details`),
[`${inventoryPath}/edit`]: i18n._(t`Edit Details`),
- [`${inventoryPath}/sources`]: i18n._(t`Sources`),
[inventoryHostsPath]: i18n._(t`Hosts`),
[`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`),
@@ -74,6 +74,9 @@ class Inventories extends Component {
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
t`Create New Host`
),
+
+ [`${inventorySourcesPath}`]: i18n._(t`Sources`),
+ [`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`),
};
this.setState({ breadcrumbConfig });
};
diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx
new file mode 100644
index 0000000000..df12500a91
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx
@@ -0,0 +1,65 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory, useParams } from 'react-router-dom';
+import { InventorySourcesAPI } from '@api';
+import useRequest from '@util/useRequest';
+import { Card } from '@patternfly/react-core';
+import { CardBody } from '@components/Card';
+import InventorySourceForm from '../shared/InventorySourceForm';
+
+function InventorySourceAdd() {
+ const history = useHistory();
+ const { id } = useParams();
+
+ const { error, request, result } = useRequest(
+ useCallback(async values => {
+ const { data } = await InventorySourcesAPI.create(values);
+ return data;
+ }, [])
+ );
+
+ useEffect(() => {
+ if (result) {
+ history.push(
+ `/inventories/inventory/${result.inventory}/sources/${result.id}/details`
+ );
+ }
+ }, [result, history]);
+
+ const handleSubmit = async form => {
+ const { credential, source_path, source_project, ...remainingForm } = form;
+
+ const sourcePath = {};
+ const sourceProject = {};
+ if (form.source === 'scm') {
+ sourcePath.source_path =
+ source_path === '/ (project root)' ? '' : source_path;
+ sourceProject.source_project = source_project.id;
+ }
+
+ await request({
+ credential: credential?.id || null,
+ inventory: id,
+ ...sourcePath,
+ ...sourceProject,
+ ...remainingForm,
+ });
+ };
+
+ const handleCancel = () => {
+ history.push(`/inventories/inventory/${id}/sources`);
+ };
+
+ return (
+ <Card>
+ <CardBody>
+ <InventorySourceForm
+ onCancel={handleCancel}
+ onSubmit={handleSubmit}
+ submitError={error}
+ />
+ </CardBody>
+ </Card>
+ );
+}
+
+export default InventorySourceAdd;
diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx
new file mode 100644
index 0000000000..80bd08c902
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import InventorySourceAdd from './InventorySourceAdd';
+import { InventorySourcesAPI, ProjectsAPI } from '@api';
+
+jest.mock('@api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 111,
+ }),
+}));
+
+describe('<InventorySourceAdd />', () => {
+ let wrapper;
+ const invSourceData = {
+ credential: { id: 222 },
+ description: 'bar',
+ inventory: 111,
+ name: 'foo',
+ overwrite: false,
+ overwrite_vars: false,
+ source: 'scm',
+ source_path: 'mock/file.sh',
+ source_project: { id: 999 },
+ source_vars: '---↵',
+ update_cache_timeout: 0,
+ update_on_launch: false,
+ update_on_project_update: false,
+ verbosity: 1,
+ };
+
+ InventorySourcesAPI.readOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {
+ source: {
+ choices: [
+ ['file', 'File, Directory or Script'],
+ ['scm', 'Sourced from a Project'],
+ ['ec2', 'Amazon EC2'],
+ ['gce', 'Google Compute Engine'],
+ ['azure_rm', 'Microsoft Azure Resource Manager'],
+ ['vmware', 'VMware vCenter'],
+ ['satellite6', 'Red Hat Satellite 6'],
+ ['cloudforms', 'Red Hat CloudForms'],
+ ['openstack', 'OpenStack'],
+ ['rhv', 'Red Hat Virtualization'],
+ ['tower', 'Ansible Tower'],
+ ['custom', 'Custom Script'],
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ ProjectsAPI.readInventories.mockResolvedValue({
+ data: [],
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('new form displays primary form fields', async () => {
+ const config = {
+ custom_virtualenvs: ['venv/foo', 'venv/bar'],
+ };
+ await act(async () => {
+ wrapper = mountWithContexts(<InventorySourceAdd />, {
+ context: { config },
+ });
+ });
+ expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Ansible Environment"]')).toHaveLength(
+ 1
+ );
+ });
+
+ test('should navigate to inventory sources list when cancel is clicked', async () => {
+ const history = createMemoryHistory({});
+ await act(async () => {
+ wrapper = mountWithContexts(<InventorySourceAdd />, {
+ context: { router: { history } },
+ });
+ });
+ await act(async () => {
+ wrapper.find('InventorySourceForm').invoke('onCancel')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/111/sources'
+ );
+ });
+
+ test('should post to the api when submit is clicked', async () => {
+ InventorySourcesAPI.create.mockResolvedValueOnce({ data: {} });
+ await act(async () => {
+ wrapper = mountWithContexts(<InventorySourceAdd />);
+ });
+ await act(async () => {
+ wrapper.find('InventorySourceForm').invoke('onSubmit')(invSourceData);
+ });
+ expect(InventorySourcesAPI.create).toHaveBeenCalledTimes(1);
+ expect(InventorySourcesAPI.create).toHaveBeenCalledWith({
+ ...invSourceData,
+ credential: 222,
+ source_project: 999,
+ });
+ });
+
+ test('successful form submission should trigger redirect', async () => {
+ const history = createMemoryHistory({});
+ InventorySourcesAPI.create.mockResolvedValueOnce({
+ data: { id: 123, inventory: 111 },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(<InventorySourceAdd />, {
+ context: { router: { history } },
+ });
+ });
+ await act(async () => {
+ wrapper.find('InventorySourceForm').invoke('onSubmit')(invSourceData);
+ });
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/111/sources/123/details'
+ );
+ });
+
+ test('unsuccessful form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ InventorySourcesAPI.create.mockImplementation(() => Promise.reject(error));
+ await act(async () => {
+ wrapper = mountWithContexts(<InventorySourceAdd />);
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ await act(async () => {
+ wrapper.find('InventorySourceForm').invoke('onSubmit')({});
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js
new file mode 100644
index 0000000000..0a3020f194
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js
@@ -0,0 +1 @@
+export { default } from './InventorySourceAdd';
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx
index 65fe8656f6..0a759b94e6 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx
@@ -88,7 +88,7 @@ function InventorySourceList({ i18n }) {
const canAdd =
sourceChoicesOptions &&
Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST');
- const detailUrl = `/inventories/${inventoryType}/${id}/sources/`;
+ const listUrl = `/inventories/${inventoryType}/${id}/sources/`;
return (
<>
<PaginatedDataList
@@ -109,7 +109,7 @@ function InventorySourceList({ i18n }) {
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
- ? [<ToolbarAddButton key="add" linkTo={`${detailUrl}add`} />]
+ ? [<ToolbarAddButton key="add" linkTo={`${listUrl}add`} />]
: []),
<ToolbarDeleteButton
key="delete"
@@ -133,7 +133,7 @@ function InventorySourceList({ i18n }) {
source={inventorySource}
onSelect={() => handleSelect(inventorySource)}
label={label}
- detailUrl={`${detailUrl}${inventorySource.id}`}
+ detailUrl={`${listUrl}${inventorySource.id}`}
isSelected={selected.some(row => row.id === inventorySource.id)}
/>
);
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx
index b0500e6230..c253f4b83f 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx
@@ -158,7 +158,7 @@ describe('<InventorySourceList />', () => {
1
);
});
- test('displays error after unseccessful read sources fetch', async () => {
+ test('displays error after unsuccessful read sources fetch', async () => {
InventorySourcesAPI.readOptions.mockRejectedValue(
new Error({
response: {
@@ -193,7 +193,7 @@ describe('<InventorySourceList />', () => {
expect(wrapper.find('ContentError').length).toBe(1);
});
- test('displays error after unseccessful read options fetch', async () => {
+ test('displays error after unsuccessful read options fetch', async () => {
InventorySourcesAPI.readOptions.mockRejectedValue(
new Error({
response: {
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx
index c2455622ad..3fe2cdc1bd 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx
@@ -1,11 +1,14 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
-
+import InventorySourceAdd from '../InventorySourceAdd';
import InventorySourceList from './InventorySourceList';
function InventorySources() {
return (
<Switch>
+ <Route key="add" path="/inventories/inventory/:id/sources/add">
+ <InventorySourceAdd />
+ </Route>
<Route path="/inventories/:inventoryType/:id/sources">
<InventorySourceList />
</Route>
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx
new file mode 100644
index 0000000000..dba54734da
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import InventorySources from './InventorySources';
+
+describe('<InventorySources />', () => {
+ test('initially renders without crashing', () => {
+ const wrapper = shallow(<InventorySources />);
+ expect(wrapper.length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx
index f0add60fe9..1cc056d5a4 100644
--- a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx
@@ -1,9 +1,11 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryForm from './InventoryForm';
+jest.mock('@api');
+
const inventory = {
id: 1,
type: 'inventory',
@@ -50,22 +52,27 @@ describe('<InventoryForm />', () => {
let wrapper;
let onCancel;
let onSubmit;
- beforeEach(() => {
+
+ beforeAll(async () => {
onCancel = jest.fn();
onSubmit = jest.fn();
- wrapper = mountWithContexts(
- <InventoryForm
- onCancel={onCancel}
- onSubmit={onSubmit}
- inventory={inventory}
- instanceGroups={instanceGroups}
- credentialTypeId={14}
- />
- );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <InventoryForm
+ onCancel={onCancel}
+ onSubmit={onSubmit}
+ inventory={inventory}
+ instanceGroups={instanceGroups}
+ credentialTypeId={14}
+ />
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
- afterEach(() => {
+ afterAll(() => {
wrapper.unmount();
+ jest.clearAllMocks();
});
test('Initially renders successfully', () => {
@@ -83,7 +90,7 @@ describe('<InventoryForm />', () => {
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
});
- test('should update form values', async () => {
+ test('should update form values', () => {
act(() => {
wrapper.find('OrganizationLookup').invoke('onBlur')();
wrapper.find('OrganizationLookup').invoke('onChange')({
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
new file mode 100644
index 0000000000..3e76f1b223
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
@@ -0,0 +1,224 @@
+import React, { useEffect, useCallback, useContext } from 'react';
+import { Formik, useField, useFormikContext } from 'formik';
+import { func, shape } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { InventorySourcesAPI } from '@api';
+import { ConfigContext } from '@contexts/Config';
+import useRequest from '@util/useRequest';
+import { required } from '@util/validators';
+
+import { Form, FormGroup, Title } from '@patternfly/react-core';
+import AnsibleSelect from '@components/AnsibleSelect';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
+import FormField, {
+ FieldTooltip,
+ FormSubmitError,
+} from '@components/FormField';
+import { FormColumnLayout, SubFormLayout } from '@components/FormLayout';
+
+import SCMSubForm from './InventorySourceSubForms';
+
+const InventorySourceFormFields = ({ sourceOptions, i18n }) => {
+ const { values, initialValues, resetForm } = useFormikContext();
+ const [sourceField, sourceMeta] = useField({
+ name: 'source',
+ validate: required(i18n._(t`Set a value for this field`), i18n),
+ });
+ const { custom_virtualenvs } = useContext(ConfigContext);
+ const [venvField] = useField('custom_virtualenv');
+ const defaultVenv = {
+ label: i18n._(t`Use Default Ansible Environment`),
+ value: '/venv/ansible/',
+ key: 'default',
+ };
+
+ const resetSubFormFields = sourceType => {
+ resetForm({
+ values: {
+ ...initialValues,
+ name: values.name,
+ description: values.description,
+ custom_virtualenv: values.custom_virtualenv,
+ source: sourceType,
+ },
+ });
+ };
+
+ return (
+ <>
+ <FormField
+ id="name"
+ label={i18n._(t`Name`)}
+ name="name"
+ type="text"
+ validate={required(null, i18n)}
+ isRequired
+ />
+ <FormField
+ id="description"
+ label={i18n._(t`Description`)}
+ name="description"
+ type="text"
+ />
+ <FormGroup
+ fieldId="source"
+ helperTextInvalid={sourceMeta.error}
+ isRequired
+ isValid={!sourceMeta.touched || !sourceMeta.error}
+ label={i18n._(t`Source`)}
+ >
+ <AnsibleSelect
+ {...sourceField}
+ id="source"
+ data={[
+ {
+ value: '',
+ key: '',
+ label: i18n._(t`Choose a source`),
+ isDisabled: true,
+ },
+ ...sourceOptions,
+ ]}
+ onChange={(event, value) => {
+ resetSubFormFields(value);
+ }}
+ />
+ </FormGroup>
+ {custom_virtualenvs && custom_virtualenvs.length > 1 && (
+ <FormGroup
+ fieldId="custom-virtualenv"
+ label={i18n._(t`Ansible Environment`)}
+ >
+ <FieldTooltip
+ content={i18n._(t`Select the custom
+ Python virtual environment for this
+ inventory source sync to run on.`)}
+ />
+ <AnsibleSelect
+ id="custom-virtualenv"
+ data={[
+ defaultVenv,
+ ...custom_virtualenvs
+ .filter(value => value !== defaultVenv.value)
+ .map(value => ({ value, label: value, key: value })),
+ ]}
+ {...venvField}
+ />
+ </FormGroup>
+ )}
+
+ {sourceField.value !== '' && (
+ <SubFormLayout>
+ <Title size="md">{i18n._(t`Source details`)}</Title>
+ <FormColumnLayout>
+ {
+ {
+ scm: <SCMSubForm />,
+ }[sourceField.value]
+ }
+ </FormColumnLayout>
+ </SubFormLayout>
+ )}
+ </>
+ );
+};
+
+const InventorySourceForm = ({
+ i18n,
+ onCancel,
+ onSubmit,
+ submitError = null,
+}) => {
+ const initialValues = {
+ credential: null,
+ custom_virtualenv: '',
+ description: '',
+ name: '',
+ overwrite: false,
+ overwrite_vars: false,
+ source: '',
+ source_path: '',
+ source_project: null,
+ source_vars: '---\n',
+ update_cache_timeout: 0,
+ update_on_launch: false,
+ update_on_project_update: false,
+ verbosity: 1,
+ };
+
+ const {
+ isLoading: isSourceOptionsLoading,
+ error: sourceOptionsError,
+ request: fetchSourceOptions,
+ result: sourceOptions,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await InventorySourcesAPI.readOptions();
+ const sourceChoices = Object.assign(
+ ...data.actions.GET.source.choices.map(([key, val]) => ({ [key]: val }))
+ );
+ delete sourceChoices.file;
+ return Object.keys(sourceChoices).map(choice => {
+ return {
+ value: choice,
+ key: choice,
+ label: sourceChoices[choice],
+ };
+ });
+ }, []),
+ []
+ );
+
+ useEffect(() => {
+ fetchSourceOptions();
+ }, [fetchSourceOptions]);
+
+ if (isSourceOptionsLoading) {
+ return <ContentLoading />;
+ }
+
+ if (sourceOptionsError) {
+ return <ContentError error={sourceOptionsError} />;
+ }
+
+ return (
+ <Formik
+ initialValues={initialValues}
+ onSubmit={values => {
+ onSubmit(values);
+ }}
+ >
+ {formik => (
+ <Form autoComplete="off" onSubmit={formik.handleSubmit}>
+ <FormColumnLayout>
+ <InventorySourceFormFields
+ formik={formik}
+ i18n={i18n}
+ sourceOptions={sourceOptions}
+ />
+ {submitError && <FormSubmitError error={submitError} />}
+ <FormActionGroup
+ onCancel={onCancel}
+ onSubmit={formik.handleSubmit}
+ />
+ </FormColumnLayout>
+ </Form>
+ )}
+ </Formik>
+ );
+};
+
+InventorySourceForm.propTypes = {
+ onCancel: func.isRequired,
+ onSubmit: func.isRequired,
+ submitError: shape({}),
+};
+
+InventorySourceForm.defaultProps = {
+ submitError: null,
+};
+
+export default withI18n()(InventorySourceForm);
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx
new file mode 100644
index 0000000000..d79cf457c9
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import InventorySourceForm from './InventorySourceForm';
+import { InventorySourcesAPI, ProjectsAPI, CredentialsAPI } from '@api';
+
+jest.mock('@api/models/Credentials');
+jest.mock('@api/models/InventorySources');
+jest.mock('@api/models/Projects');
+
+describe('<InventorySourceForm />', () => {
+ let wrapper;
+ CredentialsAPI.read.mockResolvedValue({
+ data: { count: 0, results: [] },
+ });
+ ProjectsAPI.readInventories.mockResolvedValue({
+ data: ['foo', 'bar'],
+ });
+ InventorySourcesAPI.readOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {
+ source: {
+ choices: [
+ ['file', 'File, Directory or Script'],
+ ['scm', 'Sourced from a Project'],
+ ['ec2', 'Amazon EC2'],
+ ['gce', 'Google Compute Engine'],
+ ['azure_rm', 'Microsoft Azure Resource Manager'],
+ ['vmware', 'VMware vCenter'],
+ ['satellite6', 'Red Hat Satellite 6'],
+ ['cloudforms', 'Red Hat CloudForms'],
+ ['openstack', 'OpenStack'],
+ ['rhv', 'Red Hat Virtualization'],
+ ['tower', 'Ansible Tower'],
+ ['custom', 'Custom Script'],
+ ],
+ },
+ },
+ },
+ },
+ });
+
+ describe('Successful form submission', () => {
+ const onSubmit = jest.fn();
+
+ beforeAll(async () => {
+ const config = {
+ custom_virtualenvs: ['venv/foo', 'venv/bar'],
+ };
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <InventorySourceForm onCancel={() => {}} onSubmit={onSubmit} />,
+ {
+ context: { config },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should initially display primary form fields', () => {
+ expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1);
+ expect(
+ wrapper.find('FormGroup[label="Ansible Environment"]')
+ ).toHaveLength(1);
+ });
+
+ test('should display subform when source dropdown has a value', async () => {
+ await act(async () => {
+ wrapper.find('AnsibleSelect#source').prop('onChange')(null, 'scm');
+ });
+ wrapper.update();
+ expect(wrapper.find('Title').text()).toBe('Source details');
+ });
+
+ test('should show field error when form is invalid', async () => {
+ expect(onSubmit).not.toHaveBeenCalled();
+ await act(async () => {
+ wrapper.find('CredentialLookup').invoke('onChange')({
+ id: 1,
+ name: 'mock cred',
+ });
+ wrapper.find('ProjectLookup').invoke('onChange')({
+ id: 2,
+ name: 'mock proj',
+ });
+ wrapper.find('AnsibleSelect#source_path').prop('onChange')(null, 'foo');
+ wrapper.find('AnsibleSelect#verbosity').prop('onChange')(null, '2');
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ wrapper.update();
+ expect(wrapper.find('FormGroup[label="Name"] .pf-m-error')).toHaveLength(
+ 1
+ );
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ test('should call onSubmit when Save button is clicked', async () => {
+ expect(onSubmit).not.toHaveBeenCalled();
+ wrapper.find('input#name').simulate('change', {
+ target: { value: 'new foo', name: 'name' },
+ });
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ wrapper.update();
+ expect(onSubmit).toHaveBeenCalled();
+ });
+ });
+
+ test('should display ContentError on throw', async () => {
+ InventorySourcesAPI.readOptions.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <InventorySourceForm onCancel={() => {}} onSubmit={() => {}} />
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+
+ test('calls "onCancel" when Cancel button is clicked', async () => {
+ const onCancel = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <InventorySourceForm onCancel={onCancel} onSubmit={() => {}} />
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(onCancel).not.toHaveBeenCalled();
+ wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ expect(onCancel).toBeCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx
new file mode 100644
index 0000000000..dbc36f19ca
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx
@@ -0,0 +1,109 @@
+import React, { useCallback } from 'react';
+import { useField } from 'formik';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { ProjectsAPI } from '@api';
+import useRequest from '@util/useRequest';
+import { required } from '@util/validators';
+
+import { FormGroup } from '@patternfly/react-core';
+import AnsibleSelect from '@components/AnsibleSelect';
+import { FieldTooltip } from '@components/FormField';
+import CredentialLookup from '@components/Lookup/CredentialLookup';
+import ProjectLookup from '@components/Lookup/ProjectLookup';
+import { VerbosityField, OptionsField, SourceVarsField } from './SharedFields';
+
+const SCMSubForm = ({ i18n }) => {
+ const [credentialField, , credentialHelpers] = useField('credential');
+ const [projectField, projectMeta, projectHelpers] = useField({
+ name: 'source_project',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+ const [sourcePathField, sourcePathMeta, sourcePathHelpers] = useField({
+ name: 'source_path',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+
+ const {
+ error: sourcePathError,
+ request: fetchSourcePath,
+ result: sourcePath,
+ } = useRequest(
+ useCallback(async projectId => {
+ const { data } = await ProjectsAPI.readInventories(projectId);
+ return [...data, '/ (project root)'];
+ }, []),
+ []
+ );
+
+ const handleProjectUpdate = useCallback(
+ value => {
+ sourcePathHelpers.setValue('');
+ projectHelpers.setValue(value);
+ fetchSourcePath(value.id);
+ },
+ [] // eslint-disable-line react-hooks/exhaustive-deps
+ );
+
+ return (
+ <>
+ <CredentialLookup
+ credentialTypeKind="cloud"
+ label={i18n._(t`Credential`)}
+ value={credentialField.value}
+ onChange={value => {
+ credentialHelpers.setValue(value);
+ }}
+ />
+ <ProjectLookup
+ value={projectField.value}
+ isValid={!projectMeta.touched || !projectMeta.error}
+ helperTextInvalid={projectMeta.error}
+ onBlur={() => projectHelpers.setTouched()}
+ onChange={handleProjectUpdate}
+ required
+ />
+ <FormGroup
+ fieldId="source_path"
+ helperTextInvalid={sourcePathError?.message || sourcePathMeta.error}
+ isValid={
+ (!sourcePathMeta.error || !sourcePathMeta.touched) &&
+ !sourcePathError?.message
+ }
+ isRequired
+ label={i18n._(t`Inventory file`)}
+ >
+ <FieldTooltip
+ content={i18n._(t`Select the inventory file
+ to be synced by this source. You can select from
+ the dropdown or enter a file within the input.`)}
+ />
+ <AnsibleSelect
+ {...sourcePathField}
+ id="source_path"
+ isValid={
+ (!sourcePathMeta.error || !sourcePathMeta.touched) &&
+ !sourcePathError?.message
+ }
+ data={[
+ {
+ value: '',
+ key: '',
+ label: i18n._(t`Choose an inventory file`),
+ isDisabled: true,
+ },
+ ...sourcePath.map(value => ({ value, label: value, key: value })),
+ ]}
+ onChange={(event, value) => {
+ sourcePathHelpers.setValue(value);
+ }}
+ />
+ </FormGroup>
+ <VerbosityField />
+ <OptionsField />
+ <SourceVarsField />
+ </>
+ );
+};
+
+export default withI18n()(SCMSubForm);
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx
new file mode 100644
index 0000000000..78ec81ad55
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { Formik } from 'formik';
+import SCMSubForm from './SCMSubForm';
+import { ProjectsAPI, CredentialsAPI } from '@api';
+
+jest.mock('@api/models/Projects');
+jest.mock('@api/models/Credentials');
+
+const initialValues = {
+ credential: null,
+ custom_virtualenv: '',
+ overwrite: false,
+ overwrite_vars: false,
+ source_path: '',
+ source_project: null,
+ source_vars: '---\n',
+ update_cache_timeout: 0,
+ update_on_launch: false,
+ update_on_project_update: false,
+ verbosity: 1,
+};
+
+describe('<SCMSubForm />', () => {
+ let wrapper;
+ CredentialsAPI.read.mockResolvedValue({
+ data: { count: 0, results: [] },
+ });
+ ProjectsAPI.readInventories.mockResolvedValue({
+ data: ['foo', 'bar'],
+ });
+ ProjectsAPI.read.mockResolvedValue({
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'mock proj one',
+ },
+ {
+ id: 2,
+ name: 'mock proj two',
+ },
+ ],
+ },
+ });
+
+ beforeAll(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <Formik initialValues={initialValues}>
+ <SCMSubForm />
+ </Formik>
+ );
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should render subform fields', () => {
+ expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Project"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Inventory file"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1);
+ expect(
+ wrapper.find('VariablesField[label="Environment variables"]')
+ ).toHaveLength(1);
+ });
+
+ test('project lookup should fetch project source path list', async () => {
+ expect(ProjectsAPI.readInventories).not.toHaveBeenCalled();
+ await act(async () => {
+ wrapper.find('ProjectLookup').invoke('onChange')({
+ id: 2,
+ name: 'mock proj two',
+ });
+ wrapper.find('ProjectLookup').invoke('onBlur')();
+ });
+ expect(ProjectsAPI.readInventories).toHaveBeenCalledWith(2);
+ });
+
+ test('changing source project should reset source path dropdown', async () => {
+ expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual('');
+
+ await act(async () => {
+ await wrapper.find('AnsibleSelect#source_path').prop('onChange')(
+ null,
+ 'bar'
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual(
+ 'bar'
+ );
+
+ await act(async () => {
+ wrapper.find('ProjectLookup').invoke('onChange')({
+ id: 1,
+ name: 'mock proj one',
+ });
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual('');
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx
new file mode 100644
index 0000000000..92d0933073
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx
@@ -0,0 +1,147 @@
+import React, { useEffect } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { useField } from 'formik';
+import { minMaxValue } from '@util/validators';
+import { FormGroup } from '@patternfly/react-core';
+import AnsibleSelect from '@components/AnsibleSelect';
+import { VariablesField } from '@components/CodeMirrorInput';
+import FormField, { CheckboxField, FieldTooltip } from '@components/FormField';
+import {
+ FormFullWidthLayout,
+ FormCheckboxLayout,
+} from '@components/FormLayout';
+
+export const SourceVarsField = withI18n()(({ i18n }) => (
+ <FormFullWidthLayout>
+ <VariablesField
+ id="source_vars"
+ name="source_vars"
+ label={i18n._(t`Environment variables`)}
+ />
+ </FormFullWidthLayout>
+));
+
+export const VerbosityField = withI18n()(({ i18n }) => {
+ const [field, meta, helpers] = useField('verbosity');
+ const isValid = !(meta.touched && meta.error);
+ const options = [
+ { value: '0', key: '0', label: i18n._(t`0 (Warning)`) },
+ { value: '1', key: '1', label: i18n._(t`1 (Info)`) },
+ { value: '2', key: '2', label: i18n._(t`2 (Debug)`) },
+ ];
+ return (
+ <FormGroup
+ fieldId="verbosity"
+ isValid={isValid}
+ label={i18n._(t`Verbosity`)}
+ >
+ <FieldTooltip
+ content={i18n._(t`Control the level of output Ansible
+ will produce for inventory source update jobs.`)}
+ />
+ <AnsibleSelect
+ id="verbosity"
+ data={options}
+ {...field}
+ onChange={(event, value) => helpers.setValue(value)}
+ />
+ </FormGroup>
+ );
+});
+
+export const OptionsField = withI18n()(({ i18n }) => {
+ const [updateOnLaunchField] = useField('update_on_launch');
+ const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout');
+
+ useEffect(() => {
+ if (!updateOnLaunchField.value) {
+ updateCacheTimeoutHelper.setValue(0);
+ }
+ }, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+ <>
+ <FormFullWidthLayout>
+ <FormGroup
+ fieldId="option-checkboxes"
+ label={i18n._(t`Update options`)}
+ >
+ <FormCheckboxLayout>
+ <CheckboxField
+ id="overwrite"
+ name="overwrite"
+ label={i18n._(t`Overwrite`)}
+ tooltip={
+ <>
+ {i18n._(t`If checked, any hosts and groups that were
+ previously present on the external source but are now removed
+ will be removed from the Tower inventory. Hosts and groups
+ that were not managed by the inventory source will be promoted
+ to the next manually created group or if there is no manually
+ created group to promote them into, they will be left in the "all"
+ default group for the inventory.`)}
+ <br />
+ <br />
+ {i18n._(t`When not checked, local child
+ hosts and groups not found on the external source will remain
+ untouched by the inventory update process.`)}
+ </>
+ }
+ />
+ <CheckboxField
+ id="overwrite_vars"
+ name="overwrite_vars"
+ label={i18n._(t`Overwrite variables`)}
+ tooltip={
+ <>
+ {i18n._(t`If checked, all variables for child groups
+ and hosts will be removed and replaced by those found
+ on the external source.`)}
+ <br />
+ <br />
+ {i18n._(t`When not checked, a merge will be performed,
+ combining local variables with those found on the
+ external source.`)}
+ </>
+ }
+ />
+ <CheckboxField
+ id="update_on_launch"
+ name="update_on_launch"
+ label={i18n._(t`Update on launch`)}
+ tooltip={i18n._(t`Each time a job runs using this inventory,
+ refresh the inventory from the selected source before
+ executing job tasks.`)}
+ />
+ <CheckboxField
+ id="update_on_project_update"
+ name="update_on_project_update"
+ label={i18n._(t`Update on project update`)}
+ tooltip={i18n._(t`After every project update where the SCM revision
+ changes, refresh the inventory from the selected source
+ before executing job tasks. This is intended for static content,
+ like the Ansible inventory .ini file format.`)}
+ />
+ </FormCheckboxLayout>
+ </FormGroup>
+ </FormFullWidthLayout>
+ {updateOnLaunchField.value && (
+ <FormField
+ id="cache-timeout"
+ name="update_cache_timeout"
+ type="number"
+ min="0"
+ max="2147483647"
+ validate={minMaxValue(0, 2147483647, i18n)}
+ label={i18n._(t`Cache timeout (seconds)`)}
+ tooltip={i18n._(t`Time in seconds to consider an inventory sync
+ to be current. During job runs and callbacks the task system will
+ evaluate the timestamp of the latest sync. If it is older than
+ Cache Timeout, it is not considered current, and a new
+ inventory sync will be performed.`)}
+ />
+ )}
+ </>
+ );
+});
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js
new file mode 100644
index 0000000000..79640d3a4f
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js
@@ -0,0 +1 @@
+export { default } from './SCMSubForm';