Merge pull request #8783 from marshmalien/setting-saml-ui-edit-forms

Add SAML and UI setting edit forms

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-01-27 01:30:37 +00:00
committed by GitHub
13 changed files with 1005 additions and 62 deletions

View File

@@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import { SettingsProvider } from '../../../contexts/Settings';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import SAML from './SAML';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/',
SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/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: {},
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SAML_AUTO_CREATE_OBJECTS: false,
},
});
describe('<SAML />', () => {
@@ -23,9 +43,14 @@ describe('<SAML />', () => {
initialEntries: ['/settings/saml/details'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAML />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('SAMLDetail').length).toBe(1);
});
@@ -35,9 +60,14 @@ describe('<SAML />', () => {
initialEntries: ['/settings/saml/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAML />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('SAMLEdit').length).toBe(1);
});

View File

@@ -18,6 +18,7 @@ import { SettingDetail } from '../../shared';
function SAMLDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
options.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT.type = 'certificate';
const { isLoading, error, request, result: saml } = useRequest(
useCallback(async () => {

View File

@@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SAML_AUTO_CREATE_OBJECTS: false,
},
});
@@ -59,6 +60,11 @@ describe('<SAMLDetail />', () => {
});
test('should render expected details', () => {
assertDetail(
wrapper,
'Automatically Create Organizations and Teams on SAML Login',
'Off'
);
assertDetail(
wrapper,
'SAML Assertion Consumer Service (ACS) URL',
@@ -70,7 +76,7 @@ describe('<SAMLDetail />', () => {
'https://towerhost/sso/metadata/saml/'
);
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
assertDetail(
assertVariableDetail(
wrapper,
'SAML Service Provider Public Certificate',
'mock_cert'

View File

@@ -1,25 +1,208 @@
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 {
BooleanField,
FileUploadField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function SAMLEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchSAML, result: saml } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('saml');
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(() => {
fetchSAML();
}, [fetchSAML]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/saml/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_SAML_ORG_INFO: formatJson(form.SOCIAL_AUTH_SAML_ORG_INFO),
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: formatJson(
form.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT
),
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: formatJson(
form.SOCIAL_AUTH_SAML_SUPPORT_CONTACT
),
SOCIAL_AUTH_SAML_ENABLED_IDPS: formatJson(
form.SOCIAL_AUTH_SAML_ENABLED_IDPS
),
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: formatJson(
form.SOCIAL_AUTH_SAML_ORGANIZATION_MAP
),
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: formatJson(
form.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR
),
SOCIAL_AUTH_SAML_TEAM_MAP: formatJson(form.SOCIAL_AUTH_SAML_TEAM_MAP),
SOCIAL_AUTH_SAML_TEAM_ATTR: formatJson(form.SOCIAL_AUTH_SAML_TEAM_ATTR),
SOCIAL_AUTH_SAML_SECURITY_CONFIG: formatJson(
form.SOCIAL_AUTH_SAML_SECURITY_CONFIG
),
SOCIAL_AUTH_SAML_SP_EXTRA: formatJson(form.SOCIAL_AUTH_SAML_SP_EXTRA),
SOCIAL_AUTH_SAML_EXTRA_DATA: formatJson(form.SOCIAL_AUTH_SAML_EXTRA_DATA),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(saml).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/saml/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;
}, {});
function SAMLEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/saml/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && saml && (
<Formik initialValues={initialValues(saml)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="SOCIAL_AUTH_SAML_SP_ENTITY_ID"
config={saml.SOCIAL_AUTH_SAML_SP_ENTITY_ID}
isRequired
/>
<BooleanField
name="SAML_AUTO_CREATE_OBJECTS"
config={saml.SAML_AUTO_CREATE_OBJECTS}
/>
<FileUploadField
name="SOCIAL_AUTH_SAML_SP_PUBLIC_CERT"
config={saml.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT}
isRequired
/>
<FileUploadField
name="SOCIAL_AUTH_SAML_SP_PRIVATE_KEY"
config={saml.SOCIAL_AUTH_SAML_SP_PRIVATE_KEY}
isRequired
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ORG_INFO"
config={saml.SOCIAL_AUTH_SAML_ORG_INFO}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"
config={saml.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_SUPPORT_CONTACT"
config={saml.SOCIAL_AUTH_SAML_SUPPORT_CONTACT}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ENABLED_IDPS"
config={saml.SOCIAL_AUTH_SAML_ENABLED_IDPS}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ORGANIZATION_MAP"
config={saml.SOCIAL_AUTH_SAML_ORGANIZATION_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ORGANIZATION_ATTR"
config={saml.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_TEAM_MAP"
config={saml.SOCIAL_AUTH_SAML_TEAM_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_TEAM_ATTR"
config={saml.SOCIAL_AUTH_SAML_TEAM_ATTR}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_SECURITY_CONFIG"
config={saml.SOCIAL_AUTH_SAML_SECURITY_CONFIG}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_SP_EXTRA"
config={saml.SOCIAL_AUTH_SAML_SP_EXTRA}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_EXTRA_DATA"
config={saml.SOCIAL_AUTH_SAML_EXTRA_DATA}
/>
{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()(SAMLEdit);
export default SAMLEdit;

View File

@@ -1,16 +1,251 @@
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 SAMLEdit from './SAMLEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
SAML_AUTO_CREATE_OBJECTS: true,
SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/',
SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/',
SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$',
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {
givenName: 'Mock User',
emailAddress: 'mockuser@example.com',
},
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
requestedAuthnContext: false,
},
},
});
describe('<SAMLEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<SAMLEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/saml/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAMLEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('SAMLEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(
wrapper.find('FormGroup[label="SAML Service Provider Entity ID"]').length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="Automatically Create Organizations and Teams on SAML Login"]'
).length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="SAML Service Provider Public Certificate"]'
).length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Private Key"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Organization Info"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Technical Contact"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Support Contact"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Enabled Identity Providers"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Organization Map"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="SAML Team Map"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Organization Attribute Mapping"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Team Attribute Mapping"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="SAML Security Config"]').length).toBe(
1
);
expect(
wrapper.find(
'FormGroup[label="SAML Service Provider extra configuration data"]'
).length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="SAML IDP to extra_data attribute mapping"]'
).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({
SAML_AUTO_CREATE_OBJECTS: true,
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: null,
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: null,
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_SP_ENTITY_ID: '',
SOCIAL_AUTH_SAML_SP_EXTRA: null,
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '',
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_MAP: null,
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
requestedAuthnContext: false,
},
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('input#SOCIAL_AUTH_SAML_SP_ENTITY_ID').simulate('change', {
target: { value: 'new_id', name: 'SOCIAL_AUTH_SAML_SP_ENTITY_ID' },
});
wrapper
.find(
'FormGroup[fieldId="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"] 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({
SAML_AUTO_CREATE_OBJECTS: true,
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'new_id',
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
requestedAuthnContext: false,
},
});
});
test('should navigate to saml detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/saml/details');
});
test('should navigate to saml detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/saml/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}>
<SAMLEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@@ -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);

View File

@@ -1,25 +1,128 @@
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');
};
function UIEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/ui/details"
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && uiData && (
<Formik
initialValues={{
PENDO_TRACKING_STATE: uiData?.PENDO_TRACKING_STATE?.value ?? 'off',
CUSTOM_LOGIN_INFO: uiData?.CUSTOM_LOGIN_INFO?.value ?? '',
CUSTOM_LOGO: uiData?.CUSTOM_LOGO?.value ?? '',
}}
onSubmit={handleSubmit}
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
{uiData?.PENDO_TRACKING_STATE?.value !== 'off' && (
<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;

View File

@@ -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);
});
});

