Merge pull request #7028 from marshmalien/6576-inv-src-edit-form

Add inventory source edit form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-05-21 16:49:38 +00:00
committed by GitHub
18 changed files with 454 additions and 152 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect } from 'react';
import { bool, func, node, number, string, oneOfType } from 'prop-types'; import { bool, func, node, number, string, oneOfType } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -10,6 +10,7 @@ import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
import { FieldTooltip } from '../FormField'; import { FieldTooltip } from '../FormField';
import Lookup from './Lookup'; import Lookup from './Lookup';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
const QS_CONFIG = getQSConfig('credentials', { const QS_CONFIG = getQSConfig('credentials', {
@@ -32,11 +33,12 @@ function CredentialLookup({
i18n, i18n,
tooltip, tooltip,
}) { }) {
const [credentials, setCredentials] = useState([]); const {
const [count, setCount] = useState(0); result: { count, credentials },
const [error, setError] = useState(null); error,
useEffect(() => { request: fetchCredentials,
(async () => { } = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search); const params = parseQueryString(QS_CONFIG, history.location.search);
const typeIdParams = credentialTypeId const typeIdParams = credentialTypeId
? { credential_type: credentialTypeId } ? { credential_type: credentialTypeId }
@@ -45,19 +47,23 @@ function CredentialLookup({
? { credential_type__kind: credentialTypeKind } ? { credential_type__kind: credentialTypeKind }
: {}; : {};
try { const { data } = await CredentialsAPI.read(
const { data } = await CredentialsAPI.read( mergeParams(params, { ...typeIdParams, ...typeKindParams })
mergeParams(params, { ...typeIdParams, ...typeKindParams }) );
); return {
setCredentials(data.results); count: data.count,
setCount(data.count); credentials: data.results,
} catch (err) { };
if (setError) { }, [credentialTypeId, credentialTypeKind, history.location.search]),
setError(err); {
} count: 0,
} credentials: [],
})(); }
}, [credentialTypeId, credentialTypeKind, history.location.search]); );
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
// TODO: replace credential type search with REST-based grabbing of cred types // TODO: replace credential type search with REST-based grabbing of cred types

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { func, bool } from 'prop-types'; import { func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -7,6 +7,7 @@ import { InventoriesAPI } from '../../api';
import { Inventory } from '../../types'; import { Inventory } from '../../types';
import Lookup from './Lookup'; import Lookup from './Lookup';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
@@ -17,22 +18,28 @@ const QS_CONFIG = getQSConfig('inventory', {
}); });
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
const [inventories, setInventories] = useState([]); const {
const [count, setCount] = useState(0); result: { count, inventories },
const [error, setError] = useState(null); error,
request: fetchInventories,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await InventoriesAPI.read(params);
return {
count: data.count,
inventories: data.results,
};
}, [history.location.search]),
{
count: 0,
inventories: [],
}
);
useEffect(() => { useEffect(() => {
(async () => { fetchInventories();
const params = parseQueryString(QS_CONFIG, history.location.search); }, [fetchInventories]);
try {
const { data } = await InventoriesAPI.read(params);
setInventories(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
return ( return (
<> <>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { node, string, func, bool } from 'prop-types'; import { node, string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -8,6 +8,7 @@ import { ProjectsAPI } from '../../api';
import { Project } from '../../types'; import { Project } from '../../types';
import { FieldTooltip } from '../FormField'; import { FieldTooltip } from '../FormField';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import Lookup from './Lookup'; import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
@@ -20,6 +21,7 @@ const QS_CONFIG = getQSConfig('project', {
function ProjectLookup({ function ProjectLookup({
helperTextInvalid, helperTextInvalid,
autocomplete,
i18n, i18n,
isValid, isValid,
onChange, onChange,
@@ -29,25 +31,31 @@ function ProjectLookup({
onBlur, onBlur,
history, history,
}) { }) {
const [projects, setProjects] = useState([]); const {
const [count, setCount] = useState(0); result: { count, projects },
const [error, setError] = useState(null); error,
request: fetchProjects,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await ProjectsAPI.read(params);
if (data.count === 1 && autocomplete) {
autocomplete(data.results[0]);
}
return {
count: data.count,
projects: data.results,
};
}, [history.location.search, autocomplete]),
{
count: 0,
projects: [],
}
);
useEffect(() => { useEffect(() => {
(async () => { fetchProjects();
const params = parseQueryString(QS_CONFIG, history.location.search); }, [fetchProjects]);
try {
const { data } = await ProjectsAPI.read(params);
setProjects(data.results);
setCount(data.count);
if (data.count === 1) {
onChange(data.results[0]);
}
} catch (err) {
setError(err);
}
})();
}, [onChange, history.location]);
return ( return (
<FormGroup <FormGroup
@@ -124,22 +132,24 @@ function ProjectLookup({
} }
ProjectLookup.propTypes = { ProjectLookup.propTypes = {
value: Project, autocomplete: func,
helperTextInvalid: node, helperTextInvalid: node,
isValid: bool, isValid: bool,
onBlur: func, onBlur: func,
onChange: func.isRequired, onChange: func.isRequired,
required: bool, required: bool,
tooltip: string, tooltip: string,
value: Project,
}; };
ProjectLookup.defaultProps = { ProjectLookup.defaultProps = {
autocomplete: () => {},
helperTextInvalid: '', helperTextInvalid: '',
isValid: true, isValid: true,
onBlur: () => {},
required: false, required: false,
tooltip: '', tooltip: '',
value: null, value: null,
onBlur: () => {},
}; };
export { ProjectLookup as _ProjectLookup }; export { ProjectLookup as _ProjectLookup };

View File

@@ -15,12 +15,14 @@ describe('<ProjectLookup />', () => {
count: 1, count: 1,
}, },
}); });
const onChange = jest.fn(); const autocomplete = jest.fn();
await act(async () => { await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />); mountWithContexts(
<ProjectLookup autocomplete={autocomplete} onChange={() => {}} />
);
}); });
await sleep(0); await sleep(0);
expect(onChange).toHaveBeenCalledWith({ id: 1 }); expect(autocomplete).toHaveBeenCalledWith({ id: 1 });
}); });
test('should not auto-select project when multiple available', async () => { test('should not auto-select project when multiple available', async () => {
@@ -30,11 +32,13 @@ describe('<ProjectLookup />', () => {
count: 2, count: 2,
}, },
}); });
const onChange = jest.fn(); const autocomplete = jest.fn();
await act(async () => { await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />); mountWithContexts(
<ProjectLookup autocomplete={autocomplete} onChange={() => {}} />
);
}); });
await sleep(0); await sleep(0);
expect(onChange).not.toHaveBeenCalled(); expect(autocomplete).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -19,9 +19,9 @@ class Inventories extends Component {
this.state = { this.state = {
breadcrumbConfig: { breadcrumbConfig: {
'/inventories': i18n._(t`Inventories`), '/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._( '/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 = { const breadcrumbConfig = {
'/inventories': i18n._(t`Inventories`), '/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`), '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
[inventoryPath]: `${inventory.name}`, [inventoryPath]: `${inventory.name}`,
[`${inventoryPath}/access`]: i18n._(t`Access`), [`${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}/details`]: i18n._(t`Details`),
[`${inventoryPath}/edit`]: i18n._(t`Edit Details`), [`${inventoryPath}/edit`]: i18n._(t`Edit details`),
[inventoryHostsPath]: i18n._(t`Hosts`), [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}`]: `${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}/details`]: i18n._(t`Host Details`),
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
t`Completed Jobs` t`Completed jobs`
), ),
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: 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}`]: `${nested?.name}`,
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`), [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
t`Group Details` t`Group details`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
[`${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}`]: 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}`]: `${nested?.name}`,
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
}; };
this.setState({ breadcrumbConfig }); this.setState({ breadcrumbConfig });
}; };

View File

@@ -20,6 +20,7 @@ import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import RoutedTabs from '../../../components/RoutedTabs'; import RoutedTabs from '../../../components/RoutedTabs';
import InventorySourceDetail from '../InventorySourceDetail'; import InventorySourceDetail from '../InventorySourceDetail';
import InventorySourceEdit from '../InventorySourceEdit';
function InventorySource({ i18n, inventory, setBreadcrumb }) { function InventorySource({ i18n, inventory, setBreadcrumb }) {
const location = useLocation(); const location = useLocation();
@@ -38,7 +39,7 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
useEffect(() => { useEffect(() => {
fetchSource(); fetchSource();
}, [fetchSource, match.params.sourceId]); }, [fetchSource, location.pathname]);
useEffect(() => { useEffect(() => {
if (inventory && source) { if (inventory && source) {
@@ -104,6 +105,12 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
> >
<InventorySourceDetail inventorySource={source} /> <InventorySourceDetail inventorySource={source} />
</Route> </Route>
<Route
key="edit"
path="/inventories/inventory/:id/sources/:sourceId/edit"
>
<InventorySourceEdit source={source} inventory={inventory} />
</Route>
<Route key="not-found" path="*"> <Route key="not-found" path="*">
<ContentError isNotFound> <ContentError isNotFound>
<Link to={`${match.url}/details`}> <Link to={`${match.url}/details`}>

View File

@@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import InventorySourceAdd from './InventorySourceAdd'; import InventorySourceAdd from './InventorySourceAdd';
import { InventorySourcesAPI, ProjectsAPI } from '../../../api'; import { InventorySourcesAPI, ProjectsAPI } from '../../../api';
@@ -75,6 +78,7 @@ describe('<InventorySourceAdd />', () => {
context: { config }, context: { config },
}); });
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1);

View File

@@ -228,7 +228,7 @@ function InventorySourceDetail({ inventorySource, i18n }) {
<Button <Button
component={Link} component={Link}
aria-label={i18n._(t`edit`)} aria-label={i18n._(t`edit`)}
to={`/inventories/inventory/${inventory.id}/source/${id}/edit`} to={`/inventories/inventory/${inventory.id}/sources/${id}/edit`}
> >
{i18n._(t`Edit`)} {i18n._(t`Edit`)}
</Button> </Button>

View File

@@ -88,7 +88,7 @@ describe('InventorySourceDetail', () => {
const editButton = wrapper.find('Button[aria-label="edit"]'); const editButton = wrapper.find('Button[aria-label="edit"]');
expect(editButton.text()).toEqual('Edit'); expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe( expect(editButton.prop('to')).toBe(
'/inventories/inventory/2/source/123/edit' '/inventories/inventory/2/sources/123/edit'
); );
expect(wrapper.find('DeleteButton')).toHaveLength(1); expect(wrapper.find('DeleteButton')).toHaveLength(1);
}); });

View File

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

View File

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

View File

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

View File

@@ -133,23 +133,24 @@ const InventorySourceForm = ({
i18n, i18n,
onCancel, onCancel,
onSubmit, onSubmit,
source,
submitError = null, submitError = null,
}) => { }) => {
const initialValues = { const initialValues = {
credential: null, credential: source?.summary_fields?.credential || null,
custom_virtualenv: '', custom_virtualenv: source?.custom_virtualenv || '',
description: '', description: source?.description || '',
name: '', name: source?.name || '',
overwrite: false, overwrite: source?.overwrite || false,
overwrite_vars: false, overwrite_vars: source?.overwrite_vars || false,
source: '', source: source?.source || '',
source_path: '', source_path: source?.source_path === '' ? '/ (project root)' : '',
source_project: null, source_project: source?.summary_fields?.source_project || null,
source_vars: '---\n', source_vars: source?.source_vars || '---\n',
update_cache_timeout: 0, update_cache_timeout: source?.update_cache_timeout || 0,
update_on_launch: false, update_on_launch: source?.update_on_launch || false,
update_on_project_update: false, update_on_project_update: source?.update_on_project_update || false,
verbosity: 1, verbosity: source?.verbosity || 1,
}; };
const { const {
@@ -172,21 +173,21 @@ const InventorySourceForm = ({
}; };
}); });
}, []), }, []),
[] null
); );
useEffect(() => { useEffect(() => {
fetchSourceOptions(); fetchSourceOptions();
}, [fetchSourceOptions]); }, [fetchSourceOptions]);
if (isSourceOptionsLoading) {
return <ContentLoading />;
}
if (sourceOptionsError) { if (sourceOptionsError) {
return <ContentError error={sourceOptionsError} />; return <ContentError error={sourceOptionsError} />;
} }
if (!sourceOptions || isSourceOptionsLoading) {
return <ContentLoading />;
}
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useField } from 'formik'; import { useField } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -36,6 +36,12 @@ const SCMSubForm = ({ i18n }) => {
[] []
); );
useEffect(() => {
if (projectMeta.initialValue) {
fetchSourcePath(projectMeta.initialValue.id);
}
}, [fetchSourcePath, projectMeta.initialValue]);
const handleProjectUpdate = useCallback( const handleProjectUpdate = useCallback(
value => { value => {
sourcePathHelpers.setValue(''); sourcePathHelpers.setValue('');
@@ -45,6 +51,16 @@ const SCMSubForm = ({ i18n }) => {
[] // eslint-disable-line react-hooks/exhaustive-deps [] // eslint-disable-line react-hooks/exhaustive-deps
); );
const handleProjectAutocomplete = useCallback(
val => {
projectHelpers.setValue(val);
if (!projectMeta.initialValue) {
fetchSourcePath(val.id);
}
},
[] // eslint-disable-line react-hooks/exhaustive-deps
);
return ( return (
<> <>
<CredentialLookup <CredentialLookup
@@ -56,6 +72,7 @@ const SCMSubForm = ({ i18n }) => {
}} }}
/> />
<ProjectLookup <ProjectLookup
autocomplete={handleProjectAutocomplete}
value={projectField.value} value={projectField.value}
isValid={!projectMeta.touched || !projectMeta.error} isValid={!projectMeta.touched || !projectMeta.error}
helperTextInvalid={projectMeta.error} helperTextInvalid={projectMeta.error}

View File

@@ -6,9 +6,25 @@ import {
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import JobTemplateAdd from './JobTemplateAdd'; import JobTemplateAdd from './JobTemplateAdd';
import { JobTemplatesAPI, LabelsAPI } from '../../../api'; import {
CredentialsAPI,
CredentialTypesAPI,
JobTemplatesAPI,
LabelsAPI,
ProjectsAPI,
} from '../../../api';
jest.mock('../../../api'); jest.mock('../../../api');
CredentialsAPI.read.mockResolvedValue({
data: {
results: [],
count: 0,
},
});
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
ProjectsAPI.readPlaybooks.mockResolvedValue({
data: [],
});
const jobTemplateData = { const jobTemplateData = {
allow_callbacks: false, allow_callbacks: false,

View File

@@ -6,7 +6,13 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import { JobTemplatesAPI, LabelsAPI, ProjectsAPI } from '../../../api'; import {
CredentialsAPI,
CredentialTypesAPI,
JobTemplatesAPI,
LabelsAPI,
ProjectsAPI,
} from '../../../api';
import JobTemplateEdit from './JobTemplateEdit'; import JobTemplateEdit from './JobTemplateEdit';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -60,7 +66,7 @@ const mockJobTemplate = {
{ id: 2, kind: 'ssh', name: 'Bar' }, { id: 2, kind: 'ssh', name: 'Bar' },
], ],
project: { project: {
id: 15, id: 3,
name: 'Boo', name: 'Boo',
}, },
}, },
@@ -176,6 +182,13 @@ ProjectsAPI.readPlaybooks.mockResolvedValue({
data: mockRelatedProjectPlaybooks, data: mockRelatedProjectPlaybooks,
}); });
LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
CredentialsAPI.read.mockResolvedValue({
data: {
results: [],
count: 0,
},
});
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
describe('<JobTemplateEdit />', () => { describe('<JobTemplateEdit />', () => {
beforeEach(() => { beforeEach(() => {
@@ -251,7 +264,7 @@ describe('<JobTemplateEdit />', () => {
const expected = { const expected = {
...mockJobTemplate, ...mockJobTemplate,
project: mockJobTemplate.project.id, project: mockJobTemplate.project,
...updatedTemplateData, ...updatedTemplateData,
}; };
delete expected.summary_fields; delete expected.summary_fields;

View File

@@ -46,27 +46,25 @@ const { origin } = document.location;
function JobTemplateForm({ function JobTemplateForm({
template, template,
validateField,
handleCancel, handleCancel,
handleSubmit, handleSubmit,
setFieldValue, setFieldValue,
submitError, submitError,
i18n, i18n,
}) { }) {
const { values: formikValues } = useFormikContext();
const [contentError, setContentError] = useState(false); const [contentError, setContentError] = useState(false);
const [project, setProject] = useState(template?.summary_fields?.project);
const [inventory, setInventory] = useState( const [inventory, setInventory] = useState(
template?.summary_fields?.inventory template?.summary_fields?.inventory
); );
const [allowCallbacks, setAllowCallbacks] = useState( const [allowCallbacks, setAllowCallbacks] = useState(
Boolean(template?.host_config_key) Boolean(template?.host_config_key)
); );
const [enableWebhooks, setEnableWebhooks] = useState( const [enableWebhooks, setEnableWebhooks] = useState(
Boolean(template.webhook_service) Boolean(template.webhook_service)
); );
const { values: formikValues } = useFormikContext();
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({ const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
name: 'job_type', name: 'job_type',
validate: required(null, i18n), validate: required(null, i18n),
@@ -74,16 +72,13 @@ function JobTemplateForm({
const [, inventoryMeta, inventoryHelpers] = useField('inventory'); const [, inventoryMeta, inventoryHelpers] = useField('inventory');
const [projectField, projectMeta, projectHelpers] = useField({ const [projectField, projectMeta, projectHelpers] = useField({
name: 'project', name: 'project',
validate: () => handleProjectValidation(), validate: project => handleProjectValidation(project),
}); });
const [scmField, , scmHelpers] = useField('scm_branch'); const [scmField, , scmHelpers] = useField('scm_branch');
const [playbookField, playbookMeta, playbookHelpers] = useField({ const [playbookField, playbookMeta, playbookHelpers] = useField({
name: 'playbook', name: 'playbook',
validate: required(i18n._(t`Select a value for this field`), i18n), validate: required(i18n._(t`Select a value for this field`), i18n),
}); });
const [credentialField, , credentialHelpers] = useField('credentials'); const [credentialField, , credentialHelpers] = useField('credentials');
const [labelsField, , labelsHelpers] = useField('labels'); const [labelsField, , labelsHelpers] = useField('labels');
const [limitField, limitMeta] = useField('limit'); const [limitField, limitMeta] = useField('limit');
@@ -101,13 +96,10 @@ function JobTemplateForm({
contentLoading: hasProjectLoading, contentLoading: hasProjectLoading,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
let projectData;
if (template?.project) { if (template?.project) {
projectData = await ProjectsAPI.readDetail(template?.project); await ProjectsAPI.readDetail(template?.project);
validateField('project');
setProject(projectData.data);
} }
}, [template, validateField]) }, [template])
); );
const { const {
@@ -133,26 +125,28 @@ function JobTemplateForm({
loadRelatedInstanceGroups(); loadRelatedInstanceGroups();
}, [loadRelatedInstanceGroups]); }, [loadRelatedInstanceGroups]);
const handleProjectValidation = () => { const handleProjectValidation = project => {
if (!project && projectMeta.touched) { if (!project && projectMeta.touched) {
return i18n._(t`Select a value for this field`); return i18n._(t`Select a value for this field`);
} }
if (project && project.status === 'never updated') { if (project?.value?.status === 'never updated') {
return i18n._(t`This project needs to be updated`); return i18n._(t`This project needs to be updated`);
} }
return undefined; return undefined;
}; };
const handleProjectUpdate = useCallback( const handleProjectUpdate = useCallback(
newProject => { value => {
if (project?.id !== newProject?.id) { playbookHelpers.setValue(0);
// Clear the selected playbook value when a different project is selected or
// when the project is deselected.
playbookHelpers.setValue(0);
}
setProject(newProject);
projectHelpers.setValue(newProject);
scmHelpers.setValue(''); scmHelpers.setValue('');
projectHelpers.setValue(value);
},
[] // eslint-disable-line react-hooks/exhaustive-deps
);
const handleProjectAutocomplete = useCallback(
val => {
projectHelpers.setValue(val);
}, },
[] // eslint-disable-line react-hooks/exhaustive-deps [] // eslint-disable-line react-hooks/exhaustive-deps
); );
@@ -189,8 +183,12 @@ function JobTemplateForm({
return <ContentLoading />; return <ContentLoading />;
} }
if (instanceGroupError || projectContentError) { if (contentError || instanceGroupError || projectContentError) {
return <ContentError error={contentError} />; return (
<ContentError
error={contentError || instanceGroupError || projectContentError}
/>
);
} }
return ( return (
@@ -261,18 +259,18 @@ function JobTemplateForm({
</div> </div>
)} )}
</FieldWithPrompt> </FieldWithPrompt>
<ProjectLookup <ProjectLookup
value={project} value={projectField.value}
onBlur={() => projectHelpers.setTouched()} onBlur={() => projectHelpers.setTouched()}
tooltip={i18n._(t`Select the project containing the playbook tooltip={i18n._(t`Select the project containing the playbook
you want this job to execute.`)} you want this job to execute.`)}
isValid={!projectMeta.touched || !projectMeta.error} isValid={!projectMeta.touched || !projectMeta.error}
helperTextInvalid={projectMeta.error} helperTextInvalid={projectMeta.error}
onChange={handleProjectUpdate} onChange={handleProjectUpdate}
autocomplete={handleProjectAutocomplete}
required required
/> />
{project?.allow_override && ( {projectField.value?.allow_override && (
<FieldWithPrompt <FieldWithPrompt
fieldId="template-scm-branch" fieldId="template-scm-branch"
label={i18n._(t`Source Control Branch`)} label={i18n._(t`Source Control Branch`)}
@@ -302,7 +300,7 @@ function JobTemplateForm({
content={i18n._(t`Select the playbook to be executed by this job.`)} content={i18n._(t`Select the playbook to be executed by this job.`)}
/> />
<PlaybookSelect <PlaybookSelect
projectId={project?.id || projectField.value?.id} projectId={projectField.value?.id}
isValid={!playbookMeta.touched || !playbookMeta.error} isValid={!playbookMeta.touched || !playbookMeta.error}
field={playbookField} field={playbookField}
onBlur={() => playbookHelpers.setTouched()} onBlur={() => playbookHelpers.setTouched()}
@@ -615,6 +613,8 @@ const FormikApp = withFormik({
} = template; } = template;
return { return {
allow_callbacks: template.allow_callbacks || false,
allow_simultaneous: template.allow_simultaneous || false,
ask_credential_on_launch: template.ask_credential_on_launch || false, ask_credential_on_launch: template.ask_credential_on_launch || false,
ask_diff_mode_on_launch: template.ask_diff_mode_on_launch || false, ask_diff_mode_on_launch: template.ask_diff_mode_on_launch || false,
ask_inventory_on_launch: template.ask_inventory_on_launch || false, ask_inventory_on_launch: template.ask_inventory_on_launch || false,
@@ -625,31 +625,29 @@ const FormikApp = withFormik({
ask_tags_on_launch: template.ask_tags_on_launch || false, ask_tags_on_launch: template.ask_tags_on_launch || false,
ask_variables_on_launch: template.ask_variables_on_launch || false, ask_variables_on_launch: template.ask_variables_on_launch || false,
ask_verbosity_on_launch: template.ask_verbosity_on_launch || false, ask_verbosity_on_launch: template.ask_verbosity_on_launch || false,
name: template.name || '',
description: template.description || '',
job_type: template.job_type || 'run',
inventory: template.inventory || null,
project: template.project || null,
scm_branch: template.scm_branch || '',
playbook: template.playbook || '',
labels: summary_fields.labels.results || [],
forks: template.forks || 0,
limit: template.limit || '',
verbosity: template.verbosity || '0',
job_slice_count: template.job_slice_count || 1,
timeout: template.timeout || 0,
diff_mode: template.diff_mode || false,
job_tags: template.job_tags || '',
skip_tags: template.skip_tags || '',
become_enabled: template.become_enabled || false, become_enabled: template.become_enabled || false,
allow_callbacks: template.allow_callbacks || false, credentials: summary_fields.credentials || [],
allow_simultaneous: template.allow_simultaneous || false, description: template.description || '',
use_fact_cache: template.use_fact_cache || false, diff_mode: template.diff_mode || false,
extra_vars: template.extra_vars || '---\n',
forks: template.forks || 0,
host_config_key: template.host_config_key || '', host_config_key: template.host_config_key || '',
initialInstanceGroups: [], initialInstanceGroups: [],
instanceGroups: [], instanceGroups: [],
credentials: summary_fields.credentials || [], inventory: template.inventory || null,
extra_vars: template.extra_vars || '---\n', job_slice_count: template.job_slice_count || 1,
job_tags: template.job_tags || '',
job_type: template.job_type || 'run',
labels: summary_fields.labels.results || [],
limit: template.limit || '',
name: template.name || '',
playbook: template.playbook || '',
project: summary_fields?.project || null,
scm_branch: template.scm_branch || '',
skip_tags: template.skip_tags || '',
timeout: template.timeout || 0,
use_fact_cache: template.use_fact_cache || false,
verbosity: template.verbosity || '0',
webhook_service: template.webhook_service || '', webhook_service: template.webhook_service || '',
webhook_url: template?.related?.webhook_receiver webhook_url: template?.related?.webhook_receiver
? `${origin}${template.related.webhook_receiver}` ? `${origin}${template.related.webhook_receiver}`

View File

@@ -13,6 +13,7 @@ import {
JobTemplatesAPI, JobTemplatesAPI,
ProjectsAPI, ProjectsAPI,
CredentialsAPI, CredentialsAPI,
CredentialTypesAPI,
} from '../../../api'; } from '../../../api';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -99,6 +100,7 @@ describe('<JobTemplateForm />', () => {
LabelsAPI.read.mockReturnValue({ LabelsAPI.read.mockReturnValue({
data: mockData.summary_fields.labels, data: mockData.summary_fields.labels,
}); });
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
CredentialsAPI.read.mockReturnValue({ CredentialsAPI.read.mockReturnValue({
data: { results: mockCredentials }, data: { results: mockCredentials },
}); });