diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js
index 0fa6f1c630..1c0e86400d 100644
--- a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js
+++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js
@@ -8,17 +8,11 @@ function InstanceAdd() {
const history = useHistory();
const [formError, setFormError] = useState();
const handleSubmit = async (values) => {
- const { instanceGroups, executionEnvironment } = values;
- values.execution_environment = executionEnvironment?.id;
-
try {
const {
data: { id },
- } = await InstancesAPI.create();
+ } = await InstancesAPI.create(values);
- for (const group of instanceGroups) {
- await InstancesAPI.associateInstanceGroup(id, group.id);
- }
history.push(`/instances/${id}/details`);
} catch (err) {
setFormError(err);
diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js
new file mode 100644
index 0000000000..e79b0471c8
--- /dev/null
+++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { InstancesAPI } from 'api';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+
+import InstanceAdd from './InstanceAdd';
+
+jest.mock('../../../api');
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ beforeEach(async () => {
+ history = createMemoryHistory({ initialEntries: ['/instances'] });
+ InstancesAPI.create.mockResolvedValue({ data: { id: 13 } });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ });
+
+ test('Initially renders successfully', () => {
+ expect(wrapper.length).toBe(1);
+ });
+ test('handleSubmit should call the api and redirect to details page', async () => {
+ await waitForElement(wrapper, 'isLoading', (el) => el.length === 0);
+ await act(async () => {
+ wrapper.find('InstanceForm').prop('handleSubmit')({
+ name: 'new Foo',
+ node_type: 'hop',
+ });
+ });
+ expect(InstancesAPI.create).toHaveBeenCalledWith({
+ name: 'new Foo',
+ node_type: 'hop',
+ });
+ expect(history.location.pathname).toBe('/instances/13/details');
+ });
+
+ test('handleCancel should return the user back to the instances list', async () => {
+ await waitForElement(wrapper, 'isLoading', (el) => el.length === 0);
+ await act(async () => {
+ wrapper.find('Button[aria-label="Cancel"]').simulate('click');
+ });
+ expect(history.location.pathname).toEqual('/instances');
+ });
+});
diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js
index 782fcdd187..a50891bc68 100644
--- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js
+++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js
@@ -10,12 +10,14 @@ import PaginatedTable, {
HeaderRow,
HeaderCell,
getSearchableKeys,
+ ToolbarAddButton,
} from 'components/PaginatedTable';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
+import { useConfig } from 'contexts/Config';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import useSelected from 'hooks/useSelected';
-import { InstancesAPI } from 'api';
+import { InstancesAPI, SettingsAPI } from 'api';
import { getQSConfig, parseQueryString } from 'util/qs';
import HealthCheckButton from 'components/HealthCheckButton';
import InstanceListItem from './InstanceListItem';
@@ -28,21 +30,24 @@ const QS_CONFIG = getQSConfig('instance', {
function InstanceList() {
const location = useLocation();
+ const { me } = useConfig();
const {
- result: { instances, count, relatedSearchableKeys, searchableKeys },
+ result: { instances, count, relatedSearchableKeys, searchableKeys, isK8 },
error: contentError,
isLoading,
request: fetchInstances,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
- const [response, responseActions] = await Promise.all([
+ const [response, responseActions, sysSettings] = await Promise.all([
InstancesAPI.read(params),
InstancesAPI.readOptions(),
+ SettingsAPI.readCategory('system'),
]);
return {
instances: response.data.results,
+ isK8: sysSettings.data.IS_K8S,
count: response.data.count,
actions: responseActions.data.actions,
relatedSearchableKeys: (
@@ -57,6 +62,7 @@ function InstanceList() {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
+ isK8: false,
}
);
@@ -89,6 +95,7 @@ function InstanceList() {
const { expanded, isAllExpanded, handleExpand, expandAll } =
useExpanded(instances);
+
return (
<>
@@ -135,6 +142,15 @@ function InstanceList() {
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
+ ...(isK8 && me.is_superuser
+ ? [
+ ,
+ ]
+ : []),
', () => {
},
});
InstancesAPI.readOptions.mockResolvedValue(options);
+ SettingsAPI.readCategory.mockResolvedValue({ data: { IS_K8S: false } });
const history = createMemoryHistory({
initialEntries: ['/instances/1'],
});
@@ -190,4 +191,52 @@ describe('', () => {
wrapper.update();
expect(wrapper.find('AlertModal')).toHaveLength(1);
});
+ test('Should not show Add button', () => {
+ expect(wrapper.find('Button[ouiaId="instances-add-button"]')).toHaveLength(
+ 0
+ );
+ });
+});
+
+describe('InstanceList should show Add button', () => {
+ let wrapper;
+
+ const options = { data: { actions: { POST: true } } };
+
+ beforeEach(async () => {
+ InstancesAPI.read.mockResolvedValue({
+ data: {
+ count: instances.length,
+ results: instances,
+ },
+ });
+ InstancesAPI.readOptions.mockResolvedValue(options);
+ SettingsAPI.readCategory.mockResolvedValue({ data: { IS_K8S: true } });
+ const history = createMemoryHistory({
+ initialEntries: ['/instances/1'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: {
+ router: { history, route: { location: history.location } },
+ },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Should show Add button', () => {
+ expect(wrapper.find('Button[ouiaId="instances-add-button"]')).toHaveLength(
+ 1
+ );
+ });
});
diff --git a/awx/ui/src/screens/Instances/Instances.js b/awx/ui/src/screens/Instances/Instances.js
index a230fb9a67..ca42498e41 100644
--- a/awx/ui/src/screens/Instances/Instances.js
+++ b/awx/ui/src/screens/Instances/Instances.js
@@ -6,10 +6,12 @@ import ScreenHeader from 'components/ScreenHeader';
import PersistentFilters from 'components/PersistentFilters';
import { InstanceList } from './InstanceList';
import Instance from './Instance';
+import InstanceAdd from './InstanceAdd';
function Instances() {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
'/instances': t`Instances`,
+ '/instances/add': t`Create new Instance`,
});
const buildBreadcrumbConfig = useCallback((instance) => {
@@ -27,6 +29,9 @@ function Instances() {
<>
+
+
+
diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.js
index 6d706b39cf..4dff50df77 100644
--- a/awx/ui/src/screens/Instances/Shared/InstanceForm.js
+++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.js
@@ -1,40 +1,36 @@
import React from 'react';
import { t } from '@lingui/macro';
import { Formik, useField } from 'formik';
-import { Form, FormGroup, CardBody } from '@patternfly/react-core';
+import {
+ Form,
+ FormGroup,
+ CardBody,
+ Switch,
+ Popover,
+} from '@patternfly/react-core';
import { FormColumnLayout } from 'components/FormLayout';
import FormField, { FormSubmitError } from 'components/FormField';
import FormActionGroup from 'components/FormActionGroup';
import { required } from 'util/validators';
import AnsibleSelect from 'components/AnsibleSelect';
-import {
- ExecutionEnvironmentLookup,
- InstanceGroupsLookup,
-} from 'components/Lookup';
// This is hard coded because the API does not have the ability to send us a list that contains
// only the types of instances that can be added. Control and Hybrid instances cannot be added.
const INSTANCE_TYPES = [
- { id: 2, name: t`Execution`, value: 'execution' },
- { id: 3, name: t`Hop`, value: 'hop' },
+ { id: 'execution', name: t`Execution` },
+ { id: 'hop', name: t`Hop` },
];
function InstanceFormFields() {
- const [instanceType, , instanceTypeHelpers] = useField('type');
- const [instanceGroupsField, , instanceGroupsHelpers] =
- useField('instanceGroups');
- const [
- executionEnvironmentField,
- executionEnvironmentMeta,
- executionEnvironmentHelpers,
- ] = useField('executionEnvironment');
+ const [instanceType, , instanceTypeHelpers] = useField('node_type');
+ const [enabled, , enabledHelpers] = useField('enabled');
return (
<>
+
+
({
key: type.id,
- value: type.value,
+ value: type.id,
label: type.name,
- isDisabled: false,
}))}
value={instanceType.value}
onChange={(e, opt) => {
@@ -67,25 +76,27 @@ function InstanceFormFields() {
}}
/>
- {
- instanceGroupsHelpers.setValue(value);
- }}
- fieldName="instanceGroups"
- />
-
}
- fieldName={executionEnvironmentField.name}
- onBlur={() => executionEnvironmentHelpers.setTouched()}
- value={executionEnvironmentField.value}
- onChange={(value) => {
- executionEnvironmentHelpers.setValue(value);
- }}
- />
+ >
+ {
+ enabledHelpers.setValue(!enabled.value);
+ }}
+ ouiaId="enable-instance-switch"
+ />
+
>
);
}
@@ -100,11 +111,12 @@ function InstanceForm({
{
handleSubmit(values);
diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js
new file mode 100644
index 0000000000..8a3b9898bf
--- /dev/null
+++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js
@@ -0,0 +1,98 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+
+import InstanceForm from './InstanceForm';
+
+jest.mock('../../../api');
+
+describe('', () => {
+ let wrapper;
+ let handleCancel;
+ let handleSubmit;
+
+ beforeAll(async () => {
+ handleCancel = jest.fn();
+ handleSubmit = jest.fn();
+
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Initially renders successfully', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('should display form fields properly', async () => {
+ await waitForElement(wrapper, 'InstanceForm', (el) => el.length > 0);
+ expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="Instance State"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="Listener Port"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="Instance Type"]').length).toBe(1);
+ });
+
+ test('should update form values', async () => {
+ await act(async () => {
+ wrapper.find('input#name').simulate('change', {
+ target: { value: 'new Foo', name: 'hostname' },
+ });
+ });
+
+ wrapper.update();
+ expect(wrapper.find('input#name').prop('value')).toEqual('new Foo');
+ });
+
+ test('should call handleCancel when Cancel button is clicked', async () => {
+ expect(handleCancel).not.toHaveBeenCalled();
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ wrapper.update();
+ expect(handleCancel).toBeCalled();
+ });
+
+ test('should call handleSubmit when Cancel button is clicked', async () => {
+ expect(handleSubmit).not.toHaveBeenCalled();
+ await act(async () => {
+ wrapper.find('input#name').simulate('change', {
+ target: { value: 'new Foo', name: 'hostname' },
+ });
+ wrapper.find('input#instance-description').simulate('change', {
+ target: { value: 'This is a repeat song', name: 'description' },
+ });
+ wrapper.find('input#instance-port').simulate('change', {
+ target: { value: 'This is a repeat song', name: 'listener_port' },
+ });
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('FormField[label="Instance State"]').prop('isDisabled')
+ ).toBe(true);
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').invoke('onClick')();
+ });
+
+ expect(handleSubmit).toBeCalledWith({
+ description: 'This is a repeat song',
+ enabled: true,
+ hostname: 'new Foo',
+ listener_port: 'This is a repeat song',
+ node_state: 'Installed',
+ node_type: 'execution',
+ });
+ });
+});