View File

@@ -13,7 +13,13 @@ const ButtonWrapper = styled.div`
}
`;
function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
function RevertButton({
i18n,
id,
defaultValue,
isDisabled = false,
onRevertCallback = () => null,
}) {
const [field, meta, helpers] = useField(id);
const initialValue = meta.initialValue ?? '';
const currentValue = field.value;
@@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
function handleConfirm() {
helpers.setValue(isRevertable ? defaultValue : initialValue);
onRevertCallback();
}
const revertTooltipContent = isRevertable

View File

@@ -34,6 +34,18 @@ export default withI18n()(
/>
);
break;
case 'certificate':
detail = (
<CodeDetail
dataCy={id}
helpText={helpText}
label={label}
mode="javascript"
rows={4}
value={value}
/>
);
break;
case 'image':
detail = (
<Detail

View File

@@ -1,14 +1,17 @@
import React from 'react';
import React, { useState } 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 {
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';
@@ -42,6 +45,7 @@ const SettingGroup = withI18n()(
isDisabled,
isRequired,
label,
onRevertCallback,
popoverContent,
validated,
}) => (
@@ -62,6 +66,7 @@ const SettingGroup = withI18n()(
id={fieldId}
defaultValue={defaultValue}
isDisabled={isDisabled}
onRevertCallback={onRevertCallback}
/>
</>
}
@@ -220,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 });
@@ -261,4 +304,77 @@ ObjectField.propTypes = {
isRequired: bool,
};
export { BooleanField, ChoiceField, EncryptedField, InputField, ObjectField };
const FileUploadIconWrapper = styled.div`
margin: var(--pf-global--spacer--md);
`;
const FileUploadField = withI18n()(
({ i18n, name, config, type = 'text', isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [filename, setFilename] = useState('');
const [fileIsUploading, setFileIsUploading] = useState(false);
const [field, meta, helpers] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
return config ? (
<FormFullWidthLayout>
<SettingGroup
defaultValue={config.default ?? ''}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
onRevertCallback={() => setFilename('')}
>
<FileUpload
{...field}
id={name}
type={type}
filename={filename}
onChange={(value, title) => {
helpers.setValue(value);
setFilename(title);
}}
onReadStarted={() => setFileIsUploading(true)}
onReadFinished={() => setFileIsUploading(false)}
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,
ChoiceField,
EncryptedField,
FileUploadField,
InputField,
ObjectField,
TextAreaField,
};

View File

@@ -8,8 +8,10 @@ import {
BooleanField,
ChoiceField,
EncryptedField,
FileUploadField,
InputField,
ObjectField,
TextAreaField,
} from './SharedFields';
describe('Setting form fields', () => {
@@ -131,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
@@ -161,4 +195,46 @@ describe('Setting form fields', () => {
wrapper.update();
expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]');
});
test('FileUploadField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
mock_file: 'mock file value',
}}
>
{() => (
<FileUploadField
name="mock_file"
config={{
label: 'mock file label',
help_text: 'mock file help',
default: '',
}}
/>
)}
</Formik>
);
expect(wrapper.find('FileUploadField')).toHaveLength(1);
expect(wrapper.find('label').text()).toEqual('mock file label');
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual('');
await act(async () => {
wrapper.find('FileUpload').invoke('onChange')(
{
text: () =>
'-----BEGIN PRIVATE KEY-----\\nAAAAAAAAAAAAAA\\n-----END PRIVATE KEY-----\\n',
},
'new file name'
);
});
wrapper.update();
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(
'new file name'
);
await act(async () => {
wrapper.find('button[aria-label="Revert"]').invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual('');
});
});

View File

@@ -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,