From db4d84b94cf951cf1903a6046912665adc9bcc41 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 23 Nov 2020 15:55:48 -0500 Subject: [PATCH 1/2] Add job settings form and unit tests --- .../src/screens/Setting/Jobs/Jobs.test.jsx | 6 +- .../Setting/Jobs/JobsEdit/JobsEdit.jsx | 255 ++++++++++++++++-- .../Setting/Jobs/JobsEdit/JobsEdit.test.jsx | 119 +++++++- .../JobsEdit/data.defaultJobSettings.json | 48 ++++ 4 files changed, 402 insertions(+), 26 deletions(-) create mode 100644 awx/ui_next/src/screens/Setting/Jobs/JobsEdit/data.defaultJobSettings.json diff --git a/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx index d0529ab483d1..2bc3ca194017 100644 --- a/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx +++ b/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx @@ -2,13 +2,13 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import Jobs from './Jobs'; - +import mockJobSettings from '../shared/data.jobSettings.json'; import { SettingsAPI } from '../../../api'; +import Jobs from './Jobs'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: mockJobSettings, }); describe('', () => { diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx index 7ae08c927662..143c805a0164 100644 --- a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx @@ -1,25 +1,242 @@ -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'; - -function JobsEdit({ i18n }) { +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 { + BooleanField, + InputField, + ObjectField, + RevertAllAlert, + RevertFormActionGroup, +} from '../../shared'; +import useModal from '../../../../util/useModal'; +import useRequest from '../../../../util/useRequest'; +import { formatJson } from '../../shared/settingUtils'; +import { SettingsAPI } from '../../../../api'; + +function JobsEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchJobs, result: jobs } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('jobs'); + const { + ALLOW_JINJA_IN_EXTRA_VARS, + AWX_ISOLATED_KEY_GENERATION, + AWX_ISOLATED_PRIVATE_KEY, + AWX_ISOLATED_PUBLIC_KEY, + EVENT_STDOUT_MAX_BYTES_DISPLAY, + STDOUT_MAX_BYTES_DISPLAY, + ...jobsData + } = data; + const mergedData = {}; + Object.keys(jobsData).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = jobsData[key]; + }); + + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchJobs(); + }, [fetchJobs]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/jobs/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + AD_HOC_COMMANDS: formatJson(form.AD_HOC_COMMANDS), + AWX_PROOT_SHOW_PATHS: formatJson(form.AWX_PROOT_SHOW_PATHS), + AWX_PROOT_HIDE_PATHS: formatJson(form.AWX_PROOT_HIDE_PATHS), + AWX_ANSIBLE_CALLBACK_PLUGINS: formatJson( + form.AWX_ANSIBLE_CALLBACK_PLUGINS + ), + AWX_TASK_ENV: formatJson(form.AWX_TASK_ENV), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = {}; + Object.entries(jobs).forEach(([key, value]) => { + defaultValues[key] = value.default; + }); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/jobs/details'); + }; + + const initialValues = fields => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list' || fields[key].type === 'nested object') { + const emptyDefault = fields[key].type === 'list' ? '[]' : '{}'; + acc[key] = fields[key].value + ? JSON.stringify(fields[key].value, null, 2) + : emptyDefault; + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); + return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && jobs && ( + + {formik => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + ); + }} +
+ )}
); } -export default withI18n()(JobsEdit); +export default JobsEdit; diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx index 06f4fb2f128a..14b2fb11ada3 100644 --- a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx @@ -1,16 +1,127 @@ 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 mockJobSettings from '../../shared/data.jobSettings.json'; +import mockDefaultJobSettings from './data.defaultJobSettings.json'; +import { SettingsProvider } from '../../../../contexts/Settings'; +import { SettingsAPI } from '../../../../api'; import JobsEdit from './JobsEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: mockJobSettings, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/jobs/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('JobsEdit').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(mockDefaultJobSettings); + }); + + 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); + const { + ALLOW_JINJA_IN_EXTRA_VARS, + AWX_ISOLATED_KEY_GENERATION, + AWX_ISOLATED_PRIVATE_KEY, + AWX_ISOLATED_PUBLIC_KEY, + EVENT_STDOUT_MAX_BYTES_DISPLAY, + STDOUT_MAX_BYTES_DISPLAY, + ...jobRequest + } = mockJobSettings; + expect(SettingsAPI.updateAll).toHaveBeenCalledWith(jobRequest); + }); + + 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 navigate to job settings detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/jobs/details'); + }); + + 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/Jobs/JobsEdit/data.defaultJobSettings.json b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/data.defaultJobSettings.json new file mode 100644 index 000000000000..70c73869d796 --- /dev/null +++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/data.defaultJobSettings.json @@ -0,0 +1,48 @@ +{ + "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" + ], + "ANSIBLE_FACT_CACHE_TIMEOUT": 0, + "AWX_ANSIBLE_CALLBACK_PLUGINS": [], + "AWX_COLLECTIONS_ENABLED": true, + "AWX_ISOLATED_CHECK_INTERVAL": 1, + "AWX_ISOLATED_CONNECTION_TIMEOUT": 10, + "AWX_ISOLATED_HOST_KEY_CHECKING": false, + "AWX_ISOLATED_LAUNCH_TIMEOUT": 600, + "AWX_PROOT_BASE_PATH": "/tmp", + "AWX_PROOT_ENABLED": true, + "AWX_PROOT_HIDE_PATHS": [], + "AWX_PROOT_SHOW_PATHS": [], + "AWX_RESOURCE_PROFILING_CPU_POLL_INTERVAL": 0.25, + "AWX_RESOURCE_PROFILING_ENABLED": false, + "AWX_RESOURCE_PROFILING_MEMORY_POLL_INTERVAL": 0.25, + "AWX_RESOURCE_PROFILING_PID_POLL_INTERVAL": 0.25, + "AWX_ROLES_ENABLED": true, + "AWX_SHOW_PLAYBOOK_LINKS": false, + "AWX_TASK_ENV": {}, + "DEFAULT_INVENTORY_UPDATE_TIMEOUT": 0, + "DEFAULT_JOB_TIMEOUT": 0, + "DEFAULT_PROJECT_UPDATE_TIMEOUT": 0, + "GALAXY_IGNORE_CERTS": false, + "MAX_FORKS": 200, + "PROJECT_UPDATE_VVV": false, + "SCHEDULE_MAX_JOBS": 10 +} \ No newline at end of file From 8a58a73cb0498ab95cb3878b1ad8a742bceaa401 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 24 Nov 2020 15:21:53 -0500 Subject: [PATCH 2/2] Handle reverting falsy values that are not null or undefined --- .../screens/Setting/shared/SharedFields.jsx | 2 +- .../Setting/shared/SharedFields.test.jsx | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx index 7338efb8a24f..59474acd8c03 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -195,7 +195,7 @@ const InputField = withI18n()( return config ? ( { expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo'); }); + test('InputField should revert to expected default value', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('TextInputBase')).toHaveLength(1); + expect(wrapper.find('TextInputBase').prop('value')).toEqual(5); + await act(async () => { + wrapper.find('button[aria-label="Revert"]').invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('TextInputBase').prop('value')).toEqual(0); + }); + test('TextAreaField renders the expected content', async () => { const wrapper = mountWithContexts(