Add feature to Add/Edit Execution Environments (#8165)

* Add feature to Add/Edit Execution Environments

Add feature to Add/Edit Execution Environments.

Also, add key for `ExecutionEnvironmentsList`.

See: https://github.com/ansible/awx/issues/7887

* Update registry credential label
This commit is contained in:
Kersom 2020-09-18 08:40:29 -04:00 committed by Shane McDonald
parent 9530c6ca50
commit 684b9bd47a
11 changed files with 570 additions and 29 deletions

View File

@ -140,7 +140,7 @@ function getRouteConfig(i18n) {
screen: Applications,
},
{
title: i18n._(t`Execution environments`),
title: i18n._(t`Execution Environments`),
path: '/execution_environments',
screen: ExecutionEnvironments,
},

View File

@ -1,25 +1,124 @@
import React from 'react';
import { Route, Redirect, Switch } from 'react-router-dom';
import React, { useEffect, useCallback } from 'react';
import {
Link,
Redirect,
Route,
Switch,
useLocation,
useParams,
} from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import useRequest from '../../util/useRequest';
import { ExecutionEnvironmentsAPI } from '../../api';
import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import ExecutionEnvironmentDetails from './ExecutionEnvironmentDetails';
import ExecutionEnvironmentEdit from './ExecutionEnvironmentEdit';
function ExecutionEnvironment() {
function ExecutionEnvironment({ i18n, setBreadcrumb }) {
const { id } = useParams();
const { pathname } = useLocation();
const {
isLoading,
error: contentError,
request: fetchExecutionEnvironments,
result: executionEnvironment,
} = useRequest(
useCallback(async () => {
const { data } = await ExecutionEnvironmentsAPI.readDetail(id);
return data;
}, [id]),
null
);
useEffect(() => {
fetchExecutionEnvironments();
}, [fetchExecutionEnvironments, pathname]);
useEffect(() => {
if (executionEnvironment) {
setBreadcrumb(executionEnvironment);
}
}, [executionEnvironment, setBreadcrumb]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to execution environments`)}
</>
),
link: '/execution_environments',
id: 99,
},
{
name: i18n._(t`Details`),
link: `/execution_environments/${id}/details`,
id: 0,
},
];
if (!isLoading && contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError.response?.status === 404 && (
<span>
{i18n._(t`Execution environment not found.`)}{' '}
<Link to="/execution_environments">
{i18n._(t`View all execution environments`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
let cardHeader = <RoutedTabs tabsArray={tabsArray} />;
if (pathname.endsWith('edit')) {
cardHeader = null;
}
return (
<Switch>
<Redirect
from="/execution_environments/:id"
to="/execution_environments/:id/details"
exact
/>
<Route path="/execution_environments/:id/edit">
<ExecutionEnvironmentEdit />
</Route>
<Route path="/execution_environments/:id/details">
<ExecutionEnvironmentDetails />
</Route>
</Switch>
<PageSection>
<Card>
{cardHeader}
{isLoading && <ContentLoading />}
{!isLoading && executionEnvironment && (
<Switch>
<Redirect
from="/execution_environments/:id"
to="/execution_environments/:id/details"
exact
/>
{executionEnvironment && (
<>
<Route path="/execution_environments/:id/edit">
<ExecutionEnvironmentEdit
executionEnvironment={executionEnvironment}
/>
</Route>
<Route path="/execution_environments/:id/details">
<ExecutionEnvironmentDetails />
</Route>
</>
)}
</Switch>
)}
</Card>
</PageSection>
);
}
export default ExecutionEnvironment;
export default withI18n()(ExecutionEnvironment);

View File

@ -1,11 +1,40 @@
import React from 'react';
import React, { useState } from 'react';
import { Card, PageSection } from '@patternfly/react-core';
import { useHistory } from 'react-router-dom';
import ExecutionEnvironmentForm from '../shared/ExecutionEnvironmentForm';
import { CardBody } from '../../../components/Card';
import { ExecutionEnvironmentsAPI } from '../../../api';
function ExecutionEnvironmentAdd() {
const history = useHistory();
const [submitError, setSubmitError] = useState(null);
const handleSubmit = async values => {
try {
const { data: response } = await ExecutionEnvironmentsAPI.create({
...values,
credential: values?.credential?.id,
});
history.push(`/execution_environments/${response.id}/details`);
} catch (error) {
setSubmitError(error);
}
};
const handleCancel = () => {
history.push(`/execution_environments`);
};
return (
<PageSection>
<Card>
<div>Add Execution Environments</div>
<CardBody>
<ExecutionEnvironmentForm
onSubmit={handleSubmit}
submitError={submitError}
onCancel={handleCancel}
/>
</CardBody>
</Card>
</PageSection>
);

View File

@ -0,0 +1,80 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { ExecutionEnvironmentsAPI } from '../../../api';
import ExecutionEnvironmentAdd from './ExecutionEnvironmentAdd';
jest.mock('../../../api');
const executionEnvironmentData = {
credential: 4,
description: 'A simple EE',
image: 'https://registry.com/image/container',
};
ExecutionEnvironmentsAPI.create.mockResolvedValue({
data: {
id: 42,
},
});
describe('<ExecutionEnvironmentAdd/>', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/execution_environments'],
});
await act(async () => {
wrapper = mountWithContexts(<ExecutionEnvironmentAdd />, {
context: { router: { history } },
});
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('handleSubmit should call the api and redirect to details page', async () => {
await act(async () => {
wrapper.find('ExecutionEnvironmentForm').prop('onSubmit')({
executionEnvironmentData,
});
});
wrapper.update();
expect(ExecutionEnvironmentsAPI.create).toHaveBeenCalledWith({
executionEnvironmentData,
});
expect(history.location.pathname).toBe(
'/execution_environments/42/details'
);
});
test('handleCancel should return the user back to the execution environments list', async () => {
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual('/execution_environments');
});
test('failed form submission should show an error message', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
ExecutionEnvironmentsAPI.create.mockImplementationOnce(() =>
Promise.reject(error)
);
await act(async () => {
wrapper.find('ExecutionEnvironmentForm').invoke('onSubmit')(
executionEnvironmentData
);
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});

View File

@ -1,13 +1,39 @@
import React from 'react';
import { Card, PageSection } from '@patternfly/react-core';
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
function ExecutionEnvironmentEdit() {
import { CardBody } from '../../../components/Card';
import { ExecutionEnvironmentsAPI } from '../../../api';
import ExecutionEnvironmentForm from '../shared/ExecutionEnvironmentForm';
function ExecutionEnvironmentEdit({ executionEnvironment }) {
const history = useHistory();
const [submitError, setSubmitError] = useState(null);
const detailsUrl = `/execution_environments/${executionEnvironment.id}/details`;
const handleSubmit = async values => {
try {
await ExecutionEnvironmentsAPI.update(executionEnvironment.id, {
...values,
credential: values.credential ? values.credential.id : null,
});
history.push(detailsUrl);
} catch (error) {
setSubmitError(error);
}
};
const handleCancel = () => {
history.push(detailsUrl);
};
return (
<PageSection>
<Card>
<div>Edit Execution environments</div>
</Card>
</PageSection>
<CardBody>
<ExecutionEnvironmentForm
executionEnvironment={executionEnvironment}
onSubmit={handleSubmit}
submitError={submitError}
onCancel={handleCancel}
/>
</CardBody>
);
}

View File

@ -0,0 +1,103 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { ExecutionEnvironmentsAPI } from '../../../api';
import ExecutionEnvironmentEdit from './ExecutionEnvironmentEdit';
jest.mock('../../../api');
const executionEnvironmentData = {
id: 42,
credential: { id: 4 },
description: 'A simple EE',
image: 'https://registry.com/image/container',
};
const updateExecutionEnvironmentData = {
image: 'https://registry.com/image/container2',
description: 'Updated new description',
};
describe('<ExecutionEnvironmentEdit/>', () => {
let wrapper;
let history;
beforeAll(async () => {
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(
<ExecutionEnvironmentEdit
executionEnvironment={executionEnvironmentData}
/>,
{
context: { router: { history } },
}
);
});
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('handleSubmit should call the api and redirect to details page', async () => {
await act(async () => {
wrapper.find('ExecutionEnvironmentForm').invoke('onSubmit')(
updateExecutionEnvironmentData
);
wrapper.update();
expect(ExecutionEnvironmentsAPI.update).toHaveBeenCalledWith(42, {
...updateExecutionEnvironmentData,
credential: null,
});
});
expect(history.location.pathname).toEqual(
'/execution_environments/42/details'
);
});
test('should navigate to execution environments details when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
});
expect(history.location.pathname).toEqual(
'/execution_environments/42/details'
);
});
test('should navigate to execution environments detail after successful submission', async () => {
await act(async () => {
wrapper.find('ExecutionEnvironmentForm').invoke('onSubmit')({
updateExecutionEnvironmentData,
});
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(history.location.pathname).toEqual(
'/execution_environments/42/details'
);
});
test('failed form submission should show an error message', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
ExecutionEnvironmentsAPI.update.mockImplementationOnce(() =>
Promise.reject(error)
);
await act(async () => {
wrapper.find('ExecutionEnvironmentForm').invoke('onSubmit')(
updateExecutionEnvironmentData
);
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});

View File

@ -19,12 +19,14 @@ const executionEnvironments = {
image: 'https://registry.com/r/image/manifest',
organization: null,
credential: null,
url: '/api/v2/execution_environments/1/',
},
{
id: 2,
image: 'https://registry.com/r/image2/manifest',
organization: null,
credential: null,
url: '/api/v2/execution_environments/2/',
},
],
count: 2,

View File

@ -171,6 +171,7 @@ function ExecutionEnvironmentList({ i18n }) {
)}
renderItem={executionEnvironment => (
<ExecutionEnvironmentsListItem
key={executionEnvironment.id}
executionEnvironment={executionEnvironment}
detailUrl={`${match.url}/${executionEnvironment.id}/details`}
onSelect={() => handleSelect(executionEnvironment)}

View File

@ -22,7 +22,7 @@ function ExecutionEnvironments({ i18n }) {
setBreadcrumbConfig({
'/execution_environments': i18n._(t`Execution environments`),
'/execution_environments/add': i18n._(t`Create Execution environments`),
[`/execution_environments/${executionEnvironments.id}`]: `${executionEnvironments.name}`,
[`/execution_environments/${executionEnvironments.id}`]: `${executionEnvironments.image}`,
[`/execution_environments/${executionEnvironments.id}/edit`]: i18n._(
t`Edit details`
),

View File

@ -0,0 +1,86 @@
import React from 'react';
import { func, shape } from 'prop-types';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup';
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
import { url } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout';
function ExecutionEnvironmentFormFields({ i18n }) {
const [credentialField, , credentialHelpers] = useField('credential');
return (
<>
<FormField
id="execution-environment-image"
label={i18n._(t`Image`)}
name="image"
type="text"
validate={url(i18n)}
isRequired
tooltip={i18n._(
t`The registry location where the container is stored.`
)}
/>
<FormField
id="execution-environment-description"
label={i18n._(t`Description`)}
name="description"
type="text"
/>
<CredentialLookup
label={i18n._(t`Registry Credential`)}
onChange={value => credentialHelpers.setValue(value)}
value={credentialField.value || null}
/>
</>
);
}
function ExecutionEnvironmentForm({
executionEnvironment = {},
onSubmit,
onCancel,
submitError,
...rest
}) {
const initialValues = {
image: executionEnvironment.image || '',
description: executionEnvironment.description || '',
credential: executionEnvironment?.summary_fields?.credential || null,
};
return (
<Formik initialValues={initialValues} onSubmit={values => onSubmit(values)}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ExecutionEnvironmentFormFields {...rest} />
{submitError && <FormSubmitError error={submitError} />}
<FormActionGroup
onCancel={onCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
);
}
ExecutionEnvironmentForm.propTypes = {
executionEnvironment: shape({}),
onCancel: func.isRequired,
onSubmit: func.isRequired,
submitError: shape({}),
};
ExecutionEnvironmentForm.defaultProps = {
executionEnvironment: {},
submitError: null,
};
export default withI18n()(ExecutionEnvironmentForm);

View File

@ -0,0 +1,115 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ExecutionEnvironmentForm from './ExecutionEnvironmentForm';
jest.mock('../../../api');
const executionEnvironment = {
id: 16,
type: 'execution_environment',
url: '/api/v2/execution_environments/16/',
related: {
created_by: '/api/v2/users/1/',
modified_by: '/api/v2/users/1/',
activity_stream: '/api/v2/execution_environments/16/activity_stream/',
unified_job_templates:
'/api/v2/execution_environments/16/unified_job_templates/',
credential: '/api/v2/credentials/4/',
},
summary_fields: {
credential: {
id: 4,
name: 'Container Registry',
},
},
created: '2020-09-17T16:06:57.346128Z',
modified: '2020-09-17T16:06:57.346147Z',
description: 'A simple EE',
organization: null,
image: 'https://registry.com/image/container',
managed_by_tower: false,
credential: 4,
};
describe('<ExecutionEnvironmentForm/>', () => {
let wrapper;
let onCancel;
let onSubmit;
beforeEach(async () => {
onCancel = jest.fn();
onSubmit = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<ExecutionEnvironmentForm
onCancel={onCancel}
onSubmit={onSubmit}
executionEnvironment={executionEnvironment}
/>
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('Initially renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('should display form fields properly', () => {
expect(wrapper.find('FormGroup[label="Image"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
expect(wrapper.find('CredentialLookup').length).toBe(1);
});
test('should call onSubmit when form submitted', async () => {
expect(onSubmit).not.toHaveBeenCalled();
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('should update form values', () => {
act(() => {
wrapper.find('input#execution-environment-image').simulate('change', {
target: {
value: 'https://registry.com/image/container2',
name: 'image',
},
});
wrapper
.find('input#execution-environment-description')
.simulate('change', {
target: { value: 'New description', name: 'description' },
});
wrapper.find('CredentialLookup').invoke('onBlur')();
wrapper.find('CredentialLookup').invoke('onChange')({
id: 99,
name: 'credential',
});
});
wrapper.update();
expect(
wrapper.find('input#execution-environment-image').prop('value')
).toEqual('https://registry.com/image/container2');
expect(
wrapper.find('input#execution-environment-description').prop('value')
).toEqual('New description');
expect(wrapper.find('CredentialLookup').prop('value')).toEqual({
id: 99,
name: 'credential',
});
});
test('should call handleCancel when Cancel button is clicked', async () => {
expect(onCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(onCancel).toBeCalled();
});
});