From e0feda780b4365964b6b2aa4bb17e6252caec96f Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 27 Oct 2020 15:16:26 -0400 Subject: [PATCH] Add setting system category forms and tests * Add activity stream, logging, and misc system forms * Hookup logging test alert * Hookup revert buttons * Add useModal helper hook * Swap VariablesDetail for CodeDetail within setting detail views * Update SettingDetail import path in setting detail views --- awx/ui_next/src/api/models/Settings.js | 4 + .../src/components/DetailList/CodeDetail.jsx | 14 +- .../FormActionGroup/FormActionGroup.jsx | 20 +- .../src/components/Popover/Popover.jsx | 12 +- .../Setting/ActivityStream/ActivityStream.jsx | 9 +- .../ActivityStreamDetail.jsx | 2 +- .../ActivityStreamEdit/ActivityStreamEdit.jsx | 146 ++++++++- .../ActivityStreamEdit.test.jsx | 127 ++++++- .../screens/Setting/AzureAD/AzureAD.test.jsx | 26 +- .../AzureAD/AzureADDetail/AzureADDetail.jsx | 2 +- .../AzureAD/AzureADEdit/AzureADEdit.jsx | 157 ++++++++- .../AzureAD/AzureADEdit/AzureADEdit.test.jsx | 139 +++++++- .../GitHub/GitHubDetail/GitHubDetail.jsx | 2 +- .../GoogleOAuth2Detail/GoogleOAuth2Detail.jsx | 2 +- .../Setting/Jobs/JobsDetail/JobsDetail.jsx | 2 +- .../Setting/LDAP/LDAPDetail/LDAPDetail.jsx | 2 +- .../src/screens/Setting/Logging/Logging.jsx | 13 +- .../screens/Setting/Logging/Logging.test.jsx | 24 +- .../Logging/LoggingDetail/LoggingDetail.jsx | 2 +- .../Logging/LoggingEdit/LoggingEdit.jsx | 275 +++++++++++++++- .../Logging/LoggingEdit/LoggingEdit.test.jsx | 310 +++++++++++++++++- .../screens/Setting/MiscSystem/MiscSystem.jsx | 9 +- .../MiscSystemDetail/MiscSystemDetail.jsx | 2 +- .../MiscSystemEdit/MiscSystemEdit.jsx | 308 ++++++++++++++++- .../MiscSystemEdit/MiscSystemEdit.test.jsx | 116 ++++++- .../RADIUS/RADIUSDetail/RADIUSDetail.jsx | 2 +- .../Setting/SAML/SAMLDetail/SAMLDetail.jsx | 2 +- .../src/screens/Setting/Settings.test.jsx | 74 ++++- .../TACACS/TACACSDetail/TACACSDetail.jsx | 2 +- .../screens/Setting/UI/UIDetail/UIDetail.jsx | 2 +- .../Setting/shared/LoggingTestAlert.jsx | 59 ++++ .../Setting/shared/LoggingTestAlert.test.jsx | 61 ++++ .../screens/Setting/shared/RevertAllAlert.jsx | 39 +++ .../Setting/shared/RevertAllAlert.test.jsx | 13 + .../screens/Setting/shared/RevertButton.jsx | 66 ++++ .../Setting/shared/RevertButton.test.jsx | 77 +++++ .../Setting/shared/RevertFormActionGroup.jsx | 54 +++ .../shared/RevertFormActionGroup.test.jsx | 17 + .../screens/Setting/shared/SettingDetail.jsx | 14 +- .../Setting/shared/ShareFields.test.jsx | 152 +++++++++ .../screens/Setting/shared/SharedFields.jsx | 258 +++++++++++++++ .../Setting/shared/data.allSettings.json | 309 +++++++++++++++++ .../src/screens/Setting/shared/index.js | 12 +- .../Setting/shared/settingTestUtils.js | 6 +- .../screens/Setting/shared/settingUtils.js | 7 +- awx/ui_next/src/util/useModal.js | 28 ++ awx/ui_next/src/util/useModal.test.jsx | 54 +++ 47 files changed, 2893 insertions(+), 140 deletions(-) create mode 100644 awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/RevertAllAlert.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/RevertButton.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/RevertButton.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/ShareFields.test.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/SharedFields.jsx create mode 100644 awx/ui_next/src/screens/Setting/shared/data.allSettings.json create mode 100644 awx/ui_next/src/util/useModal.js create mode 100644 awx/ui_next/src/util/useModal.test.jsx diff --git a/awx/ui_next/src/api/models/Settings.js b/awx/ui_next/src/api/models/Settings.js index cf8de70530..8610fc029f 100644 --- a/awx/ui_next/src/api/models/Settings.js +++ b/awx/ui_next/src/api/models/Settings.js @@ -21,6 +21,10 @@ class Settings extends Base { readCategoryOptions(category) { return this.http.options(`${this.baseUrl}${category}/`); } + + test(category, data) { + return this.http.post(`${this.baseUrl}${category}/test/`, data); + } } export default Settings; diff --git a/awx/ui_next/src/components/DetailList/CodeDetail.jsx b/awx/ui_next/src/components/DetailList/CodeDetail.jsx index a3a4918605..6d78ef043a 100644 --- a/awx/ui_next/src/components/DetailList/CodeDetail.jsx +++ b/awx/ui_next/src/components/DetailList/CodeDetail.jsx @@ -1,6 +1,14 @@ import 'styled-components/macro'; import React from 'react'; -import { shape, node, number, oneOf, string } from 'prop-types'; +import { + arrayOf, + oneOf, + oneOfType, + node, + number, + shape, + string, +} from 'prop-types'; import { TextListItemVariants } from '@patternfly/react-core'; import { DetailName, DetailValue } from './Detail'; import CodeMirrorInput from '../CodeMirrorInput'; @@ -57,12 +65,12 @@ function CodeDetail({ ); } CodeDetail.propTypes = { - value: shape.isRequired, + value: oneOfType([shape({}), arrayOf(string), string]).isRequired, label: node.isRequired, dataCy: string, helpText: string, rows: number, - mode: oneOf(['json', 'yaml', 'jinja2']).isRequired, + mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired, }; CodeDetail.defaultProps = { rows: null, diff --git a/awx/ui_next/src/components/FormActionGroup/FormActionGroup.jsx b/awx/ui_next/src/components/FormActionGroup/FormActionGroup.jsx index 0662f145dd..70a5c95f7d 100644 --- a/awx/ui_next/src/components/FormActionGroup/FormActionGroup.jsx +++ b/awx/ui_next/src/components/FormActionGroup/FormActionGroup.jsx @@ -5,13 +5,7 @@ import { t } from '@lingui/macro'; import { ActionGroup, Button } from '@patternfly/react-core'; import { FormFullWidthLayout } from '../FormLayout'; -const FormActionGroup = ({ - onCancel, - onRevert, - onSubmit, - submitDisabled, - i18n, -}) => { +const FormActionGroup = ({ onCancel, onSubmit, submitDisabled, i18n }) => { return ( @@ -24,16 +18,6 @@ const FormActionGroup = ({ > {i18n._(t`Save`)} - {onRevert && ( - - )} - + {formik => { + return ( +
+ + + + {submitError && } + + + {isModalOpen && ( + + )} + + ); + }} + + )} ); } -export default withI18n()(ActivityStreamEdit); +export default ActivityStreamEdit; diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx index a9794b3a69..845c68886d 100644 --- a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamEdit/ActivityStreamEdit.test.jsx @@ -1,16 +1,135 @@ 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 ActivityStreamEdit from './ActivityStreamEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + ACTIVITY_STREAM_ENABLED: false, + ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: true, + }, +}); +SettingsAPI.updateAll.mockResolvedValue({}); describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/activity_stream/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('ActivityStreamEdit').length).toBe(1); }); + + test('should navigate to activity stream detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/settings/activity_stream/details' + ); + }); + + test('should navigate to activity stream detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual( + '/settings/activity_stream/details' + ); + }); + + test('should successfully send request to api on form submission', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + ACTIVITY_STREAM_ENABLED: false, + ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: true, + }); + }); + + 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({ + ACTIVITY_STREAM_ENABLED: true, + ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: false, + }); + }); + + 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/AzureAD/AzureAD.test.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.test.jsx index fbb6e88b5c..65415f5468 100644 --- a/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.test.jsx +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureAD.test.jsx @@ -2,12 +2,25 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { SettingsProvider } from '../../../contexts/Settings'; import { SettingsAPI } from '../../../api'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import AzureAD from './AzureAD'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL: + 'https://towerhost/sso/complete/azuread-oauth2/', + SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key', + SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$', + SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: { + 'My Team': { + users: [], + }, + }, + }, }); describe('', () => { @@ -23,9 +36,14 @@ describe('', () => { initialEntries: ['/settings/azure/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('AzureADDetail').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx index cb97d182a1..889ac19163 100644 --- a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx @@ -13,7 +13,7 @@ import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; import useRequest from '../../../../util/useRequest'; import { SettingsAPI } from '../../../../api'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function AzureADDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx index 3aaf801740..115a854e8d 100644 --- a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.jsx @@ -1,25 +1,146 @@ -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 useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; + +function AzureADEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchAzureAD, result: azure } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('azuread-oauth2'); + 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(() => { + fetchAzureAD(); + }, [fetchAzureAD]); + + const { + error: submitError, + request: submitForm, + result: submitResult, + } = useRequest( + useCallback(async values => { + const result = await SettingsAPI.updateAll(values); + return result; + }, []), + null + ); + + useEffect(() => { + if (submitResult) { + history.push('/settings/azure/details'); + } + }, [submitResult, history]); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: JSON.parse( + form.SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP + ), + SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: JSON.parse( + form.SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP + ), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(azure).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/azure/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list' || fields[key].type === 'nested object') { + acc[key] = JSON.stringify(fields[key].value, null, 2); + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); -function AzureADEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && azure && ( + + {formik => ( +
+ + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(AzureADEdit); +export default AzureADEdit; diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx index 33ff2f09d8..1f8a8d258b 100644 --- a/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.test.jsx @@ -1,16 +1,147 @@ 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 AzureADEdit from './AzureADEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL: + 'https://towerhost/sso/complete/azuread-oauth2/', + SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key', + SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$', + SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: { + 'My Team': { + organization: 'foo', + }, + }, + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/azure/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('AzureADEdit').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_AZUREAD_OAUTH2_KEY: '', + SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '', + SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: null, + SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: null, + }); + }); + + test('should successfully send request to api on form submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key', + SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$', + SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: { + 'My Team': { + organization: 'foo', + }, + }, + }); + }); + + test('should navigate to azure detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/azure/details'); + }); + + test('should navigate to azure detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/azure/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/GitHubDetail/GitHubDetail.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx index 0b70e09d70..1fd6da3f0d 100644 --- a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx +++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx @@ -13,7 +13,7 @@ import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; import useRequest from '../../../../util/useRequest'; import { SettingsAPI } from '../../../../api'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function GitHubDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx index bbf512249c..f19f87378f 100644 --- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx +++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx @@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest'; import { DetailList } from '../../../../components/DetailList'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function GoogleOAuth2Detail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx index 5b099a802a..c25c221518 100644 --- a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx @@ -14,7 +14,7 @@ import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; import { SettingsAPI } from '../../../../api'; import { sortNestedDetails } from '../../shared/settingUtils'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function JobsDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx index dd881572e2..120b00fa44 100644 --- a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx +++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx @@ -13,7 +13,7 @@ import { SettingsAPI } from '../../../../api'; import useRequest from '../../../../util/useRequest'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; import { sortNestedDetails } from '../../shared/settingUtils'; function filterByPrefix(data, prefix) { diff --git a/awx/ui_next/src/screens/Setting/Logging/Logging.jsx b/awx/ui_next/src/screens/Setting/Logging/Logging.jsx index cc54dd81d1..a4d60db550 100644 --- a/awx/ui_next/src/screens/Setting/Logging/Logging.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/Logging.jsx @@ -4,11 +4,14 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection, Card } from '@patternfly/react-core'; import ContentError from '../../../components/ContentError'; +import { useConfig } from '../../../contexts/Config'; import LoggingDetail from './LoggingDetail'; import LoggingEdit from './LoggingEdit'; function Logging({ i18n }) { const baseURL = '/settings/logging'; + const { me } = useConfig(); + return ( @@ -18,11 +21,17 @@ function Logging({ i18n }) { - + {me?.is_superuser ? ( + + ) : ( + + )} - {i18n._(t`View Logging settings`)} + + {i18n._(t`View Logging settings`)} + diff --git a/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx b/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx index 87cf62dd7a..1fd76f8b3c 100644 --- a/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/Logging.test.jsx @@ -10,7 +10,29 @@ import Logging from './Logging'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + LOG_AGGREGATOR_HOST: null, + LOG_AGGREGATOR_PORT: null, + LOG_AGGREGATOR_TYPE: null, + LOG_AGGREGATOR_USERNAME: '', + LOG_AGGREGATOR_PASSWORD: '', + LOG_AGGREGATOR_LOGGERS: [ + 'awx', + 'activity_stream', + 'job_events', + 'system_tracking', + ], + LOG_AGGREGATOR_INDIVIDUAL_FACTS: false, + LOG_AGGREGATOR_ENABLED: false, + LOG_AGGREGATOR_TOWER_UUID: '', + LOG_AGGREGATOR_PROTOCOL: 'https', + LOG_AGGREGATOR_TCP_TIMEOUT: 5, + LOG_AGGREGATOR_VERIFY_CERT: true, + LOG_AGGREGATOR_LEVEL: 'INFO', + LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1, + LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx', + LOG_AGGREGATOR_RSYSLOGD_DEBUG: false, + }, }); describe('', () => { diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx index 11fe55ddfc..5ede6a1839 100644 --- a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx @@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest'; import { DetailList } from '../../../../components/DetailList'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; import { sortNestedDetails, pluck } from '../../shared/settingUtils'; function LoggingDetail({ i18n }) { diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx index 334518c4fb..9411332b7c 100644 --- a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx @@ -1,23 +1,272 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } 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 { Formik } from 'formik'; +import { Button, Form, Tooltip } 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 { + BooleanField, + ChoiceField, + EncryptedField, + InputField, + ObjectField, + RevertAllAlert, + RevertFormActionGroup, + LoggingTestAlert, +} from '../../shared'; +import useModal from '../../../../util/useModal'; +import useRequest, { useDismissableError } from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; function LoggingEdit({ i18n }) { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { + isLoading, + error, + request: fetchLogging, + result: logging, + } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('logging'); + const mergedData = {}; + Object.keys(data).forEach(key => { + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchLogging(); + }, [fetchLogging]); + + const { + error: submitError, + request: submitForm, + result: submitResult, + } = useRequest( + useCallback(async values => { + const result = await SettingsAPI.updateAll(values); + return result; + }, []), + null + ); + + useEffect(() => { + if (submitResult) { + history.push('/settings/logging/details'); + } + }, [submitResult, history]); + + const handleSubmit = async form => { + await submitForm({ + ...form, + LOG_AGGREGATOR_LOGGERS: JSON.parse(form.LOG_AGGREGATOR_LOGGERS), + LOG_AGGREGATOR_HOST: form.LOG_AGGREGATOR_HOST || null, + LOG_AGGREGATOR_TYPE: form.LOG_AGGREGATOR_TYPE || null, + }); + }; + + const handleRevertAll = async () => { + const defaultValues = {}; + Object.entries(logging).forEach(([key, value]) => { + defaultValues[key] = value.default; + }); + + await submitForm(defaultValues); + closeModal(); + }; + + const { + error: testLoggingError, + request: testLogging, + result: testSuccess, + setValue: setTestLogging, + } = useRequest( + useCallback(async () => { + const result = await SettingsAPI.test('logging', {}); + return result; + }, []), + null + ); + + const { + error: testError, + dismissError: dismissTestError, + } = useDismissableError(testLoggingError); + + const handleTest = async () => { + await testLogging(); + }; + + const handleCloseAlert = () => { + setTestLogging(null); + dismissTestError(); + }; + + const handleCancel = () => { + history.push('/settings/logging/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list') { + acc[key] = JSON.stringify(fields[key].value, null, 2); + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); + return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {formik => { + return ( +
+ + + + + + + + + + + {['tcp', 'https'].includes( + formik.values.LOG_AGGREGATOR_PROTOCOL + ) && ( + + )} + {formik.values.LOG_AGGREGATOR_PROTOCOL === 'https' && ( + + )} + + {submitError && } + + +
+ +
+
+
+
+ {isModalOpen && ( + + )} + {(testSuccess || testError) && ( + + )} + + ); + }} + + )}
); } diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx index ee1abc72f6..4b97720cf0 100644 --- a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.test.jsx @@ -1,16 +1,318 @@ 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 LoggingEdit from './LoggingEdit'; +jest.mock('../../../../api/models/Settings'); +const mockSettings = { + LOG_AGGREGATOR_HOST: 'https://logstash', + LOG_AGGREGATOR_PORT: 1234, + LOG_AGGREGATOR_TYPE: 'logstash', + LOG_AGGREGATOR_USERNAME: '', + LOG_AGGREGATOR_PASSWORD: '', + LOG_AGGREGATOR_LOGGERS: [ + 'awx', + 'activity_stream', + 'job_events', + 'system_tracking', + ], + LOG_AGGREGATOR_INDIVIDUAL_FACTS: false, + LOG_AGGREGATOR_ENABLED: true, + LOG_AGGREGATOR_TOWER_UUID: '', + LOG_AGGREGATOR_PROTOCOL: 'https', + LOG_AGGREGATOR_TCP_TIMEOUT: 123, + LOG_AGGREGATOR_VERIFY_CERT: true, + LOG_AGGREGATOR_LEVEL: 'ERROR', + LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1, + LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx', + LOG_AGGREGATOR_RSYSLOGD_DEBUG: false, +}; +const mockDefaultSettings = { + LOG_AGGREGATOR_HOST: null, + LOG_AGGREGATOR_PORT: null, + LOG_AGGREGATOR_TYPE: null, + LOG_AGGREGATOR_USERNAME: '', + LOG_AGGREGATOR_PASSWORD: '', + LOG_AGGREGATOR_LOGGERS: [ + 'awx', + 'activity_stream', + 'job_events', + 'system_tracking', + ], + LOG_AGGREGATOR_INDIVIDUAL_FACTS: false, + LOG_AGGREGATOR_ENABLED: false, + LOG_AGGREGATOR_TOWER_UUID: '', + LOG_AGGREGATOR_PROTOCOL: 'https', + LOG_AGGREGATOR_TCP_TIMEOUT: 5, + LOG_AGGREGATOR_VERIFY_CERT: true, + LOG_AGGREGATOR_LEVEL: 'INFO', + LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1, + LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx', + LOG_AGGREGATOR_RSYSLOGD_DEBUG: false, +}; + +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: mockSettings, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/logging/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('LoggingEdit').length).toBe(1); }); + + test('Enable External Logging toggle should be disabled when it is off and there is no Logging Aggregator or no Logging Aggregator Type', async () => { + const enableLoggingField = + "FormGroup[label='Enable External Logging'] Switch"; + const loggingAggregatorField = + "FormGroup[label='Logging Aggregator'] TextInputBase"; + expect(wrapper.find(enableLoggingField).prop('isChecked')).toBe(true); + expect(wrapper.find(enableLoggingField).prop('isDisabled')).toBe(false); + await act(async () => { + wrapper.find(enableLoggingField).invoke('onChange')(false); + }); + await act(async () => { + wrapper.find(loggingAggregatorField).invoke('onChange')(null, { + target: { + name: 'LOG_AGGREGATOR_HOST', + value: '', + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find(enableLoggingField) + .find('Switch') + .prop('isChecked') + ).toBe(false); + expect( + wrapper + .find(enableLoggingField) + .find('Switch') + .prop('isDisabled') + ).toBe(true); + }); + + test('Logging Aggregator and Logging Aggregator Type should be required when External Logging toggle is enabled', () => { + const enableLoggingField = wrapper.find( + "FormGroup[label='Enable External Logging']" + ); + const loggingAggregatorField = wrapper.find( + "FormGroup[label='Logging Aggregator']" + ); + const loggingAggregatorTypeField = wrapper.find( + "FormGroup[label='Logging Aggregator Type']" + ); + expect(enableLoggingField.find('RevertButton').text()).toEqual('Revert'); + expect( + loggingAggregatorField.find('.pf-c-form__label-required') + ).toHaveLength(1); + expect( + loggingAggregatorTypeField.find('.pf-c-form__label-required') + ).toHaveLength(1); + }); + + test('Logging Aggregator and Logging Aggregator Type should not be required when External Logging toggle is disabled', async () => { + await act(async () => { + wrapper + .find("FormGroup[label='Enable External Logging'] Switch") + .invoke('onChange')(false); + }); + wrapper.update(); + const enableLoggingField = wrapper.find( + "FormGroup[label='Enable External Logging']" + ); + const loggingAggregatorField = wrapper.find( + "FormGroup[label='Logging Aggregator']" + ); + const loggingAggregatorTypeField = wrapper.find( + "FormGroup[label='Logging Aggregator Type']" + ); + expect(enableLoggingField.find('RevertButton').text()).toEqual('Undo'); + expect( + loggingAggregatorField.find('.pf-c-form__label-required') + ).toHaveLength(0); + expect( + loggingAggregatorTypeField.find('.pf-c-form__label-required') + ).toHaveLength(0); + }); + + test('HTTPS certificate toggle should be shown when protocol is https', () => { + const httpsField = wrapper.find( + "FormGroup[label='Enable/disable HTTPS certificate verification']" + ); + expect(httpsField).toHaveLength(1); + expect(httpsField.find('Switch').prop('isChecked')).toBe(true); + }); + + test('TCP connection timeout should be required when protocol is tcp', () => { + const tcpTimeoutField = wrapper.find( + "FormGroup[label='TCP Connection Timeout']" + ); + expect(tcpTimeoutField).toHaveLength(1); + expect(tcpTimeoutField.find('.pf-c-form__label-required')).toHaveLength(1); + }); + + test('TCP connection timeout and https certificate toggle should be hidden when protocol is udp', async () => { + await act(async () => { + wrapper + .find('AnsibleSelect[name="LOG_AGGREGATOR_PROTOCOL"]') + .invoke('onChange')({ + target: { + name: 'LOG_AGGREGATOR_PROTOCOL', + value: 'udp', + }, + }); + }); + wrapper.update(); + expect( + wrapper.find( + "FormGroup[label='Enable/disable HTTPS certificate verification']" + ) + ).toHaveLength(0); + expect( + wrapper.find("FormGroup[label='TCP Connection Timeout']") + ).toHaveLength(0); + expect( + wrapper.find("FormGroup[label='Logging Aggregator Level Threshold']") + ).toHaveLength(1); + }); + + test('should display successful toast when test button is clicked', async () => { + SettingsAPI.test.mockResolvedValue({}); + expect(SettingsAPI.test).toHaveBeenCalledTimes(0); + expect(wrapper.find('LoggingTestAlert')).toHaveLength(0); + await act(async () => { + wrapper.find('button[aria-label="Test logging"]').invoke('onClick')(); + }); + wrapper.update(); + await waitForElement(wrapper, 'LoggingTestAlert'); + expect(SettingsAPI.test).toHaveBeenCalledTimes(1); + await act(async () => { + wrapper.find('AlertActionCloseButton button').invoke('onClick')(); + }); + await waitForElement(wrapper, 'LoggingTestAlert', el => el.length === 0); + }); + + 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(mockDefaultSettings); + }); + + test('should successfully send request to api on form submission', async () => { + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + const loggingAggregatorField = + "FormGroup[label='Logging Aggregator'] TextInputBase"; + await act(async () => { + wrapper.find(loggingAggregatorField).invoke('onChange')(null, { + target: { + name: 'LOG_AGGREGATOR_PORT', + value: 1010, + }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + ...mockSettings, + LOG_AGGREGATOR_PORT: 1010, + }); + }); + + test('should navigate to logging detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/logging/details'); + }); + + test('should navigate to logging detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/logging/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/MiscSystem/MiscSystem.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.jsx index 450c788477..5fd118e3bd 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystem.jsx @@ -4,11 +4,14 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection, Card } from '@patternfly/react-core'; import ContentError from '../../../components/ContentError'; +import { useConfig } from '../../../contexts/Config'; import MiscSystemDetail from './MiscSystemDetail'; import MiscSystemEdit from './MiscSystemEdit'; function MiscSystem({ i18n }) { const baseURL = '/settings/miscellaneous_system'; + const { me } = useConfig(); + return ( @@ -18,7 +21,11 @@ function MiscSystem({ i18n }) { - + {me?.is_superuser ? ( + + ) : ( + + )} diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx index a8fcdf411d..146e9d82a7 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx @@ -13,7 +13,7 @@ import { SettingsAPI } from '../../../../api'; import useRequest from '../../../../util/useRequest'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; import { sortNestedDetails, pluck } from '../../shared/settingUtils'; function MiscSystemDetail({ i18n }) { diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx index e6fa7fdf18..f4800d6a81 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx @@ -1,23 +1,305 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } 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 { 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 { + BooleanField, + EncryptedField, + InputField, + ObjectField, + RevertAllAlert, + RevertFormActionGroup, +} from '../../shared'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { SettingsAPI } from '../../../../api'; +import { pluck } from '../../shared/settingUtils'; function MiscSystemEdit({ i18n }) { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchSystem, result: system } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('all'); + const { + OAUTH2_PROVIDER: { + ACCESS_TOKEN_EXPIRE_SECONDS, + REFRESH_TOKEN_EXPIRE_SECONDS, + AUTHORIZATION_CODE_EXPIRE_SECONDS, + }, + ...pluckedSystemData + } = pluck( + data, + 'ALLOW_OAUTH2_FOR_EXTERNAL_USERS', + 'AUTH_BASIC_ENABLED', + 'AUTOMATION_ANALYTICS_GATHER_INTERVAL', + 'AUTOMATION_ANALYTICS_URL', + 'CUSTOM_VENV_PATHS', + 'INSIGHTS_TRACKING_STATE', + 'LOGIN_REDIRECT_OVERRIDE', + 'MANAGE_ORGANIZATION_AUTH', + 'OAUTH2_PROVIDER', + 'ORG_ADMINS_CAN_SEE_ALL_USERS', + 'REDHAT_PASSWORD', + 'REDHAT_USERNAME', + 'REMOTE_HOST_HEADERS', + 'SESSIONS_PER_USER', + 'SESSION_COOKIE_AGE', + 'TOWER_URL_BASE' + ); + + const systemData = { + ...pluckedSystemData, + ACCESS_TOKEN_EXPIRE_SECONDS, + REFRESH_TOKEN_EXPIRE_SECONDS, + AUTHORIZATION_CODE_EXPIRE_SECONDS, + }; + + const { + OAUTH2_PROVIDER: OAUTH2_PROVIDER_OPTIONS, + ...restOptions + } = options; + + const systemOptions = { + ...restOptions, + ACCESS_TOKEN_EXPIRE_SECONDS: { + ...OAUTH2_PROVIDER_OPTIONS, + default: OAUTH2_PROVIDER_OPTIONS.default.ACCESS_TOKEN_EXPIRE_SECONDS, + type: OAUTH2_PROVIDER_OPTIONS.child.type, + label: i18n._(t`Access Token Expiration`), + }, + REFRESH_TOKEN_EXPIRE_SECONDS: { + ...OAUTH2_PROVIDER_OPTIONS, + default: OAUTH2_PROVIDER_OPTIONS.default.REFRESH_TOKEN_EXPIRE_SECONDS, + type: OAUTH2_PROVIDER_OPTIONS.child.type, + label: i18n._(t`Refresh Token Expiration`), + }, + AUTHORIZATION_CODE_EXPIRE_SECONDS: { + ...OAUTH2_PROVIDER_OPTIONS, + default: + OAUTH2_PROVIDER_OPTIONS.default.AUTHORIZATION_CODE_EXPIRE_SECONDS, + type: OAUTH2_PROVIDER_OPTIONS.child.type, + label: i18n._(t`Authorization Code Expiration`), + }, + }; + + const mergedData = {}; + Object.keys(systemData).forEach(key => { + mergedData[key] = systemOptions[key]; + mergedData[key].value = systemData[key]; + }); + return mergedData; + }, [options, i18n]), + null + ); + + useEffect(() => { + fetchSystem(); + }, [fetchSystem]); + + const { + error: submitError, + request: submitForm, + result: submitResult, + } = useRequest( + useCallback(async values => { + const result = await SettingsAPI.updateAll(values); + return result; + }, []), + null + ); + + const handleSubmit = async form => { + const { + ACCESS_TOKEN_EXPIRE_SECONDS, + REFRESH_TOKEN_EXPIRE_SECONDS, + AUTHORIZATION_CODE_EXPIRE_SECONDS, + ...formData + } = form; + await submitForm({ + ...formData, + CUSTOM_VENV_PATHS: JSON.parse(formData.CUSTOM_VENV_PATHS), + REMOTE_HOST_HEADERS: JSON.parse(formData.REMOTE_HOST_HEADERS), + OAUTH2_PROVIDER: { + ACCESS_TOKEN_EXPIRE_SECONDS, + REFRESH_TOKEN_EXPIRE_SECONDS, + AUTHORIZATION_CODE_EXPIRE_SECONDS, + }, + }); + }; + + useEffect(() => { + if (submitResult) { + history.push('/settings/miscellaneous_system/details'); + } + }, [submitResult, history]); + + const handleRevertAll = async () => { + const { + ACCESS_TOKEN_EXPIRE_SECONDS, + REFRESH_TOKEN_EXPIRE_SECONDS, + AUTHORIZATION_CODE_EXPIRE_SECONDS, + ...systemData + } = system; + + const defaultValues = {}; + Object.entries(systemData).forEach(([key, value]) => { + defaultValues[key] = value.default; + }); + + await submitForm({ + ...defaultValues, + OAUTH2_PROVIDER: { + ACCESS_TOKEN_EXPIRE_SECONDS: ACCESS_TOKEN_EXPIRE_SECONDS.default, + REFRESH_TOKEN_EXPIRE_SECONDS: REFRESH_TOKEN_EXPIRE_SECONDS.default, + AUTHORIZATION_CODE_EXPIRE_SECONDS: + AUTHORIZATION_CODE_EXPIRE_SECONDS.default, + }, + }); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/miscellaneous_system/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list') { + acc[key] = JSON.stringify(fields[key].value, null, 2); + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); + return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {formik => { + return ( +
+ + + + + + + + + + + + + + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + ); + }} + + )}
); } diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx index 9d3441a413..74b483f1b7 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.test.jsx @@ -1,16 +1,124 @@ 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 mockAllSettings from '../../shared/data.allSettings.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import MiscSystemEdit from './MiscSystemEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: mockAllSettings, +}); describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/miscellaneous_system/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('MiscSystemEdit').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); + }); + + test('should successfully send request to api on form submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should navigate to miscellaneous detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual( + '/settings/miscellaneous_system/details' + ); + }); + + test('should navigate to miscellaneous detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/settings/miscellaneous_system/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/RADIUS/RADIUSDetail/RADIUSDetail.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx index d1d8a4e6f6..52b313bbe0 100644 --- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx +++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx @@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest'; import { DetailList } from '../../../../components/DetailList'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function RADIUSDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx index e6694d8591..0a7fac5806 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx @@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest'; import { DetailList } from '../../../../components/DetailList'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function SAMLDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/Settings.test.jsx b/awx/ui_next/src/screens/Setting/Settings.test.jsx index 6f00bf2f5e..faead37231 100644 --- a/awx/ui_next/src/screens/Setting/Settings.test.jsx +++ b/awx/ui_next/src/screens/Setting/Settings.test.jsx @@ -1,16 +1,78 @@ 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 { SettingsAPI } from '../../api'; +import mockAllOptions from './shared/data.allSettingOptions.json'; import Settings from './Settings'; +jest.mock('../../api/models/Settings'); +SettingsAPI.readAllOptions.mockResolvedValue({ + data: mockAllOptions, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); - test('initially renders without crashing', () => { - expect(wrapper.length).toBe(1); + + test('should render Redirect for users without system admin or auditor permissions', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { + history, + }, + config: { + me: { + is_superuser: false, + is_system_auditor: false, + }, + }, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('Redirect').length).toBe(1); + expect(wrapper.find('SettingList').length).toBe(0); + }); + + test('should render Settings for users with permissions system admin or auditor permissions', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { + history, + }, + config: { + is_superuser: true, + is_system_auditor: true, + }, + }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('SettingList').length).toBe(1); + }); + + test('should render content error on throw', async () => { + SettingsAPI.readAllOptions.mockRejectedValue(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/TACACS/TACACSDetail/TACACSDetail.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx index de85665808..6c9f1a85de 100644 --- a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx +++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx @@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest'; import { DetailList } from '../../../../components/DetailList'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function TACACSDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx index 2d4e475006..ef458e5163 100644 --- a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx +++ b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx @@ -14,7 +14,7 @@ import { DetailList } from '../../../../components/DetailList'; import { useConfig } from '../../../../contexts/Config'; import { useSettings } from '../../../../contexts/Settings'; import { pluck } from '../../shared/settingUtils'; -import SettingDetail from '../../shared'; +import { SettingDetail } from '../../shared'; function UIDetail({ i18n }) { const { me } = useConfig(); diff --git a/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx new file mode 100644 index 0000000000..a1c3903f53 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { func, shape } from 'prop-types'; +import { + Alert, + AlertActionCloseButton, + AlertGroup, +} from '@patternfly/react-core'; + +function LoggingTestAlert({ i18n, successResponse, errorResponse, onClose }) { + let testMessage = null; + if (successResponse) { + testMessage = i18n._(t`Log aggregator test sent successfully.`); + } + + let errorData = null; + if (errorResponse) { + testMessage = i18n._(t`There was an error testing the log aggregator.`); + if ( + errorResponse?.response?.statusText && + errorResponse?.response?.status + ) { + testMessage = i18n._( + t`${errorResponse.response.statusText}: ${errorResponse.response.status}` + ); + errorData = i18n._(t`${errorResponse.response?.data?.error}`); + } + } + + return ( + + {testMessage && ( + } + title={successResponse ? i18n._(t`Success`) : i18n._(t`Error`)} + variant={successResponse ? 'success' : 'danger'} + > + {testMessage} +

{errorData}

+
+ )} +
+ ); +} + +LoggingTestAlert.propTypes = { + successResponse: shape({}), + errorResponse: shape({}), + onClose: func, +}; + +LoggingTestAlert.defaultProps = { + successResponse: null, + errorResponse: null, + onClose: () => {}, +}; + +export default withI18n()(LoggingTestAlert); diff --git a/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.test.jsx b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.test.jsx new file mode 100644 index 0000000000..105cfe7382 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import LoggingTestAlert from './LoggingTestAlert'; + +describe('LoggingTestAlert', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('renders expected content when test is successful', () => { + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('b#test-message').text()).toBe( + 'Log aggregator test sent successfully.' + ); + }); + + test('renders expected content when test is unsuccessful', () => { + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('b#test-message').text()).toBe('Bad Response: 400'); + expect(wrapper.find('p#test-error').text()).toBe( + 'Name or service not known' + ); + }); + + test('close button should call "onClose"', () => { + const onClose = jest.fn(); + expect(onClose).toHaveBeenCalledTimes(0); + wrapper = mountWithContexts( + + ); + wrapper.find('AlertActionCloseButton').invoke('onClose')(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx new file mode 100644 index 0000000000..42855de751 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import AlertModal from '../../../components/AlertModal'; + +function RevertAllAlert({ i18n, onClose, onRevertAll }) { + return ( + + {i18n._(t`Revert all`)} + , + , + ]} + > + {i18n._(t`This will revert all configuration values to their + factory defaults. Are you sure you want to proceed?`)} + + ); +} + +export default withI18n()(RevertAllAlert); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.test.jsx b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.test.jsx new file mode 100644 index 0000000000..a4ef356b28 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.test.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import RevertAllAlert from './RevertAllAlert'; + +describe('RevertAllAlert', () => { + test('renders the expected content', async () => { + const wrapper = mountWithContexts( + {}} onRevertAll={() => {}} /> + ); + expect(wrapper).toHaveLength(1); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx new file mode 100644 index 0000000000..c51de0e1c2 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { Button, Tooltip } from '@patternfly/react-core'; +import styled from 'styled-components'; + +const ButtonWrapper = styled.div` + margin-left: auto; + &&& { + --pf-c-button--FontSize: var(--pf-c-button--m-small--FontSize); + } +`; + +function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { + const [field, meta, helpers] = useField(id); + const initialValue = meta.initialValue ?? ''; + const currentValue = field.value; + let isRevertable = true; + let isMatch = false; + + if (currentValue === defaultValue && currentValue !== initialValue) { + isRevertable = false; + } + + if (currentValue === defaultValue && currentValue === initialValue) { + isMatch = true; + } + + function handleConfirm() { + helpers.setValue(isRevertable ? defaultValue : initialValue); + } + + const revertTooltipContent = isRevertable + ? i18n._(t`Revert to factory default.`) + : i18n._(t`Restore initial value.`); + const tooltipContent = + isDisabled || isMatch + ? i18n._(t`Setting matches factory default.`) + : revertTooltipContent; + + return ( + + + + + + ); +} + +RevertButton.propTypes = { + id: PropTypes.string.isRequired, +}; + +export default withI18n()(RevertButton); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertButton.test.jsx b/awx/ui_next/src/screens/Setting/shared/RevertButton.test.jsx new file mode 100644 index 0000000000..0592082bdf --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/RevertButton.test.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Formik } from 'formik'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import RevertButton from './RevertButton'; + +describe('RevertButton', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + test('button text should display "Revert"', async () => { + wrapper = mountWithContexts( + + {() => } + + ); + expect(wrapper.find('button').text()).toEqual('Revert'); + }); + + test('button text should display "Undo"', async () => { + wrapper = mountWithContexts( + + {() => } + + ); + expect(wrapper.find('button').text()).toEqual('Revert'); + }); + + test('should revert value to default on button click', async () => { + wrapper = mountWithContexts( + + {() => } + + ); + expect(wrapper.find('button').text()).toEqual('Revert'); + await act(async () => { + wrapper.find('button[aria-label="Revert"]').invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('button').text()).toEqual('Undo'); + }); + + test('should be disabled when current value equals the initial and default values', async () => { + wrapper = mountWithContexts( + + {() => } + + ); + expect(wrapper.find('button').text()).toEqual('Revert'); + expect(wrapper.find('button').props().disabled).toBe(true); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx new file mode 100644 index 0000000000..03e80d4eb1 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { ActionGroup, Button } from '@patternfly/react-core'; +import { FormFullWidthLayout } from '../../../components/FormLayout'; + +const RevertFormActionGroup = ({ + children, + onCancel, + onRevert, + onSubmit, + i18n, +}) => { + return ( + + + + + {children} + + + + ); +}; + +RevertFormActionGroup.propTypes = { + onCancel: PropTypes.func.isRequired, + onRevert: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default withI18n()(RevertFormActionGroup); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.test.jsx b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.test.jsx new file mode 100644 index 0000000000..6ea958e71c --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.test.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import RevertFormActionGroup from './RevertFormActionGroup'; + +describe('RevertFormActionGroup', () => { + test('should render the expected content', () => { + const wrapper = mountWithContexts( + {}} + onCancel={() => {}} + onRevert={() => {}} + /> + ); + expect(wrapper).toHaveLength(1); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx b/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx index e3ab354a44..ddcb5fb44d 100644 --- a/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Detail } from '../../../components/DetailList'; -import { VariablesDetail } from '../../../components/CodeMirrorInput'; +import CodeDetail from '../../../components/DetailList/CodeDetail'; export default withI18n()( ({ i18n, helpText, id, label, type, unit = '', value }) => { @@ -12,10 +12,11 @@ export default withI18n()( switch (dataType) { case 'nested object': detail = ( - @@ -23,12 +24,13 @@ export default withI18n()( break; case 'list': detail = ( - ); break; diff --git a/awx/ui_next/src/screens/Setting/shared/ShareFields.test.jsx b/awx/ui_next/src/screens/Setting/shared/ShareFields.test.jsx new file mode 100644 index 0000000000..e603ae10fe --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/ShareFields.test.jsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Formik } from 'formik'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { + BooleanField, + ChoiceField, + EncryptedField, + InputField, + ObjectField, +} from './SharedFields'; + +describe('Setting form fields', () => { + test('BooleanField renders the expected content', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('Switch')).toHaveLength(1); + expect(wrapper.find('Switch').prop('isChecked')).toBe(true); + expect(wrapper.find('Switch').prop('isDisabled')).toBe(false); + await act(async () => { + wrapper.find('Switch').invoke('onChange')(false); + }); + wrapper.update(); + expect(wrapper.find('Switch').prop('isChecked')).toBe(false); + }); + + test('ChoiceField renders unrequired form field', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('FormSelect')).toHaveLength(1); + expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(0); + }); + + test('EncryptedField renders the expected content', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('PasswordInput')).toHaveLength(1); + }); + + test('InputField renders the expected content', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('TextInputBase')).toHaveLength(1); + expect(wrapper.find('TextInputBase').prop('value')).toEqual(''); + await act(async () => { + wrapper.find('TextInputBase').invoke('onChange')(null, { + target: { + name: 'text', + value: 'foo', + }, + }); + }); + wrapper.update(); + expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo'); + }); + + test('ObjectField renders the expected content', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('CodeMirrorInput')).toHaveLength(1); + expect(wrapper.find('CodeMirrorInput').prop('value')).toBe( + '["one", "two", "three"]' + ); + await act(async () => { + wrapper.find('CodeMirrorInput').invoke('onChange')('[]'); + }); + wrapper.update(); + expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]'); + }); +}); diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx new file mode 100644 index 0000000000..29c084550b --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -0,0 +1,258 @@ +import React from 'react'; +import { bool, oneOf, shape, string } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { + FormGroup as PFFormGroup, + InputGroup, + TextInput, + Switch, +} from '@patternfly/react-core'; +import styled from 'styled-components'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import CodeMirrorInput from '../../../components/CodeMirrorInput'; +import { PasswordInput } from '../../../components/FormField'; +import { FormFullWidthLayout } from '../../../components/FormLayout'; +import Popover from '../../../components/Popover'; +import { combine, required, url, minMaxValue } from '../../../util/validators'; +import RevertButton from './RevertButton'; + +const FormGroup = styled(PFFormGroup)` + .pf-c-form__group-label { + display: inline-flex; + align-items: center; + width: 100%; + } +`; + +const SettingGroup = withI18n()( + ({ + i18n, + children, + defaultValue, + fieldId, + helperTextInvalid, + isDisabled, + isRequired, + label, + popoverContent, + validated, + }) => { + return ( + + + + + } + > + {children} + + ); + } +); + +const BooleanField = withI18n()( + ({ i18n, ariaLabel = '', name, config, disabled = false }) => { + const [field, meta, helpers] = useField(name); + + return ( + + helpers.setValue(checked)} + aria-label={ariaLabel || config.label} + /> + + ); + } +); +BooleanField.propTypes = { + name: string.isRequired, + config: shape({}).isRequired, + ariaLabel: string, + disabled: bool, +}; + +const ChoiceField = withI18n()(({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [field, meta] = useField({ name, validate }); + const isValid = !meta.error || !meta.touched; + + return ( + + ({ + label, + value: value ?? '', + key: value ?? index, + })), + ]} + /> + + ); +}); +ChoiceField.propTypes = { + name: string.isRequired, + config: shape({}).isRequired, + isRequired: bool, +}; + +const EncryptedField = withI18n()( + ({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [, meta] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return ( + + + + + + ); + } +); +EncryptedField.propTypes = { + name: string.isRequired, + config: shape({}).isRequired, + isRequired: bool, +}; + +const InputField = withI18n()( + ({ i18n, name, config, type = 'text', isRequired = false }) => { + const { + min_value = Number.MIN_SAFE_INTEGER, + max_value = Number.MAX_SAFE_INTEGER, + } = config; + const validators = [ + isRequired ? required(null, i18n) : null, + type === 'url' ? url(i18n) : null, + type === 'number' ? minMaxValue(min_value, max_value, i18n) : null, + ]; + const [field, meta] = useField({ name, validate: combine(validators) }); + const isValid = !(meta.touched && meta.error); + + return ( + + { + field.onChange(event); + }} + /> + + ); + } +); +InputField.propTypes = { + name: string.isRequired, + config: shape({}).isRequired, + type: oneOf(['text', 'number', 'url']), + isRequired: bool, +}; + +const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [field, meta, helpers] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + const emptyDefault = config.type === 'list' ? '[]' : '{}'; + const defaultRevertValue = config.default + ? JSON.stringify(config.default, null, 2) + : emptyDefault; + + return ( + + + { + helpers.setValue(value); + }} + mode="javascript" + /> + + + ); +}); +ObjectField.propTypes = { + name: string.isRequired, + config: shape({}).isRequired, + isRequired: bool, +}; + +export { BooleanField, ChoiceField, EncryptedField, InputField, ObjectField }; diff --git a/awx/ui_next/src/screens/Setting/shared/data.allSettings.json b/awx/ui_next/src/screens/Setting/shared/data.allSettings.json new file mode 100644 index 0000000000..2567289cf7 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/shared/data.allSettings.json @@ -0,0 +1,309 @@ +{ + "ACTIVITY_STREAM_ENABLED":true, + "ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC":false, + "ORG_ADMINS_CAN_SEE_ALL_USERS":true, + "MANAGE_ORGANIZATION_AUTH":true, + "TOWER_URL_BASE":"https://localhost:3000", + "REMOTE_HOST_HEADERS":["REMOTE_ADDR","REMOTE_HOST"], + "PROXY_IP_ALLOWED_LIST":[], + "LICENSE":{}, + "REDHAT_USERNAME":"", + "REDHAT_PASSWORD":"", + "AUTOMATION_ANALYTICS_URL":"https://example.com", + "INSTALL_UUID":"3f5a4d68-3a94-474c-a3c0-f23a33122ce6", + "CUSTOM_VENV_PATHS":[], + "AD_HOC_COMMANDS":[ + "command", + "shell", + "yum", + "apt", + "apt_key", + "apt_repository", + "apt_rpm", + "service", + "group", + "user", + "mount", + "ping", + "selinux", + "setup", + "win_ping", + "win_service", + "win_updates", + "win_group", + "win_user" + ], + "ALLOW_JINJA_IN_EXTRA_VARS":"template", + "AWX_PROOT_ENABLED":true, + "AWX_PROOT_BASE_PATH":"/tmp", + "AWX_PROOT_HIDE_PATHS":[], + "AWX_PROOT_SHOW_PATHS":[], + "AWX_ISOLATED_CHECK_INTERVAL":1, + "AWX_ISOLATED_LAUNCH_TIMEOUT":600, + "AWX_ISOLATED_CONNECTION_TIMEOUT":10, + "AWX_ISOLATED_HOST_KEY_CHECKING":false, + "AWX_ISOLATED_KEY_GENERATION":true, + "AWX_ISOLATED_PRIVATE_KEY":"", + "AWX_ISOLATED_PUBLIC_KEY":"", + "AWX_RESOURCE_PROFILING_ENABLED":false, + "AWX_RESOURCE_PROFILING_CPU_POLL_INTERVAL":0.25, + "AWX_RESOURCE_PROFILING_MEMORY_POLL_INTERVAL":0.25, + "AWX_RESOURCE_PROFILING_PID_POLL_INTERVAL":0.25, + "AWX_TASK_ENV":{}, + "INSIGHTS_TRACKING_STATE":false, + "PROJECT_UPDATE_VVV":false, + "AWX_ROLES_ENABLED":true, + "AWX_COLLECTIONS_ENABLED":true, + "AWX_SHOW_PLAYBOOK_LINKS":false, + "GALAXY_IGNORE_CERTS":false, + "STDOUT_MAX_BYTES_DISPLAY":1048576, + "EVENT_STDOUT_MAX_BYTES_DISPLAY":1024, + "SCHEDULE_MAX_JOBS":10, + "AWX_ANSIBLE_CALLBACK_PLUGINS":[], + "DEFAULT_JOB_TIMEOUT":0, + "DEFAULT_INVENTORY_UPDATE_TIMEOUT":0, + "DEFAULT_PROJECT_UPDATE_TIMEOUT":0, + "ANSIBLE_FACT_CACHE_TIMEOUT":0, + "MAX_FORKS":200, + "LOG_AGGREGATOR_HOST":null, + "LOG_AGGREGATOR_PORT":null, + "LOG_AGGREGATOR_TYPE":null, + "LOG_AGGREGATOR_USERNAME":"", + "LOG_AGGREGATOR_PASSWORD":"", + "LOG_AGGREGATOR_LOGGERS":["awx","activity_stream","job_events","system_tracking"], + "LOG_AGGREGATOR_INDIVIDUAL_FACTS":false, + "LOG_AGGREGATOR_ENABLED":true, + "LOG_AGGREGATOR_TOWER_UUID":"", + "LOG_AGGREGATOR_PROTOCOL":"https", + "LOG_AGGREGATOR_TCP_TIMEOUT":5, + "LOG_AGGREGATOR_VERIFY_CERT":true, + "LOG_AGGREGATOR_LEVEL":"INFO", + "LOG_AGGREGATOR_MAX_DISK_USAGE_GB":1, + "LOG_AGGREGATOR_MAX_DISK_USAGE_PATH":"/var/lib/awx", + "LOG_AGGREGATOR_RSYSLOGD_DEBUG":false, + "AUTOMATION_ANALYTICS_LAST_GATHER":null, + "AUTOMATION_ANALYTICS_GATHER_INTERVAL":14400, + "SESSION_COOKIE_AGE":1800, + "SESSIONS_PER_USER":-1, + "AUTH_BASIC_ENABLED":true, + "OAUTH2_PROVIDER":{ + "ACCESS_TOKEN_EXPIRE_SECONDS":31536000000, + "REFRESH_TOKEN_EXPIRE_SECONDS":2628000, + "AUTHORIZATION_CODE_EXPIRE_SECONDS":600 + }, + "ALLOW_OAUTH2_FOR_EXTERNAL_USERS":false, + "LOGIN_REDIRECT_OVERRIDE":"", + "PENDO_TRACKING_STATE":"off", + "CUSTOM_LOGIN_INFO":"", + "CUSTOM_LOGO":"", + "MAX_UI_JOB_EVENTS":4000, + "UI_LIVE_UPDATES_ENABLED":true, + "AUTHENTICATION_BACKENDS":[ + "awx.sso.backends.LDAPBackend", + "awx.sso.backends.RADIUSBackend", + "awx.sso.backends.TACACSPlusBackend", + "social_core.backends.github.GithubTeamOAuth2", + "django.contrib.auth.backends.ModelBackend" + ], + "SOCIAL_AUTH_ORGANIZATION_MAP":null, + "SOCIAL_AUTH_TEAM_MAP":null, + "SOCIAL_AUTH_USER_FIELDS":null, + "AUTH_LDAP_SERVER_URI":"ldap://ldap.example.com", + "AUTH_LDAP_BIND_DN":"cn=eng_user1", + "AUTH_LDAP_BIND_PASSWORD":"$encrypted$", + "AUTH_LDAP_START_TLS":false, + "AUTH_LDAP_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30}, + "AUTH_LDAP_USER_SEARCH":[], + "AUTH_LDAP_USER_DN_TEMPLATE":"uid=%(user)s,OU=Users,DC=example,DC=com", + "AUTH_LDAP_USER_ATTR_MAP":{}, + "AUTH_LDAP_GROUP_SEARCH":["DC=example,DC=com","SCOPE_SUBTREE","(objectClass=group)"], + "AUTH_LDAP_GROUP_TYPE":"MemberDNGroupType", + "AUTH_LDAP_GROUP_TYPE_PARAMS":{"name_attr":"cn","member_attr":"member"}, + "AUTH_LDAP_REQUIRE_GROUP":"CN=Tower Users,OU=Users,DC=example,DC=com", + "AUTH_LDAP_DENY_GROUP":null, + "AUTH_LDAP_USER_FLAGS_BY_GROUP":{"is_superuser":["cn=superusers"]}, + "AUTH_LDAP_ORGANIZATION_MAP":{}, + "AUTH_LDAP_TEAM_MAP":{}, + "AUTH_LDAP_1_SERVER_URI":"", + "AUTH_LDAP_1_BIND_DN":"", + "AUTH_LDAP_1_BIND_PASSWORD":"", + "AUTH_LDAP_1_START_TLS":true, + "AUTH_LDAP_1_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30}, + "AUTH_LDAP_1_USER_SEARCH":[], + "AUTH_LDAP_1_USER_DN_TEMPLATE":null, + "AUTH_LDAP_1_USER_ATTR_MAP":{}, + "AUTH_LDAP_1_GROUP_SEARCH":[], + "AUTH_LDAP_1_GROUP_TYPE":"MemberDNGroupType", + "AUTH_LDAP_1_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"}, + "AUTH_LDAP_1_REQUIRE_GROUP":null, + "AUTH_LDAP_1_DENY_GROUP":"CN=Disabled1", + "AUTH_LDAP_1_USER_FLAGS_BY_GROUP":{}, + "AUTH_LDAP_1_ORGANIZATION_MAP":{}, + "AUTH_LDAP_1_TEAM_MAP":{}, + "AUTH_LDAP_2_SERVER_URI":"", + "AUTH_LDAP_2_BIND_DN":"", + "AUTH_LDAP_2_BIND_PASSWORD":"", + "AUTH_LDAP_2_START_TLS":false, + "AUTH_LDAP_2_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30}, + "AUTH_LDAP_2_USER_SEARCH":[], + "AUTH_LDAP_2_USER_DN_TEMPLATE":null, + "AUTH_LDAP_2_USER_ATTR_MAP":{}, + "AUTH_LDAP_2_GROUP_SEARCH":[], + "AUTH_LDAP_2_GROUP_TYPE":"MemberDNGroupType", + "AUTH_LDAP_2_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"}, + "AUTH_LDAP_2_REQUIRE_GROUP":null, + "AUTH_LDAP_2_DENY_GROUP":"CN=Disabled2", + "AUTH_LDAP_2_USER_FLAGS_BY_GROUP":{}, + "AUTH_LDAP_2_ORGANIZATION_MAP":{}, + "AUTH_LDAP_2_TEAM_MAP":{}, + "AUTH_LDAP_3_SERVER_URI":"", + "AUTH_LDAP_3_BIND_DN":"", + "AUTH_LDAP_3_BIND_PASSWORD":"", + "AUTH_LDAP_3_START_TLS":false, + "AUTH_LDAP_3_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30}, + "AUTH_LDAP_3_USER_SEARCH":[], + "AUTH_LDAP_3_USER_DN_TEMPLATE":null, + "AUTH_LDAP_3_USER_ATTR_MAP":{}, + "AUTH_LDAP_3_GROUP_SEARCH":[], + "AUTH_LDAP_3_GROUP_TYPE":"MemberDNGroupType", + "AUTH_LDAP_3_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"}, + "AUTH_LDAP_3_REQUIRE_GROUP":null, + "AUTH_LDAP_3_DENY_GROUP":null, + "AUTH_LDAP_3_USER_FLAGS_BY_GROUP":{}, + "AUTH_LDAP_3_ORGANIZATION_MAP":{}, + "AUTH_LDAP_3_TEAM_MAP":{}, + "AUTH_LDAP_4_SERVER_URI":"", + "AUTH_LDAP_4_BIND_DN":"", + "AUTH_LDAP_4_BIND_PASSWORD":"", + "AUTH_LDAP_4_START_TLS":false, + "AUTH_LDAP_4_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30}, + "AUTH_LDAP_4_USER_SEARCH":[], + "AUTH_LDAP_4_USER_DN_TEMPLATE":null, + "AUTH_LDAP_4_USER_ATTR_MAP":{}, + "AUTH_LDAP_4_GROUP_SEARCH":[], + "AUTH_LDAP_4_GROUP_TYPE":"MemberDNGroupType", + "AUTH_LDAP_4_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"}, + "AUTH_LDAP_4_REQUIRE_GROUP":null, + "AUTH_LDAP_4_DENY_GROUP":null, + "AUTH_LDAP_4_USER_FLAGS_BY_GROUP":{}, + "AUTH_LDAP_4_ORGANIZATION_MAP":{}, + "AUTH_LDAP_4_TEAM_MAP":{}, + "AUTH_LDAP_5_SERVER_URI":"", + "AUTH_LDAP_5_BIND_DN":"", + "AUTH_LDAP_5_BIND_PASSWORD":"", + "AUTH_LDAP_5_START_TLS":false, + "AUTH_LDAP_5_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30}, + "AUTH_LDAP_5_USER_SEARCH":[], + "AUTH_LDAP_5_USER_DN_TEMPLATE":null, + "AUTH_LDAP_5_USER_ATTR_MAP":{}, + "AUTH_LDAP_5_GROUP_SEARCH":[], + "AUTH_LDAP_5_GROUP_TYPE":"MemberDNGroupType", + "AUTH_LDAP_5_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"}, + "AUTH_LDAP_5_REQUIRE_GROUP":null, + "AUTH_LDAP_5_DENY_GROUP":null, + "AUTH_LDAP_5_USER_FLAGS_BY_GROUP":{}, + "AUTH_LDAP_5_ORGANIZATION_MAP":{}, + "AUTH_LDAP_5_TEAM_MAP":{}, + "RADIUS_SERVER":"example.org", + "RADIUS_PORT":1812, + "RADIUS_SECRET":"$encrypted$", + "TACACSPLUS_HOST":"", + "TACACSPLUS_PORT":49, + "TACACSPLUS_SECRET":"", + "TACACSPLUS_SESSION_TIMEOUT":5, + "TACACSPLUS_AUTH_PROTOCOL":"ascii", + "SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL":"https://localhost:3000/sso/complete/google-oauth2/", + "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY":"", + "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET":"", + "SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS":[], + "SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS":{}, + "SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP":null, + "SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP":null, + "SOCIAL_AUTH_GITHUB_CALLBACK_URL":"https://localhost:3000/sso/complete/github/", + "SOCIAL_AUTH_GITHUB_KEY":"", + "SOCIAL_AUTH_GITHUB_SECRET":"", + "SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP":null, + "SOCIAL_AUTH_GITHUB_TEAM_MAP":null, + "SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL":"https://localhost:3000/sso/complete/github-org/", + "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, + "SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL":"https://localhost:3000/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":{}, + "SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL":"https://localhost:3000/sso/complete/azuread-oauth2/", + "SOCIAL_AUTH_AZUREAD_OAUTH2_KEY":"", + "SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET":"", + "SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP":null, + "SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP":null, + "SAML_AUTO_CREATE_OBJECTS":true, + "SOCIAL_AUTH_SAML_CALLBACK_URL":"https://localhost:3000/sso/complete/saml/", + "SOCIAL_AUTH_SAML_METADATA_URL":"https://localhost:3000/sso/metadata/saml/", + "SOCIAL_AUTH_SAML_SP_ENTITY_ID":"", + "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT":"", + "SOCIAL_AUTH_SAML_SP_PRIVATE_KEY":"", + "SOCIAL_AUTH_SAML_ORG_INFO":{}, + "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT":{}, + "SOCIAL_AUTH_SAML_SUPPORT_CONTACT":{}, + "SOCIAL_AUTH_SAML_ENABLED_IDPS":{}, + "SOCIAL_AUTH_SAML_SECURITY_CONFIG":{"requestedAuthnContext":false}, + "SOCIAL_AUTH_SAML_SP_EXTRA":null, + "SOCIAL_AUTH_SAML_EXTRA_DATA":null, + "SOCIAL_AUTH_SAML_ORGANIZATION_MAP":null, + "SOCIAL_AUTH_SAML_TEAM_MAP":null, + "SOCIAL_AUTH_SAML_ORGANIZATION_ATTR":{}, + "SOCIAL_AUTH_SAML_TEAM_ATTR":{}, + "NAMED_URL_FORMATS":{ + "organizations":"", + "teams":"++", + "credential_types":"+", + "credentials":"+++++", + "notification_templates":"++", + "job_templates":"++", + "projects":"++", + "inventories":"++", + "hosts":"++++", + "groups":"++++", + "inventory_sources":"++++", + "inventory_scripts":"++", + "instance_groups":"", + "labels":"++", + "workflow_job_templates":"++", + "workflow_job_template_nodes":"++++", + "applications":"++", + "users":"", + "instances":"" + }, + "NAMED_URL_GRAPH_NODES":{ + "organizations":{"fields":["name"],"adj_list":[]}, + "teams":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "credential_types":{"fields":["name","kind"],"adj_list":[]}, + "credentials":{ + "fields":["name"], + "adj_list":[["credential_type","credential_types"],["organization","organizations"]] + }, + "notification_templates":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "job_templates":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "projects":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "inventories":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "hosts":{"fields":["name"],"adj_list":[["inventory","inventories"]]}, + "groups":{"fields":["name"],"adj_list":[["inventory","inventories"]]}, + "inventory_sources":{"fields":["name"],"adj_list":[["inventory","inventories"]]}, + "inventory_scripts":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "instance_groups":{"fields":["name"],"adj_list":[]}, + "labels":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "workflow_job_templates":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "workflow_job_template_nodes":{ + "fields":["identifier"], + "adj_list":[["workflow_job_template","workflow_job_templates"]] + }, + "applications":{"fields":["name"],"adj_list":[["organization","organizations"]]}, + "users":{"fields":["username"],"adj_list":[]}, + "instances":{"fields":["hostname"],"adj_list":[]} + } +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Setting/shared/index.js b/awx/ui_next/src/screens/Setting/shared/index.js index 3668a539bf..cd9a2ab833 100644 --- a/awx/ui_next/src/screens/Setting/shared/index.js +++ b/awx/ui_next/src/screens/Setting/shared/index.js @@ -1 +1,11 @@ -export { default } from './SettingDetail'; +export { default as LoggingTestAlert } from './LoggingTestAlert'; +export { default as SettingDetail } from './SettingDetail'; +export { default as RevertAllAlert } from './RevertAllAlert'; +export { default as RevertFormActionGroup } from './RevertFormActionGroup'; +export { + BooleanField, + ChoiceField, + EncryptedField, + InputField, + ObjectField, +} from './SharedFields'; diff --git a/awx/ui_next/src/screens/Setting/shared/settingTestUtils.js b/awx/ui_next/src/screens/Setting/shared/settingTestUtils.js index 31fe98a754..ab81162984 100644 --- a/awx/ui_next/src/screens/Setting/shared/settingTestUtils.js +++ b/awx/ui_next/src/screens/Setting/shared/settingTestUtils.js @@ -5,11 +5,9 @@ export function assertDetail(wrapper, label, value) { export function assertVariableDetail(wrapper, label, value) { expect( - wrapper.find(`VariablesDetail[label="${label}"] .pf-c-form__label`).text() + wrapper.find(`CodeDetail[label="${label}"] .pf-c-form__label`).text() ).toBe(label); expect( - wrapper - .find(`VariablesDetail[label="${label}"] CodeMirrorInput`) - .prop('value') + wrapper.find(`CodeDetail[label="${label}"] CodeMirrorInput`).prop('value') ).toBe(value); } diff --git a/awx/ui_next/src/screens/Setting/shared/settingUtils.js b/awx/ui_next/src/screens/Setting/shared/settingUtils.js index 78d27142aa..6f9867ca82 100644 --- a/awx/ui_next/src/screens/Setting/shared/settingUtils.js +++ b/awx/ui_next/src/screens/Setting/shared/settingUtils.js @@ -1,15 +1,18 @@ export function sortNestedDetails(obj = {}) { - const nestedTypes = ['nested object', 'list']; + const nestedTypes = ['nested object', 'list', 'boolean']; const notNested = Object.entries(obj).filter( ([, value]) => !nestedTypes.includes(value.type) ); + const booleanList = Object.entries(obj).filter( + ([, value]) => value.type === 'boolean' + ); const nestedList = Object.entries(obj).filter( ([, value]) => value.type === 'list' ); const nestedObject = Object.entries(obj).filter( ([, value]) => value.type === 'nested object' ); - return [...notNested, ...nestedList, ...nestedObject]; + return [...notNested, ...booleanList, ...nestedList, ...nestedObject]; } export function pluck(sourceObject, ...keys) { diff --git a/awx/ui_next/src/util/useModal.js b/awx/ui_next/src/util/useModal.js new file mode 100644 index 0000000000..acc853c703 --- /dev/null +++ b/awx/ui_next/src/util/useModal.js @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +/** + * useModal hook provides a way to read and update modal visibility + * Returns: { + * isModalOpen: boolean that indicates if modal is open + * toggleModal: function that toggles the modal open and close + * closeModal: function that closes modal + * } + */ + +export default function useModal() { + const [isModalOpen, setIsModalOpen] = useState(false); + + function toggleModal() { + setIsModalOpen(!isModalOpen); + } + + function closeModal() { + setIsModalOpen(false); + } + + return { + isModalOpen, + toggleModal, + closeModal, + }; +} diff --git a/awx/ui_next/src/util/useModal.test.jsx b/awx/ui_next/src/util/useModal.test.jsx new file mode 100644 index 0000000000..6d1c11faf6 --- /dev/null +++ b/awx/ui_next/src/util/useModal.test.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import useModal from './useModal'; + +const TestHook = ({ callback }) => { + callback(); + return null; +}; + +const testHook = callback => { + mount(); +}; + +describe('useModal hook', () => { + let closeModal; + let isModalOpen; + let toggleModal; + + test('should return expected initial values', () => { + testHook(() => { + ({ isModalOpen, toggleModal, closeModal } = useModal()); + }); + expect(isModalOpen).toEqual(false); + expect(toggleModal).toBeInstanceOf(Function); + expect(closeModal).toBeInstanceOf(Function); + }); + + test('should return expected isModalOpen value after modal toggle', () => { + testHook(() => { + ({ isModalOpen, toggleModal, closeModal } = useModal()); + }); + expect(isModalOpen).toEqual(false); + act(() => { + toggleModal(); + }); + expect(isModalOpen).toEqual(true); + }); + + test('isModalOpen should be false after closeModal is called', () => { + testHook(() => { + ({ isModalOpen, toggleModal, closeModal } = useModal()); + }); + expect(isModalOpen).toEqual(false); + act(() => { + toggleModal(); + }); + expect(isModalOpen).toEqual(true); + act(() => { + closeModal(); + }); + expect(isModalOpen).toEqual(false); + }); +});