mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 18:40:01 -03:30
Add inventory source edit form
This commit is contained in:
parent
8006b24ae3
commit
71ef219ffb
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { bool, func, node, number, string, oneOfType } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
@ -35,6 +35,15 @@ function CredentialLookup({
|
||||
const [credentials, setCredentials] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const isMounted = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
@ -49,10 +58,12 @@ function CredentialLookup({
|
||||
const { data } = await CredentialsAPI.read(
|
||||
mergeParams(params, { ...typeIdParams, ...typeKindParams })
|
||||
);
|
||||
setCredentials(data.results);
|
||||
setCount(data.count);
|
||||
if (isMounted.current) {
|
||||
setCredentials(data.results);
|
||||
setCount(data.count);
|
||||
}
|
||||
} catch (err) {
|
||||
if (setError) {
|
||||
if (isMounted.current) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { node, string, func, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
@ -32,19 +32,31 @@ function ProjectLookup({
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const isMounted = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await ProjectsAPI.read(params);
|
||||
setProjects(data.results);
|
||||
setCount(data.count);
|
||||
if (data.count === 1) {
|
||||
onChange(data.results[0]);
|
||||
if (isMounted.current) {
|
||||
setProjects(data.results);
|
||||
setCount(data.count);
|
||||
if (data.count === 1) {
|
||||
onChange(data.results[0]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
if (isMounted.current) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [onChange, history.location]);
|
||||
|
||||
@ -19,9 +19,9 @@ class Inventories extends Component {
|
||||
this.state = {
|
||||
breadcrumbConfig: {
|
||||
'/inventories': i18n._(t`Inventories`),
|
||||
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
|
||||
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
||||
'/inventories/smart_inventory/add': i18n._(
|
||||
t`Create New Smart Inventory`
|
||||
t`Create new smart inventory`
|
||||
),
|
||||
},
|
||||
};
|
||||
@ -43,42 +43,43 @@ class Inventories extends Component {
|
||||
|
||||
const breadcrumbConfig = {
|
||||
'/inventories': i18n._(t`Inventories`),
|
||||
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
|
||||
'/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`),
|
||||
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
||||
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
|
||||
|
||||
[inventoryPath]: `${inventory.name}`,
|
||||
[`${inventoryPath}/access`]: i18n._(t`Access`),
|
||||
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`),
|
||||
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
|
||||
[`${inventoryPath}/details`]: i18n._(t`Details`),
|
||||
[`${inventoryPath}/edit`]: i18n._(t`Edit Details`),
|
||||
[`${inventoryPath}/edit`]: i18n._(t`Edit details`),
|
||||
|
||||
[inventoryHostsPath]: i18n._(t`Hosts`),
|
||||
[`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`),
|
||||
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
|
||||
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`),
|
||||
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
||||
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(t`Host Details`),
|
||||
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
|
||||
t`Completed Jobs`
|
||||
t`Completed jobs`
|
||||
),
|
||||
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
|
||||
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
|
||||
|
||||
[inventoryGroupsPath]: i18n._(t`Groups`),
|
||||
[`${inventoryGroupsPath}/add`]: i18n._(t`Create New Group`),
|
||||
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
|
||||
[`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
|
||||
t`Group Details`
|
||||
t`Group details`
|
||||
),
|
||||
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
|
||||
[`${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`),
|
||||
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
|
||||
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
|
||||
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
||||
};
|
||||
this.setState({ breadcrumbConfig });
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@ import ContentError from '../../../components/ContentError';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
import RoutedTabs from '../../../components/RoutedTabs';
|
||||
import InventorySourceDetail from '../InventorySourceDetail';
|
||||
import InventorySourceEdit from '../InventorySourceEdit';
|
||||
|
||||
function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||
const location = useLocation();
|
||||
@ -38,7 +39,7 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchSource();
|
||||
}, [fetchSource, match.params.sourceId]);
|
||||
}, [fetchSource, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inventory && source) {
|
||||
@ -104,6 +105,12 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||
>
|
||||
<InventorySourceDetail inventorySource={source} />
|
||||
</Route>
|
||||
<Route
|
||||
key="edit"
|
||||
path="/inventories/inventory/:id/sources/:sourceId/edit"
|
||||
>
|
||||
<InventorySourceEdit source={source} inventory={inventory} />
|
||||
</Route>
|
||||
<Route key="not-found" path="*">
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${match.url}/details`}>
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import InventorySourceAdd from './InventorySourceAdd';
|
||||
import { InventorySourcesAPI, ProjectsAPI } from '../../../api';
|
||||
|
||||
@ -75,6 +78,7 @@ describe('<InventorySourceAdd />', () => {
|
||||
context: { config },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1);
|
||||
|
||||
@ -228,7 +228,7 @@ function InventorySourceDetail({ inventorySource, i18n }) {
|
||||
<Button
|
||||
component={Link}
|
||||
aria-label={i18n._(t`edit`)}
|
||||
to={`/inventories/inventory/${inventory.id}/source/${id}/edit`}
|
||||
to={`/inventories/inventory/${inventory.id}/sources/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
|
||||
@ -88,7 +88,7 @@ describe('InventorySourceDetail', () => {
|
||||
const editButton = wrapper.find('Button[aria-label="edit"]');
|
||||
expect(editButton.text()).toEqual('Edit');
|
||||
expect(editButton.prop('to')).toBe(
|
||||
'/inventories/inventory/2/source/123/edit'
|
||||
'/inventories/inventory/2/sources/123/edit'
|
||||
);
|
||||
expect(wrapper.find('DeleteButton')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { InventorySourcesAPI } from '../../../api';
|
||||
import InventorySourceForm from '../shared/InventorySourceForm';
|
||||
|
||||
function InventorySourceEdit({ source }) {
|
||||
const history = useHistory();
|
||||
const { id } = useParams();
|
||||
const detailsUrl = `/inventories/inventory/${id}/sources/${source.id}/details`;
|
||||
|
||||
const { error, request, result } = useRequest(
|
||||
useCallback(
|
||||
async values => {
|
||||
const { data } = await InventorySourcesAPI.replace(source.id, values);
|
||||
return data;
|
||||
},
|
||||
[source.id]
|
||||
),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
history.push(detailsUrl);
|
||||
}
|
||||
}, [result, detailsUrl, 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(detailsUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<InventorySourceForm
|
||||
source={source}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={error}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default InventorySourceEdit;
|
||||
@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import InventorySourceEdit from './InventorySourceEdit';
|
||||
import { CredentialsAPI, InventorySourcesAPI, ProjectsAPI } from '../../../api';
|
||||
|
||||
jest.mock('../../../api/models/Projects');
|
||||
jest.mock('../../../api/models/Credentials');
|
||||
jest.mock('../../../api/models/InventorySources');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('<InventorySourceAdd />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
const mockInvSrc = {
|
||||
id: 23,
|
||||
description: 'bar',
|
||||
inventory: 1,
|
||||
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'],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
InventorySourcesAPI.replace.mockResolvedValue({
|
||||
data: {
|
||||
...mockInvSrc,
|
||||
},
|
||||
});
|
||||
ProjectsAPI.readInventories.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
CredentialsAPI.read.mockResolvedValue({
|
||||
data: { count: 0, results: [] },
|
||||
});
|
||||
ProjectsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'mock proj one',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'mock proj two',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventorySourceEdit source={mockInvSrc} />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('handleSubmit should call api update', async () => {
|
||||
expect(InventorySourcesAPI.replace).toHaveBeenCalledTimes(0);
|
||||
await act(async () => {
|
||||
wrapper.find('InventorySourceForm').invoke('onSubmit')(mockInvSrc);
|
||||
});
|
||||
expect(InventorySourcesAPI.replace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should navigate to inventory source detail after successful submission', () => {
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/inventory/1/sources/23/details'
|
||||
);
|
||||
});
|
||||
|
||||
test('should navigate to inventory sources list when cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/inventory/1/sources/23/details'
|
||||
);
|
||||
});
|
||||
|
||||
test('unsuccessful form submission should show an error message', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
InventorySourcesAPI.replace.mockImplementation(() => Promise.reject(error));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventorySourceEdit source={mockInvSrc} />);
|
||||
});
|
||||
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 './InventorySourceEdit';
|
||||
@ -133,23 +133,24 @@ const InventorySourceForm = ({
|
||||
i18n,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
source,
|
||||
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,
|
||||
credential: source?.summary_fields?.credential || null,
|
||||
custom_virtualenv: source?.custom_virtualenv || '',
|
||||
description: source?.description || '',
|
||||
name: source?.name || '',
|
||||
overwrite: source?.overwrite || false,
|
||||
overwrite_vars: source?.overwrite_vars || false,
|
||||
source: source?.source || '',
|
||||
source_path: source?.source_path || '',
|
||||
source_project: source?.summary_fields?.source_project || null,
|
||||
source_vars: source?.source_vars || '---\n',
|
||||
update_cache_timeout: source?.update_cache_timeout || 0,
|
||||
update_on_launch: source?.update_on_launch || false,
|
||||
update_on_project_update: source?.update_on_project_update || false,
|
||||
verbosity: source?.verbosity || 1,
|
||||
};
|
||||
|
||||
const {
|
||||
@ -172,21 +173,21 @@ const InventorySourceForm = ({
|
||||
};
|
||||
});
|
||||
}, []),
|
||||
[]
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSourceOptions();
|
||||
}, [fetchSourceOptions]);
|
||||
|
||||
if (isSourceOptionsLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (sourceOptionsError) {
|
||||
return <ContentError error={sourceOptionsError} />;
|
||||
}
|
||||
|
||||
if (!sourceOptions || isSourceOptionsLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
@ -36,6 +36,12 @@ const SCMSubForm = ({ i18n }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectField.value?.id) {
|
||||
fetchSourcePath(projectField.value.id);
|
||||
}
|
||||
}, [fetchSourcePath, projectField.value]);
|
||||
|
||||
const handleProjectUpdate = useCallback(
|
||||
value => {
|
||||
sourcePathHelpers.setValue('');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user