mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 18:40:01 -03:30
Add UI settings form
This commit is contained in:
parent
9cf3066591
commit
bbde149ab1
@ -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('<UI />', () => {
|
||||
@ -26,9 +32,14 @@ describe('<UI />', () => {
|
||||
initialEntries: ['/settings/ui/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UI />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UI />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('UIDetail').length).toBe(1);
|
||||
@ -39,9 +50,14 @@ describe('<UI />', () => {
|
||||
initialEntries: ['/settings/ui/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UI />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UI />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('UIEdit').length).toBe(1);
|
||||
|
||||
@ -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 (
|
||||
<CardBody>
|
||||
{i18n._(t`Edit form coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
component={Link}
|
||||
to="/settings/ui/details"
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && uiData && (
|
||||
<Formik initialValues={initialValues(uiData)} onSubmit={handleSubmit}>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<ChoiceField
|
||||
name="PENDO_TRACKING_STATE"
|
||||
config={uiData.PENDO_TRACKING_STATE}
|
||||
isRequired
|
||||
/>
|
||||
<TextAreaField
|
||||
name="CUSTOM_LOGIN_INFO"
|
||||
config={uiData.CUSTOM_LOGIN_INFO}
|
||||
/>
|
||||
<FileUploadField
|
||||
name="CUSTOM_LOGO"
|
||||
config={uiData.CUSTOM_LOGO}
|
||||
type="dataURL"
|
||||
/>
|
||||
{submitError && <FormSubmitError error={submitError} />}
|
||||
</FormColumnLayout>
|
||||
<RevertFormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
onRevert={toggleModal}
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<RevertAllAlert
|
||||
onClose={closeModal}
|
||||
onRevertAll={handleRevertAll}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(UIEdit);
|
||||
export default UIEdit;
|
||||
|
||||
@ -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('<UIEdit />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<UIEdit />);
|
||||
});
|
||||
let history;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ui/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UIEdit />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
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(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UIEdit />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default || ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<TextArea
|
||||
id={name}
|
||||
isRequired={isRequired}
|
||||
placeholder={config.placeholder}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
resizeOrientation="vertical"
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : 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()(
|
||||
<FileUpload
|
||||
{...field}
|
||||
id={name}
|
||||
type="text"
|
||||
type={type}
|
||||
filename={filename}
|
||||
onChange={(value, title) => {
|
||||
helpers.setValue(value);
|
||||
@ -298,12 +341,33 @@ const FileUploadField = withI18n()(
|
||||
isLoading={fileIsUploading}
|
||||
allowEditingUploadedText
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
/>
|
||||
hideDefaultPreview={type === 'dataURL'}
|
||||
>
|
||||
{type === 'dataURL' && (
|
||||
<FileUploadIconWrapper>
|
||||
{field.value ? (
|
||||
<img
|
||||
src={field.value}
|
||||
alt={filename}
|
||||
height="200px"
|
||||
width="200px"
|
||||
/>
|
||||
) : (
|
||||
<FileUploadIcon size="lg" />
|
||||
)}
|
||||
</FileUploadIconWrapper>
|
||||
)}
|
||||
</FileUpload>
|
||||
</SettingGroup>
|
||||
</FormFullWidthLayout>
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
FileUploadField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
export {
|
||||
BooleanField,
|
||||
@ -312,4 +376,5 @@ export {
|
||||
FileUploadField,
|
||||
InputField,
|
||||
ObjectField,
|
||||
TextAreaField,
|
||||
};
|
||||
|
||||
@ -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(
|
||||
<Formik
|
||||
initialValues={{
|
||||
mock_textarea: '',
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<TextAreaField
|
||||
name="mock_textarea"
|
||||
config={{
|
||||
label: 'mock textarea',
|
||||
help_text: 'help text',
|
||||
default: '',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
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(
|
||||
<Formik
|
||||
|
||||
@ -2745,6 +2745,29 @@
|
||||
"category_slug": "system",
|
||||
"default": true
|
||||
},
|
||||
"PENDO_TRACKING_STATE": {
|
||||
"default": "off",
|
||||
"type": "choice",
|
||||
"required": true,
|
||||
"label": "User Analytics Tracking State",
|
||||
"help_text": "Enable or Disable User Analytics Tracking.",
|
||||
"category": "UI",
|
||||
"category_slug": "ui",
|
||||
"choices": [
|
||||
[
|
||||
"off",
|
||||
"Off"
|
||||
],
|
||||
[
|
||||
"anonymous",
|
||||
"Anonymous"
|
||||
],
|
||||
[
|
||||
"detailed",
|
||||
"Detailed"
|
||||
]
|
||||
]
|
||||
},
|
||||
"MANAGE_ORGANIZATION_AUTH": {
|
||||
"type": "boolean",
|
||||
"required": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user