From bbde149ab1d65d09019ac5c83defbbcbb90506b1 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 4 Dec 2020 15:19:04 -0500 Subject: [PATCH] Add UI settings form --- .../src/screens/Setting/UI/UI.test.jsx | 30 +++- .../src/screens/Setting/UI/UIEdit/UIEdit.jsx | 136 ++++++++++++++--- .../screens/Setting/UI/UIEdit/UIEdit.test.jsx | 143 +++++++++++++++++- .../screens/Setting/shared/SharedFields.jsx | 73 ++++++++- .../Setting/shared/SharedFields.test.jsx | 33 ++++ .../shared/data.allSettingOptions.json | 23 +++ 6 files changed, 405 insertions(+), 33 deletions(-) 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 => ( +
+ + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -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 ? ( + +