diff --git a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx
index ac7a31d608..fc5aafadcd 100644
--- a/awx/ui_next/src/screens/Setting/UI/UI.test.jsx
+++ b/awx/ui_next/src/screens/Setting/UI/UI.test.jsx
@@ -6,11 +6,17 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
+import { SettingsProvider } from '../../../contexts/Settings';
+import mockAllOptions from '../shared/data.allSettingOptions.json';
import UI from './UI';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
- data: {},
+ data: {
+ CUSTOM_LOGIN_INFO: '',
+ CUSTOM_LOGO: '',
+ PENDO_TRACKING_STATE: 'off',
+ },
});
describe('', () => {
@@ -26,9 +32,14 @@ describe('', () => {
initialEntries: ['/settings/ui/details'],
});
await act(async () => {
- wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('UIDetail').length).toBe(1);
@@ -39,9 +50,14 @@ describe('', () => {
initialEntries: ['/settings/ui/edit'],
});
await act(async () => {
- wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('UIEdit').length).toBe(1);
diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx
index c8d0f4df78..348abf4294 100644
--- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx
+++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.jsx
@@ -1,25 +1,125 @@
-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 {
+ ChoiceField,
+ FileUploadField,
+ TextAreaField,
+} from '../../shared/SharedFields';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function UIEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchUI, result: uiData } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('ui');
+ 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(() => {
+ fetchUI();
+ }, [fetchUI]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/ui/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm(form);
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(uiData).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/ui/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ acc[key] = fields[key].value ?? '';
+ return acc;
+ }, {});
-function UIEdit({ i18n }) {
return (
- {i18n._(t`Edit form coming soon :)`)}
-
-
-
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && uiData && (
+
+ {formik => (
+
+ )}
+
+ )}
);
}
-export default withI18n()(UIEdit);
+export default UIEdit;
diff --git a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx
index c51fb06fa7..adb43a788c 100644
--- a/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx
+++ b/awx/ui_next/src/screens/Setting/UI/UIEdit/UIEdit.test.jsx
@@ -1,16 +1,151 @@
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 UIEdit from './UIEdit';
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ CUSTOM_LOGIN_INFO: 'mock info',
+ CUSTOM_LOGO: 'data:mock/jpeg;',
+ PENDO_TRACKING_STATE: 'detailed',
+ },
+});
+
describe('', () => {
let wrapper;
- beforeEach(() => {
- wrapper = mountWithContexts();
- });
+ let history;
+
afterEach(() => {
wrapper.unmount();
+ jest.clearAllMocks();
});
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/ui/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('UIEdit').length).toBe(1);
});
+
+ test('should display expected form fields', async () => {
+ expect(wrapper.find('FormGroup[label="Custom Login Info"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="Custom Logo"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="User Analytics Tracking State"]').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({
+ CUSTOM_LOGIN_INFO: '',
+ CUSTOM_LOGO: '',
+ PENDO_TRACKING_STATE: 'off',
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper.find('textarea#CUSTOM_LOGIN_INFO').simulate('change', {
+ target: { value: 'new login info', name: 'CUSTOM_LOGIN_INFO' },
+ });
+ wrapper
+ .find('FormGroup[fieldId="CUSTOM_LOGO"] button[aria-label="Revert"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ CUSTOM_LOGIN_INFO: 'new login info',
+ CUSTOM_LOGO: '',
+ PENDO_TRACKING_STATE: 'detailed',
+ });
+ });
+
+ test('should navigate to ui detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual('/settings/ui/details');
+ });
+
+ test('should navigate to ui detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/ui/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/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
index f668289976..7338efb8a2 100644
--- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
@@ -7,9 +7,11 @@ import {
FileUpload,
FormGroup as PFFormGroup,
InputGroup,
- TextInput,
Switch,
+ TextArea,
+ TextInput,
} from '@patternfly/react-core';
+import FileUploadIcon from '@patternfly/react-icons/dist/js/icons/file-upload-icon';
import styled from 'styled-components';
import AnsibleSelect from '../../../components/AnsibleSelect';
import CodeMirrorInput from '../../../components/CodeMirrorInput';
@@ -223,6 +225,44 @@ InputField.propTypes = {
isRequired: bool,
};
+const TextAreaField = withI18n()(
+ ({ i18n, name, config, isRequired = false }) => {
+ const validate = isRequired ? required(null, i18n) : null;
+ const [field, meta] = useField({ name, validate });
+ const isValid = !(meta.touched && meta.error);
+
+ return config ? (
+
+
+ ) : null;
+ }
+);
+TextAreaField.propTypes = {
+ name: string.isRequired,
+ config: shape({}).isRequired,
+ isRequired: bool,
+};
+
const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [field, meta, helpers] = useField({ name, validate });
@@ -264,8 +304,11 @@ ObjectField.propTypes = {
isRequired: bool,
};
+const FileUploadIconWrapper = styled.div`
+ margin: var(--pf-global--spacer--md);
+`;
const FileUploadField = withI18n()(
- ({ i18n, name, config, isRequired = false }) => {
+ ({ i18n, name, config, type = 'text', isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [filename, setFilename] = useState('');
const [fileIsUploading, setFileIsUploading] = useState(false);
@@ -287,7 +330,7 @@ const FileUploadField = withI18n()(
{
helpers.setValue(value);
@@ -298,12 +341,33 @@ const FileUploadField = withI18n()(
isLoading={fileIsUploading}
allowEditingUploadedText
validated={isValid ? 'default' : 'error'}
- />
+ hideDefaultPreview={type === 'dataURL'}
+ >
+ {type === 'dataURL' && (
+
+ {field.value ? (
+
+ ) : (
+
+ )}
+
+ )}
+
) : null;
}
);
+FileUploadField.propTypes = {
+ name: string.isRequired,
+ config: shape({}).isRequired,
+ isRequired: bool,
+};
export {
BooleanField,
@@ -312,4 +376,5 @@ export {
FileUploadField,
InputField,
ObjectField,
+ TextAreaField,
};
diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx
index 39b49f9428..dd8ac2f4cc 100644
--- a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx
@@ -11,6 +11,7 @@ import {
FileUploadField,
InputField,
ObjectField,
+ TextAreaField,
} from './SharedFields';
describe('Setting form fields', () => {
@@ -132,6 +133,38 @@ describe('Setting form fields', () => {
expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo');
});
+ test('TextAreaField renders the expected content', async () => {
+ const wrapper = mountWithContexts(
+
+ {() => (
+
+ )}
+
+ );
+ expect(wrapper.find('textarea')).toHaveLength(1);
+ expect(wrapper.find('textarea#mock_textarea').prop('value')).toEqual('');
+ await act(async () => {
+ wrapper.find('textarea#mock_textarea').simulate('change', {
+ target: { value: 'new textarea value', name: 'mock_textarea' },
+ });
+ });
+ wrapper.update();
+ expect(wrapper.find('textarea').prop('value')).toEqual(
+ 'new textarea value'
+ );
+ });
+
test('ObjectField renders the expected content', async () => {
const wrapper = mountWithContexts(