Merge pull request #8070 from nixocio/ui_add_edit_container_groups

Add/Edit Container Groups

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-09-14 20:48:05 +00:00 committed by GitHub
commit 1a581a79ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 711 additions and 10 deletions

View File

@ -111,7 +111,7 @@ function ContainerGroup({ i18n, setBreadcrumb }) {
{instanceGroup && (
<>
<Route path="/instance_groups/container_group/:id/edit">
<ContainerGroupEdit />
<ContainerGroupEdit instanceGroup={instanceGroup} />
</Route>
<Route path="/instance_groups/container_group/:id/details">
<ContainerGroupDetails />

View File

@ -1,11 +1,103 @@
import React from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Card, PageSection } from '@patternfly/react-core';
import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card';
import { InstanceGroupsAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import { jsonToYaml, isJsonString } from '../../../util/yaml';
import ContainerGroupForm from '../shared/ContainerGroupForm';
function ContainerGroupAdd() {
const history = useHistory();
const [submitError, setSubmitError] = useState(null);
const getPodSpecValue = value => {
if (isJsonString(value)) {
value = jsonToYaml(value);
}
if (value !== jsonToYaml(JSON.stringify(initialPodSpec))) {
return value;
}
return null;
};
const handleSubmit = async values => {
try {
const { data: response } = await InstanceGroupsAPI.create({
name: values.name,
credential: values?.credential?.id,
pod_spec_override: values.override
? getPodSpecValue(values.pod_spec_override)
: null,
});
history.push(`/instance_groups/container_group/${response.id}/details`);
} catch (error) {
setSubmitError(error);
}
};
const handleCancel = () => {
history.push(`/instance_groups`);
};
const {
error: fetchError,
isLoading,
request: fetchInitialPodSpec,
result: initialPodSpec,
} = useRequest(
useCallback(async () => {
const { data } = await InstanceGroupsAPI.readOptions();
return data.actions.POST.pod_spec_override.default;
}, []),
{
initialPodSpec: {},
}
);
useEffect(() => {
fetchInitialPodSpec();
}, [fetchInitialPodSpec]);
if (fetchError) {
return (
<PageSection>
<Card>
<CardBody>
<ContentError error={fetchError} />
</CardBody>
</Card>
</PageSection>
);
}
if (isLoading) {
return (
<PageSection>
<Card>
<CardBody>
<ContentLoading />
</CardBody>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card>
<div>Add container group</div>
<CardBody>
<ContainerGroupForm
initialPodSpec={initialPodSpec}
onSubmit={handleSubmit}
submitError={submitError}
onCancel={handleCancel}
/>
</CardBody>
</Card>
</PageSection>
);

View File

@ -0,0 +1,94 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { InstanceGroupsAPI } from '../../../api';
import ContainerGroupAdd from './ContainerGroupAdd';
jest.mock('../../../api');
const initialPodSpec = {
default: {
apiVersion: 'v1',
kind: 'Pod',
metadata: {
namespace: 'default',
},
spec: {
containers: [
{
image: 'ansible/ansible-runner',
tty: true,
stdin: true,
imagePullPolicy: 'Always',
args: ['sleep', 'infinity'],
},
],
},
},
};
const instanceGroupCreateData = {
name: 'Fuz',
credential: { id: 71, name: 'CG' },
pod_spec_override:
'apiVersion: v1\nkind: Pod\nmetadata:\n namespace: default\nspec:\n containers:\n - image: ansible/ansible-runner\n tty: true\n stdin: true\n imagePullPolicy: Always\n args:\n - sleep\n - infinity\n - test',
};
InstanceGroupsAPI.create.mockResolvedValue({
data: {
id: 123,
},
});
InstanceGroupsAPI.readOptions.mockResolvedValue({
data: {
results: initialPodSpec,
},
});
describe('<ContainerGroupAdd/>', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/instance_groups'],
});
await act(async () => {
wrapper = mountWithContexts(<ContainerGroupAdd />, {
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('ContainerGroupForm').prop('onSubmit')({
...instanceGroupCreateData,
override: true,
});
});
wrapper.update();
expect(InstanceGroupsAPI.create).toHaveBeenCalledWith({
...instanceGroupCreateData,
credential: 71,
});
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(history.location.pathname).toBe(
'/instance_groups/container_group/123/details'
);
});
test('handleCancel should return the user back to the instance group list', async () => {
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual('/instance_groups');
});
});

View File

@ -1,13 +1,88 @@
import React from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core';
function ContainerGroupEdit() {
import { CardBody } from '../../../components/Card';
import { InstanceGroupsAPI } from '../../../api';
import ContainerGroupForm from '../shared/ContainerGroupForm';
import useRequest from '../../../util/useRequest';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
function ContainerGroupEdit({ instanceGroup }) {
const history = useHistory();
const [submitError, setSubmitError] = useState(null);
const detailsIUrl = `/instance_groups/container_group/${instanceGroup.id}/details`;
const {
error: fetchError,
isLoading,
request: fetchInitialPodSpec,
result: initialPodSpec,
} = useRequest(
useCallback(async () => {
const { data } = await InstanceGroupsAPI.readOptions();
return data.actions.POST.pod_spec_override.default;
}, []),
{
initialPodSpec: {},
}
);
useEffect(() => {
fetchInitialPodSpec();
}, [fetchInitialPodSpec]);
const handleSubmit = async values => {
try {
await InstanceGroupsAPI.update(instanceGroup.id, {
name: values.name,
credential: values.credential.id,
pod_spec_override: values.override ? values.pod_spec_override : null,
});
history.push(detailsIUrl);
} catch (error) {
setSubmitError(error);
}
};
const handleCancel = () => {
history.push(detailsIUrl);
};
if (fetchError) {
return (
<PageSection>
<Card>
<CardBody>
<ContentError error={fetchError} />
</CardBody>
</Card>
</PageSection>
);
}
if (isLoading) {
return (
<PageSection>
<Card>
<CardBody>
<ContentLoading />
</CardBody>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card>
<div>Edit container group</div>
</Card>
</PageSection>
<CardBody>
<ContainerGroupForm
instanceGroup={instanceGroup}
initialPodSpec={initialPodSpec}
onSubmit={handleSubmit}
submitError={submitError}
onCancel={handleCancel}
/>
</CardBody>
);
}

View File

@ -0,0 +1,155 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { InstanceGroupsAPI, CredentialsAPI } from '../../../api';
import ContainerGroupEdit from './ContainerGroupEdit';
jest.mock('../../../api');
const instanceGroup = {
id: 123,
type: 'instance_group',
url: '/api/v2/instance_groups/123/',
related: {
named_url: '/api/v2/instance_groups/Foo/',
jobs: '/api/v2/instance_groups/123/jobs/',
instances: '/api/v2/instance_groups/123/instances/',
credential: '/api/v2/credentials/71/',
},
name: 'Foo',
created: '2020-09-02T17:20:01.214170Z',
modified: '2020-09-02T17:20:01.214236Z',
capacity: 0,
committed_capacity: 0,
consumed_capacity: 0,
percent_capacity_remaining: 0.0,
jobs_running: 0,
jobs_total: 0,
instances: 0,
controller: null,
is_controller: false,
is_isolated: false,
is_containerized: true,
credential: 71,
policy_instance_percentage: 0,
policy_instance_minimum: 0,
policy_instance_list: [],
pod_spec_override: '',
summary_fields: {
credential: {
id: 71,
name: 'CG',
description: 'a',
kind: 'kubernetes_bearer_token',
cloud: false,
kubernetes: true,
credential_type_id: 17,
},
user_capabilities: {
edit: true,
delete: true,
},
},
};
const updatedInstanceGroup = {
name: 'Bar',
credential: { id: 12, name: 'CGX' },
};
const initialPodSpec = {
default: {
apiVersion: 'v1',
kind: 'Pod',
metadata: {
namespace: 'default',
},
spec: {
containers: [
{
image: 'ansible/ansible-runner',
tty: true,
stdin: true,
imagePullPolicy: 'Always',
args: ['sleep', 'infinity'],
},
],
},
},
};
InstanceGroupsAPI.readOptions.mockResolvedValue({
data: {
results: initialPodSpec,
},
});
CredentialsAPI.read.mockResolvedValue({
data: {
results: [
{
id: 71,
name: 'Test',
},
],
},
});
describe('<ContainerGroupEdit/>', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({ initialEntries: ['/instance_groups'] });
await act(async () => {
wrapper = mountWithContexts(
<ContainerGroupEdit instanceGroup={instanceGroup} />,
{
context: { router: { history } },
}
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', async () => {
expect(wrapper.find('ContainerGroupEdit').length).toBe(1);
});
test('called InstanceGroupsAPI.readOptions', async () => {
expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1);
});
test('handleCancel returns the user to container group detail', async () => {
await act(async () => {
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
});
expect(history.location.pathname).toEqual(
'/instance_groups/container_group/123/details'
);
});
test('handleSubmit should call the api and redirect to details page', async () => {
await act(async () => {
wrapper.find('ContainerGroupForm').prop('onSubmit')({
...updatedInstanceGroup,
override: false,
});
});
wrapper.update();
expect(InstanceGroupsAPI.update).toHaveBeenCalledWith(123, {
...updatedInstanceGroup,
credential: 12,
pod_spec_override: null,
});
expect(history.location.pathname).toEqual(
'/instance_groups/container_group/123/details'
);
});
});

View File

@ -0,0 +1,144 @@
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, FormGroup } from '@patternfly/react-core';
import { jsonToYaml } from '../../../util/yaml';
import FormField, {
FormSubmitError,
CheckboxField,
} from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup';
import { required } from '../../../util/validators';
import {
FormColumnLayout,
FormFullWidthLayout,
FormCheckboxLayout,
SubFormLayout,
} from '../../../components/FormLayout';
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
import { VariablesField } from '../../../components/CodeMirrorInput';
function ContainerGroupFormFields({ i18n }) {
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [overrideField] = useField('override');
return (
<>
<FormField
name="name"
id="container-group-name"
label={i18n._(t`Name`)}
type="text"
validate={required(null, i18n)}
isRequired
/>
<CredentialLookup
label={i18n._(t`Credential`)}
credentialTypeKind="kubernetes"
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={value => {
credentialHelpers.setValue(value);
}}
value={credentialField.value}
required
tooltip={i18n._(
t`Credential to authenticate with Kubernetes or OpenShift. Must be of type "Kubernetes/OpenShift API Bearer Token”.`
)}
/>
<FormGroup
fieldId="container-groups-option-checkbox"
label={i18n._(t`Options`)}
>
<FormCheckboxLayout>
<CheckboxField
name="override"
aria-label={i18n._(t`Customize pod specification`)}
label={i18n._(t`Customize pod specification`)}
id="container-groups-override-pod-specification"
/>
</FormCheckboxLayout>
</FormGroup>
{overrideField.value && (
<SubFormLayout>
<FormFullWidthLayout>
<VariablesField
tooltip={i18n._(
t`Field for passing a custom Kubernetes or OpenShift Pod specification.`
)}
id="custom-pod-spec"
name="pod_spec_override"
label={i18n._(t`Custom pod spec`)}
/>
</FormFullWidthLayout>
</SubFormLayout>
)}
</>
);
}
function ContainerGroupForm({
initialPodSpec,
instanceGroup,
onSubmit,
onCancel,
submitError,
...rest
}) {
const isCheckboxChecked = Boolean(instanceGroup?.pod_spec_override) || false;
const initialValues = {
name: instanceGroup?.name || '',
credential: instanceGroup?.summary_fields?.credential,
pod_spec_override: isCheckboxChecked
? instanceGroup?.pod_spec_override
: jsonToYaml(JSON.stringify(initialPodSpec)),
override: isCheckboxChecked,
};
return (
<Formik
initialValues={initialValues}
onSubmit={values => {
onSubmit(values);
}}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ContainerGroupFormFields {...rest} />
{submitError && <FormSubmitError error={submitError} />}
<FormActionGroup
onCancel={onCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
);
}
ContainerGroupForm.propTypes = {
instanceGroup: shape({}),
onCancel: func.isRequired,
onSubmit: func.isRequired,
submitError: shape({}),
initialPodSpec: shape({}),
};
ContainerGroupForm.defaultProps = {
instanceGroup: {},
submitError: null,
initialPodSpec: {},
};
export default withI18n()(ContainerGroupForm);

View File

@ -0,0 +1,141 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ContainerGroupForm from './ContainerGroupForm';
jest.mock('../../../api');
const instanceGroup = {
id: 7,
type: 'instance_group',
url: '/api/v2/instance_groups/7/',
related: {
jobs: '/api/v2/instance_groups/7/jobs/',
instances: '/api/v2/instance_groups/7/instances/',
},
name: 'Bar',
created: '2020-07-21T18:41:02.818081Z',
modified: '2020-07-24T20:32:03.121079Z',
capacity: 24,
committed_capacity: 0,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 0,
instances: 1,
controller: null,
is_controller: false,
is_isolated: false,
is_containerized: false,
credential: null,
policy_instance_percentage: 46,
policy_instance_minimum: 12,
policy_instance_list: [],
pod_spec_override: '',
summary_fields: {
user_capabilities: {
edit: true,
delete: true,
},
},
};
const initialPodSpec = {
default: {
apiVersion: 'v1',
kind: 'Pod',
metadata: {
namespace: 'default',
},
spec: {
containers: [
{
image: 'ansible/ansible-runner',
tty: true,
stdin: true,
imagePullPolicy: 'Always',
args: ['sleep', 'infinity'],
},
],
},
},
};
describe('<ContainerGroupForm/>', () => {
let wrapper;
let onCancel;
let onSubmit;
beforeEach(async () => {
onCancel = jest.fn();
onSubmit = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<ContainerGroupForm
onCancel={onCancel}
onSubmit={onSubmit}
instanceGroup={instanceGroup}
initialPodSpec={initialPodSpec}
/>
);
});
});
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="Name"]').length).toBe(1);
expect(wrapper.find('VariablesField[label="Custom pod spec"]').length).toBe(
0
);
expect(
wrapper
.find('Checkbox[aria-label="Customize pod specification"]')
.prop('isChecked')
).toBeFalsy();
expect(wrapper.find('CredentialLookup').prop('value')).toBeFalsy();
});
test('should update form values', () => {
act(() => {
wrapper.find('CredentialLookup').invoke('onBlur')();
wrapper.find('CredentialLookup').invoke('onChange')({
id: 99,
name: 'credential',
});
wrapper.find('TextInputBase#container-group-name').simulate('change', {
target: { value: 'new Foo', name: 'name' },
});
});
wrapper.update();
expect(wrapper.find('CredentialLookup').prop('value')).toEqual({
id: 99,
name: 'credential',
});
expect(
wrapper.find('TextInputBase#container-group-name').prop('value')
).toEqual('new Foo');
});
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 call handleCancel when Cancel button is clicked', async () => {
expect(onCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(onCancel).toBeCalled();
});
});