bag.props.handleSubmit(values),
+ handleSubmit: (values, { props }) => props.handleSubmit(values),
})(JobTemplateForm);
export { JobTemplateForm as _JobTemplateForm };
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
index 71a470112c..cf2da14304 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
@@ -1,8 +1,8 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
-import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm';
-import { LabelsAPI, JobTemplatesAPI } from '@api';
+import JobTemplateForm from './JobTemplateForm';
+import { LabelsAPI, JobTemplatesAPI, ProjectsAPI } from '@api';
jest.mock('@api');
@@ -61,13 +61,16 @@ describe('
', () => {
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
+ ProjectsAPI.readPlaybooks.mockReturnValue({
+ data: ['debug.yml'],
+ });
});
afterEach(() => {
jest.clearAllMocks();
});
- test('should render labels MultiSelect', async () => {
+ test('should render LabelsSelect', async () => {
const wrapper = mountWithContexts(
', () => {
expect(LabelsAPI.read).toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalled();
wrapper.update();
- expect(
- wrapper
- .find('FormGroup[fieldId="template-labels"] MultiSelect')
- .prop('associatedItems')
- ).toEqual(mockData.summary_fields.labels.results);
+ const select = wrapper.find('LabelSelect');
+ expect(select).toHaveLength(1);
+ expect(select.prop('value')).toEqual(
+ mockData.summary_fields.labels.results
+ );
});
test('should update form values on input changes', async () => {
@@ -155,75 +158,4 @@ describe('
', () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
});
-
- test('should call loadRelatedProjectPlaybooks when project value changes', async () => {
- const loadRelatedProjectPlaybooks = jest.spyOn(
- _JobTemplateForm.prototype,
- 'loadRelatedProjectPlaybooks'
- );
- const wrapper = mountWithContexts(
-
- );
- await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
- wrapper.find('ProjectLookup').prop('onChange')({
- id: 10,
- name: 'project',
- });
- expect(loadRelatedProjectPlaybooks).toHaveBeenCalledWith(10);
- });
-
- test('handleNewLabel should arrange new labels properly', async () => {
- const event = { key: 'Enter', preventDefault: () => {} };
- const wrapper = mountWithContexts(
-
- );
- await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
- const multiSelect = wrapper.find(
- 'FormGroup[fieldId="template-labels"] MultiSelect'
- );
- const component = wrapper.find('JobTemplateForm');
-
- wrapper.setState({ newLabels: [], loadedLabels: [], removedLabels: [] });
- multiSelect.setState({ input: 'Foo' });
- component
- .find('FormGroup[fieldId="template-labels"] input[aria-label="labels"]')
- .prop('onKeyDown')(event);
-
- component.instance().handleNewLabel({ name: 'Bar', id: 2 });
- const newLabels = component.state('newLabels');
- expect(newLabels).toHaveLength(2);
- expect(newLabels[0].name).toEqual('Foo');
- expect(newLabels[0].organization).toEqual(1);
- });
-
- test('disassociateLabel should arrange new labels properly', async () => {
- const wrapper = mountWithContexts(
-
- );
- await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
- const component = wrapper.find('JobTemplateForm');
- // This asserts that the user generated a label or clicked
- // on a label option, and then changed their mind and
- // removed the label.
- component.instance().removeLabel({ name: 'Alex', id: 17 });
- expect(component.state().newLabels.length).toBe(0);
- expect(component.state().removedLabels.length).toBe(0);
- // This asserts that the user removed a label that was associated
- // with the template when the template loaded.
- component.instance().removeLabel({ name: 'Sushi', id: 1 });
- expect(component.state().newLabels.length).toBe(0);
- expect(component.state().removedLabels.length).toBe(1);
- });
});
diff --git a/awx/ui_next/src/screens/Template/shared/LabelSelect.jsx b/awx/ui_next/src/screens/Template/shared/LabelSelect.jsx
new file mode 100644
index 0000000000..3443aeb3b4
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/LabelSelect.jsx
@@ -0,0 +1,61 @@
+import React, { useState, useEffect } from 'react';
+import { func, arrayOf, number, shape, string, oneOfType } from 'prop-types';
+import MultiSelect from '@components/MultiSelect';
+import { LabelsAPI } from '@api';
+
+async function loadLabelOptions(setLabels, onError) {
+ let labels;
+ try {
+ const { data } = await LabelsAPI.read({
+ page: 1,
+ page_size: 200,
+ order_by: 'name',
+ });
+ labels = data.results;
+ setLabels(labels);
+ if (data.next && data.next.includes('page=2')) {
+ const {
+ data: { results },
+ } = await LabelsAPI.read({
+ page: 2,
+ page_size: 200,
+ order_by: 'name',
+ });
+ labels = labels.concat(results);
+ }
+ setLabels(labels);
+ } catch (err) {
+ onError(err);
+ }
+}
+
+function LabelSelect({ value, onChange, onError }) {
+ const [options, setOptions] = useState([]);
+ useEffect(() => {
+ loadLabelOptions(setOptions, onError);
+ }, []);
+
+ return (
+
({
+ id: name,
+ name,
+ isNew: true,
+ })}
+ />
+ );
+}
+LabelSelect.propTypes = {
+ value: arrayOf(
+ shape({
+ id: oneOfType([number, string]).isRequired,
+ name: string.isRequired,
+ })
+ ).isRequired,
+ onError: func.isRequired,
+};
+
+export default LabelSelect;
diff --git a/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx b/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx
new file mode 100644
index 0000000000..b03d4e1572
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { LabelsAPI } from '@api';
+import { sleep } from '@testUtils/testUtils';
+import LabelSelect from './LabelSelect';
+
+jest.mock('@api');
+
+const options = [{ id: 1, name: 'one' }, { id: 2, name: 'two' }];
+
+describe('', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ test('should fetch labels', async () => {
+ LabelsAPI.read.mockReturnValue({
+ data: { results: options },
+ });
+ const wrapper = mount();
+ await sleep(1);
+ wrapper.update();
+
+ expect(LabelsAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('MultiSelect').prop('options')).toEqual(options);
+ });
+
+ test('should fetch two pages labels if present', async () => {
+ LabelsAPI.read.mockReturnValueOnce({
+ data: {
+ results: options,
+ next: '/foo?page=2',
+ },
+ });
+ LabelsAPI.read.mockReturnValueOnce({
+ data: {
+ results: options,
+ },
+ });
+ const wrapper = mount();
+ await sleep(1);
+ wrapper.update();
+
+ expect(LabelsAPI.read).toHaveBeenCalledTimes(2);
+ expect(wrapper.find('MultiSelect').prop('options')).toEqual([
+ ...options,
+ ...options,
+ ]);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx
new file mode 100644
index 0000000000..3e7d03c37e
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx
@@ -0,0 +1,54 @@
+import React, { useState, useEffect } from 'react';
+import { number, string, oneOfType } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import AnsibleSelect from '@components/AnsibleSelect';
+import { ProjectsAPI } from '@api';
+
+function PlaybookSelect({ projectId, isValid, form, field, onError, i18n }) {
+ const [options, setOptions] = useState([]);
+ useEffect(() => {
+ if (!projectId) {
+ return;
+ }
+ (async () => {
+ try {
+ const { data } = await ProjectsAPI.readPlaybooks(projectId);
+ const opts = (data || []).map(playbook => ({
+ value: playbook,
+ key: playbook,
+ label: playbook,
+ isDisabled: false,
+ }));
+ opts.unshift({
+ value: '',
+ key: '',
+ label: i18n._(t`Choose a playbook`),
+ isDisabled: false,
+ });
+ setOptions(opts);
+ } catch (contentError) {
+ onError(contentError);
+ }
+ })();
+ }, [projectId]);
+
+ return (
+
+ );
+}
+PlaybookSelect.propTypes = {
+ projectId: oneOfType([number, string]),
+};
+PlaybookSelect.defaultProps = {
+ projectId: null,
+};
+
+export { PlaybookSelect as _PlaybookSelect };
+export default withI18n()(PlaybookSelect);
diff --git a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.test.jsx b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.test.jsx
new file mode 100644
index 0000000000..3d1a26355a
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.test.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import PlaybookSelect from './PlaybookSelect';
+import { ProjectsAPI } from '@api';
+
+jest.mock('@api');
+
+describe('', () => {
+ beforeEach(() => {
+ ProjectsAPI.readPlaybooks.mockReturnValue({
+ data: ['debug.yml'],
+ });
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ test('should reload playbooks when project value changes', () => {
+ const wrapper = mountWithContexts(
+ {}}
+ />
+ );
+
+ expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
+ wrapper.setProps({ projectId: 15 });
+
+ expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
+ expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
+ });
+});
diff --git a/awx/ui_next/src/util/lists.js b/awx/ui_next/src/util/lists.js
new file mode 100644
index 0000000000..561c306faf
--- /dev/null
+++ b/awx/ui_next/src/util/lists.js
@@ -0,0 +1,18 @@
+/* eslint-disable import/prefer-default-export */
+export function getAddedAndRemoved(original, current) {
+ original = original || [];
+ current = current || [];
+ const added = [];
+ const removed = [];
+ original.forEach(orig => {
+ if (!current.find(cur => cur.id === orig.id)) {
+ removed.push(orig);
+ }
+ });
+ current.forEach(cur => {
+ if (!original.find(orig => orig.id === cur.id)) {
+ added.push(cur);
+ }
+ });
+ return { added, removed };
+}
diff --git a/awx/ui_next/src/util/lists.test.js b/awx/ui_next/src/util/lists.test.js
new file mode 100644
index 0000000000..126de4f07e
--- /dev/null
+++ b/awx/ui_next/src/util/lists.test.js
@@ -0,0 +1,51 @@
+import { getAddedAndRemoved } from './lists';
+
+const one = { id: 1 };
+const two = { id: 2 };
+const three = { id: 3 };
+
+describe('getAddedAndRemoved', () => {
+ test('should handle no original list', () => {
+ const items = [one, two, three];
+ expect(getAddedAndRemoved(null, items)).toEqual({
+ added: items,
+ removed: [],
+ });
+ });
+
+ test('should list added item', () => {
+ const original = [one, two];
+ const current = [one, two, three];
+ expect(getAddedAndRemoved(original, current)).toEqual({
+ added: [three],
+ removed: [],
+ });
+ });
+
+ test('should list removed item', () => {
+ const original = [one, two, three];
+ const current = [one, three];
+ expect(getAddedAndRemoved(original, current)).toEqual({
+ added: [],
+ removed: [two],
+ });
+ });
+
+ test('should handle both added and removed together', () => {
+ const original = [two];
+ const current = [one, three];
+ expect(getAddedAndRemoved(original, current)).toEqual({
+ added: [one, three],
+ removed: [two],
+ });
+ });
+
+ test('should handle different list order', () => {
+ const original = [three, two];
+ const current = [one, two, three];
+ expect(getAddedAndRemoved(original, current)).toEqual({
+ added: [one],
+ removed: [],
+ });
+ });
+});