Add inventory source add form

This commit is contained in:
Marliana Lara 2020-04-30 13:15:26 -04:00
parent de8c37fd3d
commit b717aabcc9
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
17 changed files with 936 additions and 26 deletions

View File

@ -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/`);
}

View File

@ -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: () => {},

View File

@ -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 });
};

View File

@ -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;

View File

@ -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);
});
});

View File

@ -0,0 +1 @@
export { default } from './InventorySourceAdd';

View File

@ -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)}
/>
);

View File

@ -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: {

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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')({

View 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);

View File

@ -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();
});
});

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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.`)}
/>
)}
</>
);
});

View File

@ -0,0 +1 @@
export { default } from './SCMSubForm';