diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
index c134adca9c..03d92e5899 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
@@ -6,6 +6,8 @@ import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import GitHubDetail from './GitHubDetail';
import GitHubEdit from './GitHubEdit';
+import GitHubOrgEdit from './GitHubOrgEdit';
+import GitHubTeamEdit from './GitHubTeamEdit';
function GitHub({ i18n }) {
const baseURL = '/settings/github';
@@ -29,9 +31,15 @@ function GitHub({ i18n }) {
-
+
+
+
+
+
+
+
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
index 37a1c62a67..68572d6c35 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
@@ -5,33 +5,94 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
-import GitHub from './GitHub';
import { SettingsAPI } from '../../../api';
+import { SettingsProvider } from '../../../contexts/Settings';
+import mockAllOptions from '../shared/data.allSettingOptions.json';
+import GitHub from './GitHub';
jest.mock('../../../api/models/Settings');
-SettingsAPI.readCategory.mockResolvedValue({
- data: {},
-});
describe('', () => {
let wrapper;
+ beforeEach(() => {
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github/',
+ SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
+ SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
+ },
+ });
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-org/',
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
+ },
+ });
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-team/',
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
+ },
+ });
+ });
+
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
- test('should render github details', async () => {
+ test('should render github default details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/github/'],
});
await act(async () => {
- wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubDetail').length).toBe(1);
+ expect(wrapper.find('Detail[label="GitHub OAuth2 Key"]').length).toBe(1);
+ });
+
+ test('should redirect to github organization category details', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/settings/github/organization'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('GitHubDetail').length).toBe(1);
+ expect(
+ wrapper.find('Detail[label="GitHub Organization OAuth2 Key"]').length
+ ).toBe(1);
});
test('should render github edit', async () => {
@@ -39,9 +100,14 @@ describe('', () => {
initialEntries: ['/settings/github/default/edit'],
});
await act(async () => {
- wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubEdit').length).toBe(1);
@@ -52,9 +118,14 @@ describe('', () => {
initialEntries: ['/settings/github/foo/bar'],
});
await act(async () => {
- wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
});
expect(wrapper.find('ContentError').length).toBe(1);
});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx
index 07a6f45015..f1b562043d 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx
@@ -1,25 +1,141 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Button } from '@patternfly/react-core';
-import { CardBody, CardActionsRow } from '../../../../components/Card';
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: formatJson(form.SOCIAL_AUTH_GITHUB_TEAM_MAP),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
-function GitHubEdit({ i18n }) {
return (
- {i18n._(t`Edit form coming soon :)`)}
-
-
-
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && github && (
+
+ {formik => (
+
+ )}
+
+ )}
);
}
-export default withI18n()(GitHubEdit);
+export default GitHubEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx
index 539932c99a..f864f1f6ca 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx
@@ -1,16 +1,173 @@
import React from 'react';
-import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
import GitHubEdit from './GitHubEdit';
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://foo/complete/github/',
+ SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
+ SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
+ Default: {
+ users: true,
+ },
+ },
+ },
+});
+
describe('', () => {
let wrapper;
- beforeEach(() => {
- wrapper = mountWithContexts();
- });
+ let history;
+
afterEach(() => {
wrapper.unmount();
+ jest.clearAllMocks();
});
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
test('initially renders without crashing', () => {
expect(wrapper.find('GitHubEdit').length).toBe(1);
});
+
+ test('should display expected form fields', async () => {
+ expect(wrapper.find('FormGroup[label="GitHub OAuth2 Key"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="GitHub OAuth2 Secret"]').length).toBe(
+ 1
+ );
+ expect(
+ wrapper.find('FormGroup[label="GitHub OAuth2 Organization Map"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub OAuth2 Team Map"]').length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_KEY: '',
+ SOCIAL_AUTH_GITHUB_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GITHUB_KEY').simulate('change', {
+ target: { value: 'new key', name: 'SOCIAL_AUTH_GITHUB_KEY' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_KEY: 'new key',
+ SOCIAL_AUTH_GITHUB_SECRET: '',
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github default detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/details');
+ });
+
+ test('should navigate to github default detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/details');
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx
new file mode 100644
index 0000000000..6224acb5b7
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx
@@ -0,0 +1,147 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubOrgEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-org');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/organization/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/organization/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && github && (
+
+ {formik => (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default GitHubOrgEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx
new file mode 100644
index 0000000000..57396c43d1
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx
@@ -0,0 +1,186 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubOrgEdit from './GitHubOrgEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-org/',
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
+ },
+});
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/organization/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubOrgEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization OAuth2 Key"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization OAuth2 Secret"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization Name"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Organization OAuth2 Organization Map"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization OAuth2 Team Map"]')
+ .length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ORG_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GITHUB_ORG_NAME').simulate('change', {
+ target: { value: 'new org', name: 'SOCIAL_AUTH_GITHUB_ORG_NAME' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: 'new org',
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github organization detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/organization/details'
+ );
+ });
+
+ test('should navigate to github organization detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/organization/details'
+ );
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js
new file mode 100644
index 0000000000..1652804b44
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubOrgEdit';
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx
new file mode 100644
index 0000000000..f898539283
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx
@@ -0,0 +1,147 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubTeamEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-team');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/team/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/team/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && github && (
+
+ {formik => (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default GitHubTeamEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx
new file mode 100644
index 0000000000..bbc36f8948
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubTeamEdit from './GitHubTeamEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-team/',
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
+ },
+});
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/team/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubTeamEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Key"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Secret"]').length
+ ).toBe(1);
+ expect(wrapper.find('FormGroup[label="GitHub Team ID"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Organization Map"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Team Map"]').length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: '',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: '',
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_TEAM_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GITHUB_TEAM_ID').simulate('change', {
+ target: { value: '12345', name: 'SOCIAL_AUTH_GITHUB_TEAM_ID' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\ntrue\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: '12345',
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {
+ Default: {
+ users: true,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github team detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/team/details');
+ });
+
+ test('should navigate to github team detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/team/details');
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js
new file mode 100644
index 0000000000..ba00e5355b
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubTeamEdit';