mirror of
https://github.com/ansible/awx.git
synced 2026-05-20 07:17:40 -02:30
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:
@@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils';
|
|||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
import SAML from './SAML';
|
import SAML from './SAML';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
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 />', () => {
|
describe('<SAML />', () => {
|
||||||
@@ -23,9 +43,14 @@ describe('<SAML />', () => {
|
|||||||
initialEntries: ['/settings/saml/details'],
|
initialEntries: ['/settings/saml/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<SAML />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<SAML />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('SAMLDetail').length).toBe(1);
|
expect(wrapper.find('SAMLDetail').length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -35,9 +60,14 @@ describe('<SAML />', () => {
|
|||||||
initialEntries: ['/settings/saml/edit'],
|
initialEntries: ['/settings/saml/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<SAML />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<SAML />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SettingDetail } from '../../shared';
|
|||||||
function SAMLDetail({ i18n }) {
|
function SAMLDetail({ i18n }) {
|
||||||
const { me } = useConfig();
|
const { me } = useConfig();
|
||||||
const { GET: options } = useSettings();
|
const { GET: options } = useSettings();
|
||||||
|
options.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT.type = 'certificate';
|
||||||
|
|
||||||
const { isLoading, error, request, result: saml } = useRequest(
|
const { isLoading, error, request, result: saml } = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({
|
|||||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
||||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||||
|
SAML_AUTO_CREATE_OBJECTS: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ describe('<SAMLDetail />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render expected details', () => {
|
test('should render expected details', () => {
|
||||||
|
assertDetail(
|
||||||
|
wrapper,
|
||||||
|
'Automatically Create Organizations and Teams on SAML Login',
|
||||||
|
'Off'
|
||||||
|
);
|
||||||
assertDetail(
|
assertDetail(
|
||||||
wrapper,
|
wrapper,
|
||||||
'SAML Assertion Consumer Service (ACS) URL',
|
'SAML Assertion Consumer Service (ACS) URL',
|
||||||
@@ -70,7 +76,7 @@ describe('<SAMLDetail />', () => {
|
|||||||
'https://towerhost/sso/metadata/saml/'
|
'https://towerhost/sso/metadata/saml/'
|
||||||
);
|
);
|
||||||
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
|
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
|
||||||
assertDetail(
|
assertVariableDetail(
|
||||||
wrapper,
|
wrapper,
|
||||||
'SAML Service Provider Public Certificate',
|
'SAML Service Provider Public Certificate',
|
||||||
'mock_cert'
|
'mock_cert'
|
||||||
|
|||||||
@@ -1,25 +1,208 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } 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 (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && saml && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik initialValues={initialValues(saml)} onSubmit={handleSubmit}>
|
||||||
component={Link}
|
{formik => (
|
||||||
to="/settings/saml/details"
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
>
|
<FormColumnLayout>
|
||||||
{i18n._(t`Cancel`)}
|
<InputField
|
||||||
</Button>
|
name="SOCIAL_AUTH_SAML_SP_ENTITY_ID"
|
||||||
</CardActionsRow>
|
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>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(SAMLEdit);
|
export default SAMLEdit;
|
||||||
|
|||||||
@@ -1,16 +1,251 @@
|
|||||||
import React from 'react';
|
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';
|
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 />', () => {
|
describe('<SAMLEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<SAMLEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
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', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import {
|
|||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
import UI from './UI';
|
import UI from './UI';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
data: {},
|
data: {
|
||||||
|
CUSTOM_LOGIN_INFO: '',
|
||||||
|
CUSTOM_LOGO: '',
|
||||||
|
PENDO_TRACKING_STATE: 'off',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<UI />', () => {
|
describe('<UI />', () => {
|
||||||
@@ -26,9 +32,14 @@ describe('<UI />', () => {
|
|||||||
initialEntries: ['/settings/ui/details'],
|
initialEntries: ['/settings/ui/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<UI />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<UI />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('UIDetail').length).toBe(1);
|
expect(wrapper.find('UIDetail').length).toBe(1);
|
||||||
@@ -39,9 +50,14 @@ describe('<UI />', () => {
|
|||||||
initialEntries: ['/settings/ui/edit'],
|
initialEntries: ['/settings/ui/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<UI />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<UI />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('UIEdit').length).toBe(1);
|
expect(wrapper.find('UIEdit').length).toBe(1);
|
||||||
|
|||||||
@@ -1,25 +1,128 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } 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 (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && uiData && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik
|
||||||
component={Link}
|
initialValues={{
|
||||||
to="/settings/ui/details"
|
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`)}
|
{formik => (
|
||||||
</Button>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
</CardActionsRow>
|
<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>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(UIEdit);
|
export default UIEdit;
|
||||||
|
|||||||
@@ -1,16 +1,151 @@
|
|||||||
import React from 'react';
|
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';
|
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 />', () => {
|
describe('<UIEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<UIEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
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', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('UIEdit').length).toBe(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 [field, meta, helpers] = useField(id);
|
||||||
const initialValue = meta.initialValue ?? '';
|
const initialValue = meta.initialValue ?? '';
|
||||||
const currentValue = field.value;
|
const currentValue = field.value;
|
||||||
@@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
helpers.setValue(isRevertable ? defaultValue : initialValue);
|
helpers.setValue(isRevertable ? defaultValue : initialValue);
|
||||||
|
onRevertCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
const revertTooltipContent = isRevertable
|
const revertTooltipContent = isRevertable
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export default withI18n()(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'certificate':
|
||||||
|
detail = (
|
||||||
|
<CodeDetail
|
||||||
|
dataCy={id}
|
||||||
|
helpText={helpText}
|
||||||
|
label={label}
|
||||||
|
mode="javascript"
|
||||||
|
rows={4}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
detail = (
|
detail = (
|
||||||
<Detail
|
<Detail
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { bool, oneOf, shape, string } from 'prop-types';
|
import { bool, oneOf, shape, string } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
import {
|
import {
|
||||||
|
FileUpload,
|
||||||
FormGroup as PFFormGroup,
|
FormGroup as PFFormGroup,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
TextInput,
|
|
||||||
Switch,
|
Switch,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
import FileUploadIcon from '@patternfly/react-icons/dist/js/icons/file-upload-icon';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import AnsibleSelect from '../../../components/AnsibleSelect';
|
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||||
import CodeMirrorInput from '../../../components/CodeMirrorInput';
|
import CodeMirrorInput from '../../../components/CodeMirrorInput';
|
||||||
@@ -42,6 +45,7 @@ const SettingGroup = withI18n()(
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
isRequired,
|
isRequired,
|
||||||
label,
|
label,
|
||||||
|
onRevertCallback,
|
||||||
popoverContent,
|
popoverContent,
|
||||||
validated,
|
validated,
|
||||||
}) => (
|
}) => (
|
||||||
@@ -62,6 +66,7 @@ const SettingGroup = withI18n()(
|
|||||||
id={fieldId}
|
id={fieldId}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
onRevertCallback={onRevertCallback}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -220,6 +225,44 @@ InputField.propTypes = {
|
|||||||
isRequired: bool,
|
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 ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
|
||||||
const validate = isRequired ? required(null, i18n) : null;
|
const validate = isRequired ? required(null, i18n) : null;
|
||||||
const [field, meta, helpers] = useField({ name, validate });
|
const [field, meta, helpers] = useField({ name, validate });
|
||||||
@@ -261,4 +304,77 @@ ObjectField.propTypes = {
|
|||||||
isRequired: bool,
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
BooleanField,
|
BooleanField,
|
||||||
ChoiceField,
|
ChoiceField,
|
||||||
EncryptedField,
|
EncryptedField,
|
||||||
|
FileUploadField,
|
||||||
InputField,
|
InputField,
|
||||||
ObjectField,
|
ObjectField,
|
||||||
|
TextAreaField,
|
||||||
} from './SharedFields';
|
} from './SharedFields';
|
||||||
|
|
||||||
describe('Setting form fields', () => {
|
describe('Setting form fields', () => {
|
||||||
@@ -131,6 +133,38 @@ describe('Setting form fields', () => {
|
|||||||
expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo');
|
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 () => {
|
test('ObjectField renders the expected content', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<Formik
|
<Formik
|
||||||
@@ -161,4 +195,46 @@ describe('Setting form fields', () => {
|
|||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]');
|
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('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2745,6 +2745,29 @@
|
|||||||
"category_slug": "system",
|
"category_slug": "system",
|
||||||
"default": true
|
"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": {
|
"MANAGE_ORGANIZATION_AUTH": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user