mirror of
https://github.com/ansible/awx.git
synced 2026-03-20 02:17:37 -02:30
Add inventory source add form
This commit is contained in:
@@ -11,6 +11,7 @@ class Projects extends SchedulesMixin(
|
|||||||
this.baseUrl = '/api/v2/projects/';
|
this.baseUrl = '/api/v2/projects/';
|
||||||
|
|
||||||
this.readAccessList = this.readAccessList.bind(this);
|
this.readAccessList = this.readAccessList.bind(this);
|
||||||
|
this.readInventories = this.readInventories.bind(this);
|
||||||
this.readPlaybooks = this.readPlaybooks.bind(this);
|
this.readPlaybooks = this.readPlaybooks.bind(this);
|
||||||
this.readSync = this.readSync.bind(this);
|
this.readSync = this.readSync.bind(this);
|
||||||
this.sync = this.sync.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 });
|
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readInventories(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/inventories/`);
|
||||||
|
}
|
||||||
|
|
||||||
readPlaybooks(id) {
|
readPlaybooks(id) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
|
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function CredentialLookup({
|
|||||||
onChange,
|
onChange,
|
||||||
required,
|
required,
|
||||||
credentialTypeId,
|
credentialTypeId,
|
||||||
|
credentialTypeKind,
|
||||||
value,
|
value,
|
||||||
history,
|
history,
|
||||||
i18n,
|
i18n,
|
||||||
@@ -34,13 +35,19 @@ function CredentialLookup({
|
|||||||
const [credentials, setCredentials] = useState([]);
|
const [credentials, setCredentials] = useState([]);
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
const typeIdParams = credentialTypeId
|
||||||
|
? { credential_type: credentialTypeId }
|
||||||
|
: {};
|
||||||
|
const typeKindParams = credentialTypeKind
|
||||||
|
? { credential_type__kind: credentialTypeKind }
|
||||||
|
: {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await CredentialsAPI.read(
|
const { data } = await CredentialsAPI.read(
|
||||||
mergeParams(params, { credential_type: credentialTypeId })
|
mergeParams(params, { ...typeIdParams, ...typeKindParams })
|
||||||
);
|
);
|
||||||
setCredentials(data.results);
|
setCredentials(data.results);
|
||||||
setCount(data.count);
|
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
|
// 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 = {
|
CredentialLookup.propTypes = {
|
||||||
credentialTypeId: oneOfType([number, string]).isRequired,
|
credentialTypeId: oneOfType([number, string]),
|
||||||
|
credentialTypeKind: idOrKind,
|
||||||
helperTextInvalid: node,
|
helperTextInvalid: node,
|
||||||
isValid: bool,
|
isValid: bool,
|
||||||
label: string.isRequired,
|
label: string.isRequired,
|
||||||
@@ -123,6 +151,8 @@ CredentialLookup.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
CredentialLookup.defaultProps = {
|
CredentialLookup.defaultProps = {
|
||||||
|
credentialTypeId: '',
|
||||||
|
credentialTypeKind: '',
|
||||||
helperTextInvalid: '',
|
helperTextInvalid: '',
|
||||||
isValid: true,
|
isValid: true,
|
||||||
onBlur: () => {},
|
onBlur: () => {},
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ class Inventories extends Component {
|
|||||||
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
|
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
|
||||||
|
|
||||||
const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`;
|
const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`;
|
||||||
const inventoryHostsPath = `/inventories/${inventoryKind}/${inventory.id}/hosts`;
|
const inventoryHostsPath = `${inventoryPath}/hosts`;
|
||||||
const inventoryGroupsPath = `/inventories/${inventoryKind}/${inventory.id}/groups`;
|
const inventoryGroupsPath = `${inventoryPath}/groups`;
|
||||||
|
const inventorySourcesPath = `${inventoryPath}/sources`;
|
||||||
|
|
||||||
const breadcrumbConfig = {
|
const breadcrumbConfig = {
|
||||||
'/inventories': i18n._(t`Inventories`),
|
'/inventories': i18n._(t`Inventories`),
|
||||||
@@ -50,7 +51,6 @@ class Inventories extends Component {
|
|||||||
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`),
|
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`),
|
||||||
[`${inventoryPath}/details`]: i18n._(t`Details`),
|
[`${inventoryPath}/details`]: i18n._(t`Details`),
|
||||||
[`${inventoryPath}/edit`]: i18n._(t`Edit Details`),
|
[`${inventoryPath}/edit`]: i18n._(t`Edit Details`),
|
||||||
[`${inventoryPath}/sources`]: i18n._(t`Sources`),
|
|
||||||
|
|
||||||
[inventoryHostsPath]: i18n._(t`Hosts`),
|
[inventoryHostsPath]: i18n._(t`Hosts`),
|
||||||
[`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`),
|
[`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`),
|
||||||
@@ -74,6 +74,9 @@ class Inventories extends Component {
|
|||||||
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
|
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
|
||||||
t`Create New Host`
|
t`Create New Host`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
|
||||||
|
[`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`),
|
||||||
};
|
};
|
||||||
this.setState({ breadcrumbConfig });
|
this.setState({ breadcrumbConfig });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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_project, ...remainingForm } = form;
|
||||||
|
|
||||||
|
await request({
|
||||||
|
credential: credential?.id || null,
|
||||||
|
source_project: source_project?.id || null,
|
||||||
|
inventory: id,
|
||||||
|
...remainingForm,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push(`/inventories/inventory/${id}/sources`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<InventorySourceForm
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitError={error}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InventorySourceAdd;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventorySourceAdd';
|
||||||
@@ -88,7 +88,7 @@ function InventorySourceList({ i18n }) {
|
|||||||
const canAdd =
|
const canAdd =
|
||||||
sourceChoicesOptions &&
|
sourceChoicesOptions &&
|
||||||
Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST');
|
Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST');
|
||||||
const detailUrl = `/inventories/${inventoryType}/${id}/sources/`;
|
const listUrl = `/inventories/${inventoryType}/${id}/sources/`;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
@@ -109,7 +109,7 @@ function InventorySourceList({ i18n }) {
|
|||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(canAdd
|
...(canAdd
|
||||||
? [<ToolbarAddButton key="add" linkTo={`${detailUrl}add`} />]
|
? [<ToolbarAddButton key="add" linkTo={`${listUrl}add`} />]
|
||||||
: []),
|
: []),
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
@@ -133,7 +133,7 @@ function InventorySourceList({ i18n }) {
|
|||||||
source={inventorySource}
|
source={inventorySource}
|
||||||
onSelect={() => handleSelect(inventorySource)}
|
onSelect={() => handleSelect(inventorySource)}
|
||||||
label={label}
|
label={label}
|
||||||
detailUrl={`${detailUrl}${inventorySource.id}`}
|
detailUrl={`${listUrl}${inventorySource.id}`}
|
||||||
isSelected={selected.some(row => row.id === inventorySource.id)}
|
isSelected={selected.some(row => row.id === inventorySource.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ describe('<InventorySourceList />', () => {
|
|||||||
1
|
1
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
test('displays error after unseccessful read sources fetch', async () => {
|
test('displays error after unsuccessful read sources fetch', async () => {
|
||||||
InventorySourcesAPI.readOptions.mockRejectedValue(
|
InventorySourcesAPI.readOptions.mockRejectedValue(
|
||||||
new Error({
|
new Error({
|
||||||
response: {
|
response: {
|
||||||
@@ -193,7 +193,7 @@ describe('<InventorySourceList />', () => {
|
|||||||
expect(wrapper.find('ContentError').length).toBe(1);
|
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(
|
InventorySourcesAPI.readOptions.mockRejectedValue(
|
||||||
new Error({
|
new Error({
|
||||||
response: {
|
response: {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch, Route } from 'react-router-dom';
|
import { Switch, Route } from 'react-router-dom';
|
||||||
|
import InventorySourceAdd from '../InventorySourceAdd';
|
||||||
import InventorySourceList from './InventorySourceList';
|
import InventorySourceList from './InventorySourceList';
|
||||||
|
|
||||||
function InventorySources() {
|
function InventorySources() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route key="add" path="/inventories/inventory/:id/sources/add">
|
||||||
|
<InventorySourceAdd />
|
||||||
|
</Route>
|
||||||
<Route path="/inventories/:inventoryType/:id/sources">
|
<Route path="/inventories/:inventoryType/:id/sources">
|
||||||
<InventorySourceList />
|
<InventorySourceList />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import InventoryForm from './InventoryForm';
|
import InventoryForm from './InventoryForm';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
const inventory = {
|
const inventory = {
|
||||||
id: 1,
|
id: 1,
|
||||||
type: 'inventory',
|
type: 'inventory',
|
||||||
@@ -50,22 +52,27 @@ describe('<InventoryForm />', () => {
|
|||||||
let wrapper;
|
let wrapper;
|
||||||
let onCancel;
|
let onCancel;
|
||||||
let onSubmit;
|
let onSubmit;
|
||||||
beforeEach(() => {
|
|
||||||
|
beforeAll(async () => {
|
||||||
onCancel = jest.fn();
|
onCancel = jest.fn();
|
||||||
onSubmit = jest.fn();
|
onSubmit = jest.fn();
|
||||||
wrapper = mountWithContexts(
|
await act(async () => {
|
||||||
<InventoryForm
|
wrapper = mountWithContexts(
|
||||||
onCancel={onCancel}
|
<InventoryForm
|
||||||
onSubmit={onSubmit}
|
onCancel={onCancel}
|
||||||
inventory={inventory}
|
onSubmit={onSubmit}
|
||||||
instanceGroups={instanceGroups}
|
inventory={inventory}
|
||||||
credentialTypeId={14}
|
instanceGroups={instanceGroups}
|
||||||
/>
|
credentialTypeId={14}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterAll(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Initially renders successfully', () => {
|
test('Initially renders successfully', () => {
|
||||||
@@ -83,7 +90,7 @@ describe('<InventoryForm />', () => {
|
|||||||
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
|
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update form values', async () => {
|
test('should update form values', () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
||||||
wrapper.find('OrganizationLookup').invoke('onChange')({
|
wrapper.find('OrganizationLookup').invoke('onChange')({
|
||||||
|
|||||||
200
awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
Normal file
200
awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useEffect, useCallback, useContext } from 'react';
|
||||||
|
import { Formik, useField } from 'formik';
|
||||||
|
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 [sourceField, sourceMeta, sourceHelpers] = 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
sourceHelpers.setValue(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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(InventorySourceForm);
|
||||||
@@ -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'],
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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);
|
||||||
|
data.push('/ (project root)');
|
||||||
|
return data;
|
||||||
|
}, []),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProjectUpdate = useCallback(
|
||||||
|
value => {
|
||||||
|
projectHelpers.setValue(value);
|
||||||
|
fetchSourcePath(value.id);
|
||||||
|
sourcePathHelpers.setValue('');
|
||||||
|
},
|
||||||
|
[] // 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={
|
||||||
|
!sourcePathError?.message ||
|
||||||
|
!sourcePathMeta.error ||
|
||||||
|
!sourcePathMeta.touched
|
||||||
|
}
|
||||||
|
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.touched || !sourcePathMeta.error}
|
||||||
|
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);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 } from '@api';
|
||||||
|
|
||||||
|
jest.mock('@api/models/Projects');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
ProjectsAPI.readInventories.mockResolvedValue({
|
||||||
|
data: ['foo'],
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
wrapper.find('ProjectLookup').invoke('onBlur')();
|
||||||
|
});
|
||||||
|
expect(ProjectsAPI.readInventories).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
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');
|
||||||
|
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. 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.
|
||||||
|
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"
|
||||||
|
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.`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './SCMSubForm';
|
||||||
Reference in New Issue
Block a user