Add setting system category forms and tests

* Add activity stream, logging, and misc system forms
 * Hookup logging test alert
 * Hookup revert buttons
 * Add useModal helper hook
 * Swap VariablesDetail for CodeDetail within setting detail views
 * Update SettingDetail import path in setting detail views
This commit is contained in:
Marliana Lara 2020-10-27 15:16:26 -04:00
parent b338da40c5
commit e0feda780b
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
47 changed files with 2893 additions and 140 deletions

View File

@ -21,6 +21,10 @@ class Settings extends Base {
readCategoryOptions(category) {
return this.http.options(`${this.baseUrl}${category}/`);
}
test(category, data) {
return this.http.post(`${this.baseUrl}${category}/test/`, data);
}
}
export default Settings;

View File

@ -1,6 +1,14 @@
import 'styled-components/macro';
import React from 'react';
import { shape, node, number, oneOf, string } from 'prop-types';
import {
arrayOf,
oneOf,
oneOfType,
node,
number,
shape,
string,
} from 'prop-types';
import { TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from './Detail';
import CodeMirrorInput from '../CodeMirrorInput';
@ -57,12 +65,12 @@ function CodeDetail({
);
}
CodeDetail.propTypes = {
value: shape.isRequired,
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired,
dataCy: string,
helpText: string,
rows: number,
mode: oneOf(['json', 'yaml', 'jinja2']).isRequired,
mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired,
};
CodeDetail.defaultProps = {
rows: null,

View File

@ -5,13 +5,7 @@ import { t } from '@lingui/macro';
import { ActionGroup, Button } from '@patternfly/react-core';
import { FormFullWidthLayout } from '../FormLayout';
const FormActionGroup = ({
onCancel,
onRevert,
onSubmit,
submitDisabled,
i18n,
}) => {
const FormActionGroup = ({ onCancel, onSubmit, submitDisabled, i18n }) => {
return (
<FormFullWidthLayout>
<ActionGroup>
@ -24,16 +18,6 @@ const FormActionGroup = ({
>
{i18n._(t`Save`)}
</Button>
{onRevert && (
<Button
aria-label={i18n._(t`Revert`)}
variant="secondary"
type="button"
onClick={onRevert}
>
{i18n._(t`Revert`)}
</Button>
)}
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
@ -49,13 +33,11 @@ const FormActionGroup = ({
FormActionGroup.propTypes = {
onCancel: PropTypes.func.isRequired,
onRevert: PropTypes.func,
onSubmit: PropTypes.func.isRequired,
submitDisabled: PropTypes.bool,
};
FormActionGroup.defaultProps = {
onRevert: null,
submitDisabled: false,
};

View File

@ -9,9 +9,19 @@ import styled from 'styled-components';
const PopoverButton = styled.button`
padding: var(--pf-global--spacer--xs);
margin: -(var(--pf-global--spacer--xs));
font-size: var(--pf-global--FontSize--sm);
`;
function Popover({ i18n, ariaLabel, content, header, id, maxWidth, ...rest }) {
function Popover({
i18n,
i18nHash,
ariaLabel,
content,
header,
id,
maxWidth,
...rest
}) {
if (!content) {
return null;
}

View File

@ -4,11 +4,14 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import { useConfig } from '../../../contexts/Config';
import ActivityStreamDetail from './ActivityStreamDetail';
import ActivityStreamEdit from './ActivityStreamEdit';
function ActivityStream({ i18n }) {
const baseURL = '/settings/activity_stream';
const { me } = useConfig();
return (
<PageSection>
<Card>
@ -18,7 +21,11 @@ function ActivityStream({ i18n }) {
<ActivityStreamDetail />
</Route>
<Route path={`${baseURL}/edit`}>
<ActivityStreamEdit />
{me?.is_superuser ? (
<ActivityStreamEdit />
) : (
<Redirect to={`${baseURL}/details`} />
)}
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>

View File

@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function ActivityStreamDetail({ i18n }) {
const { me } = useConfig();

View File

@ -1,25 +1,137 @@
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 {
BooleanField,
RevertAllAlert,
RevertFormActionGroup,
} from '../../shared';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function ActivityStreamEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const {
isLoading,
error,
request,
result: {
ACTIVITY_STREAM_ENABLED,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
},
} = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('system');
return {
ACTIVITY_STREAM_ENABLED: {
...options.ACTIVITY_STREAM_ENABLED,
value: data.ACTIVITY_STREAM_ENABLED,
},
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: {
...options.ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
value: data.ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
},
};
}, [options]),
{}
);
useEffect(() => {
request();
}, [request]);
const {
error: submitError,
request: submitForm,
result: submitResult,
} = useRequest(
useCallback(async values => {
const result = await SettingsAPI.updateAll(values);
return result;
}, []),
null
);
useEffect(() => {
if (submitResult) {
history.push('/settings/activity_stream/details');
}
}, [submitResult, history]);
const handleSubmit = async form => {
await submitForm(form);
};
const handleCancel = () => {
history.push('/settings/activity_stream/details');
};
const handleRevertAll = async () => {
const defaultValues = {
ACTIVITY_STREAM_ENABLED: ACTIVITY_STREAM_ENABLED.default,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC:
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC.default,
};
await submitForm(defaultValues);
closeModal();
};
function ActivityStreamEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/activity_stream/details"
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && ACTIVITY_STREAM_ENABLED && (
<Formik
initialValues={{
ACTIVITY_STREAM_ENABLED: ACTIVITY_STREAM_ENABLED.value,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC:
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC.value,
}}
onSubmit={handleSubmit}
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{formik => {
return (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<BooleanField
name="ACTIVITY_STREAM_ENABLED"
config={ACTIVITY_STREAM_ENABLED}
/>
<BooleanField
name="ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC"
config={ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC}
/>
{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()(ActivityStreamEdit);
export default ActivityStreamEdit;

View File

@ -1,16 +1,135 @@
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 ActivityStreamEdit from './ActivityStreamEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
ACTIVITY_STREAM_ENABLED: false,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: true,
},
});
SettingsAPI.updateAll.mockResolvedValue({});
describe('<ActivityStreamEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<ActivityStreamEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/activity_stream/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<ActivityStreamEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('ActivityStreamEdit').length).toBe(1);
});
test('should navigate to activity stream detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual(
'/settings/activity_stream/details'
);
});
test('should navigate to activity stream detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual(
'/settings/activity_stream/details'
);
});
test('should successfully send request to api on form submission', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
ACTIVITY_STREAM_ENABLED: false,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: true,
});
});
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({
ACTIVITY_STREAM_ENABLED: true,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: false,
});
});
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}>
<ActivityStreamEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -2,12 +2,25 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import AzureAD from './AzureAD';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL:
'https://towerhost/sso/complete/azuread-oauth2/',
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key',
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$',
SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {},
SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: {
'My Team': {
users: [],
},
},
},
});
describe('<AzureAD />', () => {
@ -23,9 +36,14 @@ describe('<AzureAD />', () => {
initialEntries: ['/settings/azure/details'],
});
await act(async () => {
wrapper = mountWithContexts(<AzureAD />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<AzureAD />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('AzureADDetail').length).toBe(1);
});

View File

@ -13,7 +13,7 @@ import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function AzureADDetail({ i18n }) {
const { me } = useConfig();

View File

@ -1,25 +1,146 @@
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 {
EncryptedField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function AzureADEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchAzureAD, result: azure } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('azuread-oauth2');
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(() => {
fetchAzureAD();
}, [fetchAzureAD]);
const {
error: submitError,
request: submitForm,
result: submitResult,
} = useRequest(
useCallback(async values => {
const result = await SettingsAPI.updateAll(values);
return result;
}, []),
null
);
useEffect(() => {
if (submitResult) {
history.push('/settings/azure/details');
}
}, [submitResult, history]);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: JSON.parse(
form.SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP
),
SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: JSON.parse(
form.SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP
),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(azure).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/azure/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
acc[key] = JSON.stringify(fields[key].value, null, 2);
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
function AzureADEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/azure/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && azure && (
<Formik initialValues={initialValues(azure)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="SOCIAL_AUTH_AZUREAD_OAUTH2_KEY"
config={azure.SOCIAL_AUTH_AZUREAD_OAUTH2_KEY}
/>
<EncryptedField
name="SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET"
config={azure.SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET}
/>
<ObjectField
name="SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP"
config={azure.SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP"
config={azure.SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP}
/>
{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()(AzureADEdit);
export default AzureADEdit;

View File

@ -1,16 +1,147 @@
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 AzureADEdit from './AzureADEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL:
'https://towerhost/sso/complete/azuread-oauth2/',
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key',
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$',
SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {},
SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: {
'My Team': {
organization: 'foo',
},
},
},
});
describe('<AzureADEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<AzureADEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/azure/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<AzureADEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('AzureADEdit').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({
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: '',
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '',
SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: null,
SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: null,
});
});
test('should successfully send request to api on form submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key',
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$',
SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {},
SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: {
'My Team': {
organization: 'foo',
},
},
});
});
test('should navigate to azure detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/azure/details');
});
test('should navigate to azure detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/azure/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}>
<AzureADEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -13,7 +13,7 @@ import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function GitHubDetail({ i18n }) {
const { me } = useConfig();

View File

@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function GoogleOAuth2Detail({ i18n }) {
const { me } = useConfig();

View File

@ -14,7 +14,7 @@ import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import { sortNestedDetails } from '../../shared/settingUtils';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function JobsDetail({ i18n }) {
const { me } = useConfig();

View File

@ -13,7 +13,7 @@ import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
import { sortNestedDetails } from '../../shared/settingUtils';
function filterByPrefix(data, prefix) {

View File

@ -4,11 +4,14 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import { useConfig } from '../../../contexts/Config';
import LoggingDetail from './LoggingDetail';
import LoggingEdit from './LoggingEdit';
function Logging({ i18n }) {
const baseURL = '/settings/logging';
const { me } = useConfig();
return (
<PageSection>
<Card>
@ -18,11 +21,17 @@ function Logging({ i18n }) {
<LoggingDetail />
</Route>
<Route path={`${baseURL}/edit`}>
<LoggingEdit />
{me?.is_superuser ? (
<LoggingEdit />
) : (
<Redirect to={`${baseURL}/details`} />
)}
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}`}>{i18n._(t`View Logging settings`)}</Link>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Logging settings`)}
</Link>
</ContentError>
</Route>
</Switch>

View File

@ -10,7 +10,29 @@ import Logging from './Logging';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
LOG_AGGREGATOR_HOST: null,
LOG_AGGREGATOR_PORT: null,
LOG_AGGREGATOR_TYPE: null,
LOG_AGGREGATOR_USERNAME: '',
LOG_AGGREGATOR_PASSWORD: '',
LOG_AGGREGATOR_LOGGERS: [
'awx',
'activity_stream',
'job_events',
'system_tracking',
],
LOG_AGGREGATOR_INDIVIDUAL_FACTS: false,
LOG_AGGREGATOR_ENABLED: false,
LOG_AGGREGATOR_TOWER_UUID: '',
LOG_AGGREGATOR_PROTOCOL: 'https',
LOG_AGGREGATOR_TCP_TIMEOUT: 5,
LOG_AGGREGATOR_VERIFY_CERT: true,
LOG_AGGREGATOR_LEVEL: 'INFO',
LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1,
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
},
});
describe('<Logging />', () => {

View File

@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
import { sortNestedDetails, pluck } from '../../shared/settingUtils';
function LoggingDetail({ i18n }) {

View File

@ -1,23 +1,272 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useCallback, useEffect } from 'react';
import { useHistory } 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 { Formik } from 'formik';
import { Button, Form, Tooltip } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import {
BooleanField,
ChoiceField,
EncryptedField,
InputField,
ObjectField,
RevertAllAlert,
RevertFormActionGroup,
LoggingTestAlert,
} from '../../shared';
import useModal from '../../../../util/useModal';
import useRequest, { useDismissableError } from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function LoggingEdit({ i18n }) {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const {
isLoading,
error,
request: fetchLogging,
result: logging,
} = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('logging');
const mergedData = {};
Object.keys(data).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchLogging();
}, [fetchLogging]);
const {
error: submitError,
request: submitForm,
result: submitResult,
} = useRequest(
useCallback(async values => {
const result = await SettingsAPI.updateAll(values);
return result;
}, []),
null
);
useEffect(() => {
if (submitResult) {
history.push('/settings/logging/details');
}
}, [submitResult, history]);
const handleSubmit = async form => {
await submitForm({
...form,
LOG_AGGREGATOR_LOGGERS: JSON.parse(form.LOG_AGGREGATOR_LOGGERS),
LOG_AGGREGATOR_HOST: form.LOG_AGGREGATOR_HOST || null,
LOG_AGGREGATOR_TYPE: form.LOG_AGGREGATOR_TYPE || null,
});
};
const handleRevertAll = async () => {
const defaultValues = {};
Object.entries(logging).forEach(([key, value]) => {
defaultValues[key] = value.default;
});
await submitForm(defaultValues);
closeModal();
};
const {
error: testLoggingError,
request: testLogging,
result: testSuccess,
setValue: setTestLogging,
} = useRequest(
useCallback(async () => {
const result = await SettingsAPI.test('logging', {});
return result;
}, []),
null
);
const {
error: testError,
dismissError: dismissTestError,
} = useDismissableError(testLoggingError);
const handleTest = async () => {
await testLogging();
};
const handleCloseAlert = () => {
setTestLogging(null);
dismissTestError();
};
const handleCancel = () => {
history.push('/settings/logging/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list') {
acc[key] = JSON.stringify(fields[key].value, null, 2);
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/logging/details"
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && logging && (
<Formik
initialValues={{ ...initialValues(logging) }}
onSubmit={handleSubmit}
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{formik => {
return (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<BooleanField
name="LOG_AGGREGATOR_ENABLED"
config={logging.LOG_AGGREGATOR_ENABLED}
ariaLabel={i18n._(t`Enable external logging`)}
disabled={
!formik.values.LOG_AGGREGATOR_ENABLED &&
(!formik.values.LOG_AGGREGATOR_HOST ||
!formik.values.LOG_AGGREGATOR_TYPE)
}
/>
<InputField
name="LOG_AGGREGATOR_HOST"
config={logging.LOG_AGGREGATOR_HOST}
isRequired={Boolean(formik.values.LOG_AGGREGATOR_ENABLED)}
/>
<InputField
name="LOG_AGGREGATOR_PORT"
config={logging.LOG_AGGREGATOR_PORT}
type="number"
/>
<ChoiceField
name="LOG_AGGREGATOR_TYPE"
config={logging.LOG_AGGREGATOR_TYPE}
isRequired={Boolean(formik.values.LOG_AGGREGATOR_ENABLED)}
/>
<InputField
name="LOG_AGGREGATOR_USERNAME"
config={logging.LOG_AGGREGATOR_USERNAME}
/>
<EncryptedField
name="LOG_AGGREGATOR_PASSWORD"
config={logging.LOG_AGGREGATOR_PASSWORD}
/>
<BooleanField
name="LOG_AGGREGATOR_INDIVIDUAL_FACTS"
ariaLabel={i18n._(
t`Enable log system tracking facts individually`
)}
config={logging.LOG_AGGREGATOR_INDIVIDUAL_FACTS}
/>
<ChoiceField
name="LOG_AGGREGATOR_PROTOCOL"
config={logging.LOG_AGGREGATOR_PROTOCOL}
/>
<ChoiceField
name="LOG_AGGREGATOR_LEVEL"
config={logging.LOG_AGGREGATOR_LEVEL}
/>
{['tcp', 'https'].includes(
formik.values.LOG_AGGREGATOR_PROTOCOL
) && (
<InputField
name="LOG_AGGREGATOR_TCP_TIMEOUT"
config={logging.LOG_AGGREGATOR_TCP_TIMEOUT}
type="number"
isRequired
/>
)}
{formik.values.LOG_AGGREGATOR_PROTOCOL === 'https' && (
<BooleanField
name="LOG_AGGREGATOR_VERIFY_CERT"
ariaLabel={i18n._(
t`Enable HTTPS certificate verification`
)}
config={logging.LOG_AGGREGATOR_VERIFY_CERT}
/>
)}
<ObjectField
name="LOG_AGGREGATOR_LOGGERS"
config={logging.LOG_AGGREGATOR_LOGGERS}
/>
{submitError && <FormSubmitError error={submitError} />}
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
>
<Tooltip
content={
formik.dirty || !formik.values.LOG_AGGREGATOR_ENABLED
? i18n._(
t`Save and enable log aggregation before testing the log aggregator.`
)
: i18n._(
t`Send a test log message to the configured log aggregator.`
)
}
>
<div>
<Button
aria-label={i18n._(t`Test logging`)}
variant="secondary"
type="button"
onClick={handleTest}
isDisabled={
formik.dirty ||
!formik.values.LOG_AGGREGATOR_ENABLED ||
testSuccess ||
testError
}
>
{i18n._(t`Test`)}
</Button>
</div>
</Tooltip>
</RevertFormActionGroup>
</FormColumnLayout>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
{(testSuccess || testError) && (
<LoggingTestAlert
successResponse={testSuccess}
errorResponse={testError}
onClose={handleCloseAlert}
/>
)}
</Form>
);
}}
</Formik>
)}
</CardBody>
);
}

View File

@ -1,16 +1,318 @@
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 LoggingEdit from './LoggingEdit';
jest.mock('../../../../api/models/Settings');
const mockSettings = {
LOG_AGGREGATOR_HOST: 'https://logstash',
LOG_AGGREGATOR_PORT: 1234,
LOG_AGGREGATOR_TYPE: 'logstash',
LOG_AGGREGATOR_USERNAME: '',
LOG_AGGREGATOR_PASSWORD: '',
LOG_AGGREGATOR_LOGGERS: [
'awx',
'activity_stream',
'job_events',
'system_tracking',
],
LOG_AGGREGATOR_INDIVIDUAL_FACTS: false,
LOG_AGGREGATOR_ENABLED: true,
LOG_AGGREGATOR_TOWER_UUID: '',
LOG_AGGREGATOR_PROTOCOL: 'https',
LOG_AGGREGATOR_TCP_TIMEOUT: 123,
LOG_AGGREGATOR_VERIFY_CERT: true,
LOG_AGGREGATOR_LEVEL: 'ERROR',
LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1,
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
};
const mockDefaultSettings = {
LOG_AGGREGATOR_HOST: null,
LOG_AGGREGATOR_PORT: null,
LOG_AGGREGATOR_TYPE: null,
LOG_AGGREGATOR_USERNAME: '',
LOG_AGGREGATOR_PASSWORD: '',
LOG_AGGREGATOR_LOGGERS: [
'awx',
'activity_stream',
'job_events',
'system_tracking',
],
LOG_AGGREGATOR_INDIVIDUAL_FACTS: false,
LOG_AGGREGATOR_ENABLED: false,
LOG_AGGREGATOR_TOWER_UUID: '',
LOG_AGGREGATOR_PROTOCOL: 'https',
LOG_AGGREGATOR_TCP_TIMEOUT: 5,
LOG_AGGREGATOR_VERIFY_CERT: true,
LOG_AGGREGATOR_LEVEL: 'INFO',
LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1,
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
};
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: mockSettings,
});
describe('<LoggingEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LoggingEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/logging/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LoggingEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('LoggingEdit').length).toBe(1);
});
test('Enable External Logging toggle should be disabled when it is off and there is no Logging Aggregator or no Logging Aggregator Type', async () => {
const enableLoggingField =
"FormGroup[label='Enable External Logging'] Switch";
const loggingAggregatorField =
"FormGroup[label='Logging Aggregator'] TextInputBase";
expect(wrapper.find(enableLoggingField).prop('isChecked')).toBe(true);
expect(wrapper.find(enableLoggingField).prop('isDisabled')).toBe(false);
await act(async () => {
wrapper.find(enableLoggingField).invoke('onChange')(false);
});
await act(async () => {
wrapper.find(loggingAggregatorField).invoke('onChange')(null, {
target: {
name: 'LOG_AGGREGATOR_HOST',
value: '',
},
});
});
wrapper.update();
expect(
wrapper
.find(enableLoggingField)
.find('Switch')
.prop('isChecked')
).toBe(false);
expect(
wrapper
.find(enableLoggingField)
.find('Switch')
.prop('isDisabled')
).toBe(true);
});
test('Logging Aggregator and Logging Aggregator Type should be required when External Logging toggle is enabled', () => {
const enableLoggingField = wrapper.find(
"FormGroup[label='Enable External Logging']"
);
const loggingAggregatorField = wrapper.find(
"FormGroup[label='Logging Aggregator']"
);
const loggingAggregatorTypeField = wrapper.find(
"FormGroup[label='Logging Aggregator Type']"
);
expect(enableLoggingField.find('RevertButton').text()).toEqual('Revert');
expect(
loggingAggregatorField.find('.pf-c-form__label-required')
).toHaveLength(1);
expect(
loggingAggregatorTypeField.find('.pf-c-form__label-required')
).toHaveLength(1);
});
test('Logging Aggregator and Logging Aggregator Type should not be required when External Logging toggle is disabled', async () => {
await act(async () => {
wrapper
.find("FormGroup[label='Enable External Logging'] Switch")
.invoke('onChange')(false);
});
wrapper.update();
const enableLoggingField = wrapper.find(
"FormGroup[label='Enable External Logging']"
);
const loggingAggregatorField = wrapper.find(
"FormGroup[label='Logging Aggregator']"
);
const loggingAggregatorTypeField = wrapper.find(
"FormGroup[label='Logging Aggregator Type']"
);
expect(enableLoggingField.find('RevertButton').text()).toEqual('Undo');
expect(
loggingAggregatorField.find('.pf-c-form__label-required')
).toHaveLength(0);
expect(
loggingAggregatorTypeField.find('.pf-c-form__label-required')
).toHaveLength(0);
});
test('HTTPS certificate toggle should be shown when protocol is https', () => {
const httpsField = wrapper.find(
"FormGroup[label='Enable/disable HTTPS certificate verification']"
);
expect(httpsField).toHaveLength(1);
expect(httpsField.find('Switch').prop('isChecked')).toBe(true);
});
test('TCP connection timeout should be required when protocol is tcp', () => {
const tcpTimeoutField = wrapper.find(
"FormGroup[label='TCP Connection Timeout']"
);
expect(tcpTimeoutField).toHaveLength(1);
expect(tcpTimeoutField.find('.pf-c-form__label-required')).toHaveLength(1);
});
test('TCP connection timeout and https certificate toggle should be hidden when protocol is udp', async () => {
await act(async () => {
wrapper
.find('AnsibleSelect[name="LOG_AGGREGATOR_PROTOCOL"]')
.invoke('onChange')({
target: {
name: 'LOG_AGGREGATOR_PROTOCOL',
value: 'udp',
},
});
});
wrapper.update();
expect(
wrapper.find(
"FormGroup[label='Enable/disable HTTPS certificate verification']"
)
).toHaveLength(0);
expect(
wrapper.find("FormGroup[label='TCP Connection Timeout']")
).toHaveLength(0);
expect(
wrapper.find("FormGroup[label='Logging Aggregator Level Threshold']")
).toHaveLength(1);
});
test('should display successful toast when test button is clicked', async () => {
SettingsAPI.test.mockResolvedValue({});
expect(SettingsAPI.test).toHaveBeenCalledTimes(0);
expect(wrapper.find('LoggingTestAlert')).toHaveLength(0);
await act(async () => {
wrapper.find('button[aria-label="Test logging"]').invoke('onClick')();
});
wrapper.update();
await waitForElement(wrapper, 'LoggingTestAlert');
expect(SettingsAPI.test).toHaveBeenCalledTimes(1);
await act(async () => {
wrapper.find('AlertActionCloseButton button').invoke('onClick')();
});
await waitForElement(wrapper, 'LoggingTestAlert', el => el.length === 0);
});
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(mockDefaultSettings);
});
test('should successfully send request to api on form submission', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
const loggingAggregatorField =
"FormGroup[label='Logging Aggregator'] TextInputBase";
await act(async () => {
wrapper.find(loggingAggregatorField).invoke('onChange')(null, {
target: {
name: 'LOG_AGGREGATOR_PORT',
value: 1010,
},
});
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
...mockSettings,
LOG_AGGREGATOR_PORT: 1010,
});
});
test('should navigate to logging detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/logging/details');
});
test('should navigate to logging detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/logging/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}>
<LoggingEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -4,11 +4,14 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import { useConfig } from '../../../contexts/Config';
import MiscSystemDetail from './MiscSystemDetail';
import MiscSystemEdit from './MiscSystemEdit';
function MiscSystem({ i18n }) {
const baseURL = '/settings/miscellaneous_system';
const { me } = useConfig();
return (
<PageSection>
<Card>
@ -18,7 +21,11 @@ function MiscSystem({ i18n }) {
<MiscSystemDetail />
</Route>
<Route path={`${baseURL}/edit`}>
<MiscSystemEdit />
{me?.is_superuser ? (
<MiscSystemEdit />
) : (
<Redirect to={`${baseURL}/details`} />
)}
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>

View File

@ -13,7 +13,7 @@ import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
import { sortNestedDetails, pluck } from '../../shared/settingUtils';
function MiscSystemDetail({ i18n }) {

View File

@ -1,23 +1,305 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useCallback, useEffect } from 'react';
import { useHistory } 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 { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import {
BooleanField,
EncryptedField,
InputField,
ObjectField,
RevertAllAlert,
RevertFormActionGroup,
} from '../../shared';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
import { pluck } from '../../shared/settingUtils';
function MiscSystemEdit({ i18n }) {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchSystem, result: system } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('all');
const {
OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS,
REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS,
},
...pluckedSystemData
} = pluck(
data,
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS',
'AUTH_BASIC_ENABLED',
'AUTOMATION_ANALYTICS_GATHER_INTERVAL',
'AUTOMATION_ANALYTICS_URL',
'CUSTOM_VENV_PATHS',
'INSIGHTS_TRACKING_STATE',
'LOGIN_REDIRECT_OVERRIDE',
'MANAGE_ORGANIZATION_AUTH',
'OAUTH2_PROVIDER',
'ORG_ADMINS_CAN_SEE_ALL_USERS',
'REDHAT_PASSWORD',
'REDHAT_USERNAME',
'REMOTE_HOST_HEADERS',
'SESSIONS_PER_USER',
'SESSION_COOKIE_AGE',
'TOWER_URL_BASE'
);
const systemData = {
...pluckedSystemData,
ACCESS_TOKEN_EXPIRE_SECONDS,
REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS,
};
const {
OAUTH2_PROVIDER: OAUTH2_PROVIDER_OPTIONS,
...restOptions
} = options;
const systemOptions = {
...restOptions,
ACCESS_TOKEN_EXPIRE_SECONDS: {
...OAUTH2_PROVIDER_OPTIONS,
default: OAUTH2_PROVIDER_OPTIONS.default.ACCESS_TOKEN_EXPIRE_SECONDS,
type: OAUTH2_PROVIDER_OPTIONS.child.type,
label: i18n._(t`Access Token Expiration`),
},
REFRESH_TOKEN_EXPIRE_SECONDS: {
...OAUTH2_PROVIDER_OPTIONS,
default: OAUTH2_PROVIDER_OPTIONS.default.REFRESH_TOKEN_EXPIRE_SECONDS,
type: OAUTH2_PROVIDER_OPTIONS.child.type,
label: i18n._(t`Refresh Token Expiration`),
},
AUTHORIZATION_CODE_EXPIRE_SECONDS: {
...OAUTH2_PROVIDER_OPTIONS,
default:
OAUTH2_PROVIDER_OPTIONS.default.AUTHORIZATION_CODE_EXPIRE_SECONDS,
type: OAUTH2_PROVIDER_OPTIONS.child.type,
label: i18n._(t`Authorization Code Expiration`),
},
};
const mergedData = {};
Object.keys(systemData).forEach(key => {
mergedData[key] = systemOptions[key];
mergedData[key].value = systemData[key];
});
return mergedData;
}, [options, i18n]),
null
);
useEffect(() => {
fetchSystem();
}, [fetchSystem]);
const {
error: submitError,
request: submitForm,
result: submitResult,
} = useRequest(
useCallback(async values => {
const result = await SettingsAPI.updateAll(values);
return result;
}, []),
null
);
const handleSubmit = async form => {
const {
ACCESS_TOKEN_EXPIRE_SECONDS,
REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS,
...formData
} = form;
await submitForm({
...formData,
CUSTOM_VENV_PATHS: JSON.parse(formData.CUSTOM_VENV_PATHS),
REMOTE_HOST_HEADERS: JSON.parse(formData.REMOTE_HOST_HEADERS),
OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS,
REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS,
},
});
};
useEffect(() => {
if (submitResult) {
history.push('/settings/miscellaneous_system/details');
}
}, [submitResult, history]);
const handleRevertAll = async () => {
const {
ACCESS_TOKEN_EXPIRE_SECONDS,
REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS,
...systemData
} = system;
const defaultValues = {};
Object.entries(systemData).forEach(([key, value]) => {
defaultValues[key] = value.default;
});
await submitForm({
...defaultValues,
OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS: ACCESS_TOKEN_EXPIRE_SECONDS.default,
REFRESH_TOKEN_EXPIRE_SECONDS: REFRESH_TOKEN_EXPIRE_SECONDS.default,
AUTHORIZATION_CODE_EXPIRE_SECONDS:
AUTHORIZATION_CODE_EXPIRE_SECONDS.default,
},
});
closeModal();
};
const handleCancel = () => {
history.push('/settings/miscellaneous_system/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list') {
acc[key] = JSON.stringify(fields[key].value, null, 2);
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/miscellaneous_system/details"
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && system && (
<Formik
initialValues={{ ...initialValues(system) }}
onSubmit={handleSubmit}
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{formik => {
return (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="TOWER_URL_BASE"
config={system.TOWER_URL_BASE}
isRequired
type="url"
/>
<BooleanField
name="ORG_ADMINS_CAN_SEE_ALL_USERS"
config={system.ORG_ADMINS_CAN_SEE_ALL_USERS}
/>
<BooleanField
name="MANAGE_ORGANIZATION_AUTH"
config={system.MANAGE_ORGANIZATION_AUTH}
/>
<InputField
name="SESSION_COOKIE_AGE"
config={system.SESSION_COOKIE_AGE}
type="number"
isRequired
/>
<InputField
name="SESSIONS_PER_USER"
config={system.SESSIONS_PER_USER}
type="number"
isRequired
/>
<BooleanField
name="AUTH_BASIC_ENABLED"
config={system.AUTH_BASIC_ENABLED}
/>
<BooleanField
name="ALLOW_OAUTH2_FOR_EXTERNAL_USERS"
config={system.ALLOW_OAUTH2_FOR_EXTERNAL_USERS}
/>
<InputField
name="LOGIN_REDIRECT_OVERRIDE"
config={system.LOGIN_REDIRECT_OVERRIDE}
type="url"
/>
<InputField
name="ACCESS_TOKEN_EXPIRE_SECONDS"
config={system.ACCESS_TOKEN_EXPIRE_SECONDS}
type="number"
/>
<InputField
name="REFRESH_TOKEN_EXPIRE_SECONDS"
config={system.REFRESH_TOKEN_EXPIRE_SECONDS}
type="number"
/>
<InputField
name="AUTHORIZATION_CODE_EXPIRE_SECONDS"
config={system.AUTHORIZATION_CODE_EXPIRE_SECONDS}
type="number"
/>
<BooleanField
name="INSIGHTS_TRACKING_STATE"
config={system.INSIGHTS_TRACKING_STATE}
/>
<InputField
name="REDHAT_USERNAME"
config={system.REDHAT_USERNAME}
/>
<EncryptedField
name="REDHAT_PASSWORD"
config={system.REDHAT_PASSWORD}
/>
<InputField
name="AUTOMATION_ANALYTICS_URL"
config={system.AUTOMATION_ANALYTICS_URL}
type="url"
/>
<InputField
name="AUTOMATION_ANALYTICS_GATHER_INTERVAL"
config={system.AUTOMATION_ANALYTICS_GATHER_INTERVAL}
type="number"
isRequired
/>
<ObjectField
name="REMOTE_HOST_HEADERS"
config={system.REMOTE_HOST_HEADERS}
isRequired
/>
<ObjectField
name="CUSTOM_VENV_PATHS"
config={system.CUSTOM_VENV_PATHS}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
);
}}
</Formik>
)}
</CardBody>
);
}

View File

@ -1,16 +1,124 @@
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 mockAllSettings from '../../shared/data.allSettings.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import MiscSystemEdit from './MiscSystemEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: mockAllSettings,
});
describe('<MiscSystemEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<MiscSystemEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/miscellaneous_system/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<MiscSystemEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('MiscSystemEdit').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);
});
test('should successfully send request to api on form submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should navigate to miscellaneous detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual(
'/settings/miscellaneous_system/details'
);
});
test('should navigate to miscellaneous detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual(
'/settings/miscellaneous_system/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}>
<MiscSystemEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function RADIUSDetail({ i18n }) {
const { me } = useConfig();

View File

@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function SAMLDetail({ i18n }) {
const { me } = useConfig();

View File

@ -1,16 +1,78 @@
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 { SettingsAPI } from '../../api';
import mockAllOptions from './shared/data.allSettingOptions.json';
import Settings from './Settings';
jest.mock('../../api/models/Settings');
SettingsAPI.readAllOptions.mockResolvedValue({
data: mockAllOptions,
});
describe('<Settings />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<Settings />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.length).toBe(1);
test('should render Redirect for users without system admin or auditor permissions', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings'],
});
await act(async () => {
wrapper = mountWithContexts(<Settings />, {
context: {
router: {
history,
},
config: {
me: {
is_superuser: false,
is_system_auditor: false,
},
},
},
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Redirect').length).toBe(1);
expect(wrapper.find('SettingList').length).toBe(0);
});
test('should render Settings for users with permissions system admin or auditor permissions', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings'],
});
await act(async () => {
wrapper = mountWithContexts(<Settings />, {
context: {
router: {
history,
},
config: {
is_superuser: true,
is_system_auditor: true,
},
},
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('SettingList').length).toBe(1);
});
test('should render content error on throw', async () => {
SettingsAPI.readAllOptions.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(<Settings />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -13,7 +13,7 @@ import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function TACACSDetail({ i18n }) {
const { me } = useConfig();

View File

@ -14,7 +14,7 @@ import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import { pluck } from '../../shared/settingUtils';
import SettingDetail from '../../shared';
import { SettingDetail } from '../../shared';
function UIDetail({ i18n }) {
const { me } = useConfig();

View File

@ -0,0 +1,59 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import {
Alert,
AlertActionCloseButton,
AlertGroup,
} from '@patternfly/react-core';
function LoggingTestAlert({ i18n, successResponse, errorResponse, onClose }) {
let testMessage = null;
if (successResponse) {
testMessage = i18n._(t`Log aggregator test sent successfully.`);
}
let errorData = null;
if (errorResponse) {
testMessage = i18n._(t`There was an error testing the log aggregator.`);
if (
errorResponse?.response?.statusText &&
errorResponse?.response?.status
) {
testMessage = i18n._(
t`${errorResponse.response.statusText}: ${errorResponse.response.status}`
);
errorData = i18n._(t`${errorResponse.response?.data?.error}`);
}
}
return (
<AlertGroup isToast>
{testMessage && (
<Alert
actionClose={<AlertActionCloseButton onClose={onClose} />}
title={successResponse ? i18n._(t`Success`) : i18n._(t`Error`)}
variant={successResponse ? 'success' : 'danger'}
>
<b id="test-message">{testMessage}</b>
<p id="test-error">{errorData}</p>
</Alert>
)}
</AlertGroup>
);
}
LoggingTestAlert.propTypes = {
successResponse: shape({}),
errorResponse: shape({}),
onClose: func,
};
LoggingTestAlert.defaultProps = {
successResponse: null,
errorResponse: null,
onClose: () => {},
};
export default withI18n()(LoggingTestAlert);

View File

@ -0,0 +1,61 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import LoggingTestAlert from './LoggingTestAlert';
describe('LoggingTestAlert', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('renders expected content when test is successful', () => {
wrapper = mountWithContexts(
<LoggingTestAlert
successResponse={{}}
errorResponse={null}
onClose={() => {}}
/>
);
expect(wrapper.find('b#test-message').text()).toBe(
'Log aggregator test sent successfully.'
);
});
test('renders expected content when test is unsuccessful', () => {
wrapper = mountWithContexts(
<LoggingTestAlert
successResponse={null}
errorResponse={{
response: {
data: {
error: 'Name or service not known',
},
status: 400,
statusText: 'Bad Response',
},
}}
onClose={() => {}}
/>
);
expect(wrapper.find('b#test-message').text()).toBe('Bad Response: 400');
expect(wrapper.find('p#test-error').text()).toBe(
'Name or service not known'
);
});
test('close button should call "onClose"', () => {
const onClose = jest.fn();
expect(onClose).toHaveBeenCalledTimes(0);
wrapper = mountWithContexts(
<LoggingTestAlert
successResponse={{}}
errorResponse={null}
onClose={onClose}
/>
);
wrapper.find('AlertActionCloseButton').invoke('onClose')();
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,39 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal';
function RevertAllAlert({ i18n, onClose, onRevertAll }) {
return (
<AlertModal
isOpen
title={i18n._(t`Revert settings`)}
variant="info"
onClose={onClose}
actions={[
<Button
key="revert"
variant="primary"
aria-label={i18n._(t`Confirm revert all`)}
onClick={onRevertAll}
>
{i18n._(t`Revert all`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel revert`)}
onClick={onClose}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{i18n._(t`This will revert all configuration values to their
factory defaults. Are you sure you want to proceed?`)}
</AlertModal>
);
}
export default withI18n()(RevertAllAlert);

View File

@ -0,0 +1,13 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import RevertAllAlert from './RevertAllAlert';
describe('RevertAllAlert', () => {
test('renders the expected content', async () => {
const wrapper = mountWithContexts(
<RevertAllAlert onClose={() => {}} onRevertAll={() => {}} />
);
expect(wrapper).toHaveLength(1);
wrapper.unmount();
});
});

View File

@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import { Button, Tooltip } from '@patternfly/react-core';
import styled from 'styled-components';
const ButtonWrapper = styled.div`
margin-left: auto;
&&& {
--pf-c-button--FontSize: var(--pf-c-button--m-small--FontSize);
}
`;
function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
const [field, meta, helpers] = useField(id);
const initialValue = meta.initialValue ?? '';
const currentValue = field.value;
let isRevertable = true;
let isMatch = false;
if (currentValue === defaultValue && currentValue !== initialValue) {
isRevertable = false;
}
if (currentValue === defaultValue && currentValue === initialValue) {
isMatch = true;
}
function handleConfirm() {
helpers.setValue(isRevertable ? defaultValue : initialValue);
}
const revertTooltipContent = isRevertable
? i18n._(t`Revert to factory default.`)
: i18n._(t`Restore initial value.`);
const tooltipContent =
isDisabled || isMatch
? i18n._(t`Setting matches factory default.`)
: revertTooltipContent;
return (
<Tooltip entryDelay={700} content={tooltipContent}>
<ButtonWrapper>
<Button
aria-label={isRevertable ? i18n._(t`Revert`) : i18n._(t`Undo`)}
isInline
isSmall
onClick={handleConfirm}
type="button"
variant="link"
isDisabled={isDisabled || isMatch}
>
{isRevertable ? i18n._(t`Revert`) : i18n._(t`Undo`)}
</Button>
</ButtonWrapper>
</Tooltip>
);
}
RevertButton.propTypes = {
id: PropTypes.string.isRequired,
};
export default withI18n()(RevertButton);

View File

@ -0,0 +1,77 @@
import React from 'react';
import { Formik } from 'formik';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import RevertButton from './RevertButton';
describe('RevertButton', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
});
test('button text should display "Revert"', async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
test_input: 'foo',
}}
>
{() => <RevertButton id="test_input" defaultValue="" />}
</Formik>
);
expect(wrapper.find('button').text()).toEqual('Revert');
});
test('button text should display "Undo"', async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
test_input: 'foo',
}}
values={{
test_input: 'bar',
}}
>
{() => <RevertButton id="test_input" defaultValue="bar" />}
</Formik>
);
expect(wrapper.find('button').text()).toEqual('Revert');
});
test('should revert value to default on button click', async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
test_input: 'foo',
}}
>
{() => <RevertButton id="test_input" defaultValue="bar" />}
</Formik>
);
expect(wrapper.find('button').text()).toEqual('Revert');
await act(async () => {
wrapper.find('button[aria-label="Revert"]').invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('button').text()).toEqual('Undo');
});
test('should be disabled when current value equals the initial and default values', async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
test_input: 'bar',
}}
values={{
test_input: 'bar',
}}
>
{() => <RevertButton id="test_input" defaultValue="bar" />}
</Formik>
);
expect(wrapper.find('button').text()).toEqual('Revert');
expect(wrapper.find('button').props().disabled).toBe(true);
});
});

View File

@ -0,0 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { ActionGroup, Button } from '@patternfly/react-core';
import { FormFullWidthLayout } from '../../../components/FormLayout';
const RevertFormActionGroup = ({
children,
onCancel,
onRevert,
onSubmit,
i18n,
}) => {
return (
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={onSubmit}
>
{i18n._(t`Save`)}
</Button>
<Button
aria-label={i18n._(t`Revert all to default`)}
variant="secondary"
type="button"
onClick={onRevert}
>
{i18n._(t`Revert all to default`)}
</Button>
{children}
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
);
};
RevertFormActionGroup.propTypes = {
onCancel: PropTypes.func.isRequired,
onRevert: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
export default withI18n()(RevertFormActionGroup);

View File

@ -0,0 +1,17 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import RevertFormActionGroup from './RevertFormActionGroup';
describe('RevertFormActionGroup', () => {
test('should render the expected content', () => {
const wrapper = mountWithContexts(
<RevertFormActionGroup
onSubmit={() => {}}
onCancel={() => {}}
onRevert={() => {}}
/>
);
expect(wrapper).toHaveLength(1);
wrapper.unmount();
});
});

View File

@ -2,7 +2,7 @@ import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Detail } from '../../../components/DetailList';
import { VariablesDetail } from '../../../components/CodeMirrorInput';
import CodeDetail from '../../../components/DetailList/CodeDetail';
export default withI18n()(
({ i18n, helpText, id, label, type, unit = '', value }) => {
@ -12,10 +12,11 @@ export default withI18n()(
switch (dataType) {
case 'nested object':
detail = (
<VariablesDetail
<CodeDetail
dataCy={id}
label={label}
helpText={helpText}
label={label}
mode="javascript"
rows={4}
value={JSON.stringify(value || {}, undefined, 2)}
/>
@ -23,12 +24,13 @@ export default withI18n()(
break;
case 'list':
detail = (
<VariablesDetail
<CodeDetail
dataCy={id}
helpText={helpText}
rows={4}
label={label}
value={value}
mode="javascript"
rows={4}
value={JSON.stringify(value || [], undefined, 2)}
/>
);
break;

View File

@ -0,0 +1,152 @@
import React from 'react';
import { Formik } from 'formik';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import {
BooleanField,
ChoiceField,
EncryptedField,
InputField,
ObjectField,
} from './SharedFields';
describe('Setting form fields', () => {
test('BooleanField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
boolean: true,
}}
>
{() => (
<BooleanField
name="boolean"
config={{
label: 'test',
help_text: 'test',
}}
/>
)}
</Formik>
);
expect(wrapper.find('Switch')).toHaveLength(1);
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
expect(wrapper.find('Switch').prop('isDisabled')).toBe(false);
await act(async () => {
wrapper.find('Switch').invoke('onChange')(false);
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toBe(false);
});
test('ChoiceField renders unrequired form field', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
choice: 'one',
}}
>
{() => (
<ChoiceField
name="choice"
config={{
label: 'test',
help_text: 'test',
choices: [
['one', 'One'],
['two', 'Two'],
],
}}
/>
)}
</Formik>
);
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(0);
});
test('EncryptedField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
encrypted: '',
}}
>
{() => (
<EncryptedField
name="encrypted"
config={{
label: 'test',
help_text: 'test',
}}
/>
)}
</Formik>
);
expect(wrapper.find('PasswordInput')).toHaveLength(1);
});
test('InputField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
text: '',
}}
>
{() => (
<InputField
name="text"
config={{
label: 'test',
help_text: 'test',
default: '',
}}
/>
)}
</Formik>
);
expect(wrapper.find('TextInputBase')).toHaveLength(1);
expect(wrapper.find('TextInputBase').prop('value')).toEqual('');
await act(async () => {
wrapper.find('TextInputBase').invoke('onChange')(null, {
target: {
name: 'text',
value: 'foo',
},
});
});
wrapper.update();
expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo');
});
test('ObjectField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
object: '["one", "two", "three"]',
}}
>
{() => (
<ObjectField
name="object"
config={{
label: 'test',
help_text: 'test',
default: '[]',
type: 'list',
}}
/>
)}
</Formik>
);
expect(wrapper.find('CodeMirrorInput')).toHaveLength(1);
expect(wrapper.find('CodeMirrorInput').prop('value')).toBe(
'["one", "two", "three"]'
);
await act(async () => {
wrapper.find('CodeMirrorInput').invoke('onChange')('[]');
});
wrapper.update();
expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]');
});
});

View File

@ -0,0 +1,258 @@
import React 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 {
FormGroup as PFFormGroup,
InputGroup,
TextInput,
Switch,
} from '@patternfly/react-core';
import styled from 'styled-components';
import AnsibleSelect from '../../../components/AnsibleSelect';
import CodeMirrorInput from '../../../components/CodeMirrorInput';
import { PasswordInput } from '../../../components/FormField';
import { FormFullWidthLayout } from '../../../components/FormLayout';
import Popover from '../../../components/Popover';
import { combine, required, url, minMaxValue } from '../../../util/validators';
import RevertButton from './RevertButton';
const FormGroup = styled(PFFormGroup)`
.pf-c-form__group-label {
display: inline-flex;
align-items: center;
width: 100%;
}
`;
const SettingGroup = withI18n()(
({
i18n,
children,
defaultValue,
fieldId,
helperTextInvalid,
isDisabled,
isRequired,
label,
popoverContent,
validated,
}) => {
return (
<FormGroup
fieldId={fieldId}
helperTextInvalid={helperTextInvalid}
isRequired={isRequired}
label={label}
validated={validated}
labelIcon={
<>
<Popover
content={popoverContent}
ariaLabel={`${i18n._(t`More information for`)} ${label}`}
/>
<RevertButton
id={fieldId}
defaultValue={defaultValue}
isDisabled={isDisabled}
/>
</>
}
>
{children}
</FormGroup>
);
}
);
const BooleanField = withI18n()(
({ i18n, ariaLabel = '', name, config, disabled = false }) => {
const [field, meta, helpers] = useField(name);
return (
<SettingGroup
defaultValue={config.default ?? false}
fieldId={name}
helperTextInvalid={meta.error}
isDisabled={disabled}
label={config.label}
popoverContent={config.help_text}
>
<Switch
id={name}
isChecked={field.value}
isDisabled={disabled}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
onChange={checked => helpers.setValue(checked)}
aria-label={ariaLabel || config.label}
/>
</SettingGroup>
);
}
);
BooleanField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
ariaLabel: string,
disabled: bool,
};
const ChoiceField = withI18n()(({ i18n, name, config, isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [field, meta] = useField({ name, validate });
const isValid = !meta.error || !meta.touched;
return (
<SettingGroup
defaultValue={config.default ?? ''}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
>
<AnsibleSelect
id={name}
{...field}
data={[
...config.choices.map(([value, label], index) => ({
label,
value: value ?? '',
key: value ?? index,
})),
]}
/>
</SettingGroup>
);
});
ChoiceField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
isRequired: bool,
};
const EncryptedField = withI18n()(
({ i18n, name, config, isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
return (
<SettingGroup
defaultValue={config.default ?? ''}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
>
<InputGroup>
<PasswordInput
id={name}
name={name}
label={config.label}
validate={validate}
isRequired={isRequired}
/>
</InputGroup>
</SettingGroup>
);
}
);
EncryptedField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
isRequired: bool,
};
const InputField = withI18n()(
({ i18n, name, config, type = 'text', isRequired = false }) => {
const {
min_value = Number.MIN_SAFE_INTEGER,
max_value = Number.MAX_SAFE_INTEGER,
} = config;
const validators = [
isRequired ? required(null, i18n) : null,
type === 'url' ? url(i18n) : null,
type === 'number' ? minMaxValue(min_value, max_value, i18n) : null,
];
const [field, meta] = useField({ name, validate: combine(validators) });
const isValid = !(meta.touched && meta.error);
return (
<SettingGroup
defaultValue={config.default || ''}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
>
<TextInput
id={name}
isRequired={isRequired}
placeholder={config.placeholder}
type={type}
validated={isValid ? 'default' : 'error'}
value={field.value}
onBlur={field.onBlur}
onChange={(value, event) => {
field.onChange(event);
}}
/>
</SettingGroup>
);
}
);
InputField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
type: oneOf(['text', 'number', 'url']),
isRequired: bool,
};
const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [field, meta, helpers] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
const emptyDefault = config.type === 'list' ? '[]' : '{}';
const defaultRevertValue = config.default
? JSON.stringify(config.default, null, 2)
: emptyDefault;
return (
<FormFullWidthLayout>
<SettingGroup
defaultValue={defaultRevertValue}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
>
<CodeMirrorInput
{...field}
id={name}
onChange={value => {
helpers.setValue(value);
}}
mode="javascript"
/>
</SettingGroup>
</FormFullWidthLayout>
);
});
ObjectField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
isRequired: bool,
};
export { BooleanField, ChoiceField, EncryptedField, InputField, ObjectField };

View File

@ -0,0 +1,309 @@
{
"ACTIVITY_STREAM_ENABLED":true,
"ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC":false,
"ORG_ADMINS_CAN_SEE_ALL_USERS":true,
"MANAGE_ORGANIZATION_AUTH":true,
"TOWER_URL_BASE":"https://localhost:3000",
"REMOTE_HOST_HEADERS":["REMOTE_ADDR","REMOTE_HOST"],
"PROXY_IP_ALLOWED_LIST":[],
"LICENSE":{},
"REDHAT_USERNAME":"",
"REDHAT_PASSWORD":"",
"AUTOMATION_ANALYTICS_URL":"https://example.com",
"INSTALL_UUID":"3f5a4d68-3a94-474c-a3c0-f23a33122ce6",
"CUSTOM_VENV_PATHS":[],
"AD_HOC_COMMANDS":[
"command",
"shell",
"yum",
"apt",
"apt_key",
"apt_repository",
"apt_rpm",
"service",
"group",
"user",
"mount",
"ping",
"selinux",
"setup",
"win_ping",
"win_service",
"win_updates",
"win_group",
"win_user"
],
"ALLOW_JINJA_IN_EXTRA_VARS":"template",
"AWX_PROOT_ENABLED":true,
"AWX_PROOT_BASE_PATH":"/tmp",
"AWX_PROOT_HIDE_PATHS":[],
"AWX_PROOT_SHOW_PATHS":[],
"AWX_ISOLATED_CHECK_INTERVAL":1,
"AWX_ISOLATED_LAUNCH_TIMEOUT":600,
"AWX_ISOLATED_CONNECTION_TIMEOUT":10,
"AWX_ISOLATED_HOST_KEY_CHECKING":false,
"AWX_ISOLATED_KEY_GENERATION":true,
"AWX_ISOLATED_PRIVATE_KEY":"",
"AWX_ISOLATED_PUBLIC_KEY":"",
"AWX_RESOURCE_PROFILING_ENABLED":false,
"AWX_RESOURCE_PROFILING_CPU_POLL_INTERVAL":0.25,
"AWX_RESOURCE_PROFILING_MEMORY_POLL_INTERVAL":0.25,
"AWX_RESOURCE_PROFILING_PID_POLL_INTERVAL":0.25,
"AWX_TASK_ENV":{},
"INSIGHTS_TRACKING_STATE":false,
"PROJECT_UPDATE_VVV":false,
"AWX_ROLES_ENABLED":true,
"AWX_COLLECTIONS_ENABLED":true,
"AWX_SHOW_PLAYBOOK_LINKS":false,
"GALAXY_IGNORE_CERTS":false,
"STDOUT_MAX_BYTES_DISPLAY":1048576,
"EVENT_STDOUT_MAX_BYTES_DISPLAY":1024,
"SCHEDULE_MAX_JOBS":10,
"AWX_ANSIBLE_CALLBACK_PLUGINS":[],
"DEFAULT_JOB_TIMEOUT":0,
"DEFAULT_INVENTORY_UPDATE_TIMEOUT":0,
"DEFAULT_PROJECT_UPDATE_TIMEOUT":0,
"ANSIBLE_FACT_CACHE_TIMEOUT":0,
"MAX_FORKS":200,
"LOG_AGGREGATOR_HOST":null,
"LOG_AGGREGATOR_PORT":null,
"LOG_AGGREGATOR_TYPE":null,
"LOG_AGGREGATOR_USERNAME":"",
"LOG_AGGREGATOR_PASSWORD":"",
"LOG_AGGREGATOR_LOGGERS":["awx","activity_stream","job_events","system_tracking"],
"LOG_AGGREGATOR_INDIVIDUAL_FACTS":false,
"LOG_AGGREGATOR_ENABLED":true,
"LOG_AGGREGATOR_TOWER_UUID":"",
"LOG_AGGREGATOR_PROTOCOL":"https",
"LOG_AGGREGATOR_TCP_TIMEOUT":5,
"LOG_AGGREGATOR_VERIFY_CERT":true,
"LOG_AGGREGATOR_LEVEL":"INFO",
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB":1,
"LOG_AGGREGATOR_MAX_DISK_USAGE_PATH":"/var/lib/awx",
"LOG_AGGREGATOR_RSYSLOGD_DEBUG":false,
"AUTOMATION_ANALYTICS_LAST_GATHER":null,
"AUTOMATION_ANALYTICS_GATHER_INTERVAL":14400,
"SESSION_COOKIE_AGE":1800,
"SESSIONS_PER_USER":-1,
"AUTH_BASIC_ENABLED":true,
"OAUTH2_PROVIDER":{
"ACCESS_TOKEN_EXPIRE_SECONDS":31536000000,
"REFRESH_TOKEN_EXPIRE_SECONDS":2628000,
"AUTHORIZATION_CODE_EXPIRE_SECONDS":600
},
"ALLOW_OAUTH2_FOR_EXTERNAL_USERS":false,
"LOGIN_REDIRECT_OVERRIDE":"",
"PENDO_TRACKING_STATE":"off",
"CUSTOM_LOGIN_INFO":"",
"CUSTOM_LOGO":"",
"MAX_UI_JOB_EVENTS":4000,
"UI_LIVE_UPDATES_ENABLED":true,
"AUTHENTICATION_BACKENDS":[
"awx.sso.backends.LDAPBackend",
"awx.sso.backends.RADIUSBackend",
"awx.sso.backends.TACACSPlusBackend",
"social_core.backends.github.GithubTeamOAuth2",
"django.contrib.auth.backends.ModelBackend"
],
"SOCIAL_AUTH_ORGANIZATION_MAP":null,
"SOCIAL_AUTH_TEAM_MAP":null,
"SOCIAL_AUTH_USER_FIELDS":null,
"AUTH_LDAP_SERVER_URI":"ldap://ldap.example.com",
"AUTH_LDAP_BIND_DN":"cn=eng_user1",
"AUTH_LDAP_BIND_PASSWORD":"$encrypted$",
"AUTH_LDAP_START_TLS":false,
"AUTH_LDAP_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30},
"AUTH_LDAP_USER_SEARCH":[],
"AUTH_LDAP_USER_DN_TEMPLATE":"uid=%(user)s,OU=Users,DC=example,DC=com",
"AUTH_LDAP_USER_ATTR_MAP":{},
"AUTH_LDAP_GROUP_SEARCH":["DC=example,DC=com","SCOPE_SUBTREE","(objectClass=group)"],
"AUTH_LDAP_GROUP_TYPE":"MemberDNGroupType",
"AUTH_LDAP_GROUP_TYPE_PARAMS":{"name_attr":"cn","member_attr":"member"},
"AUTH_LDAP_REQUIRE_GROUP":"CN=Tower Users,OU=Users,DC=example,DC=com",
"AUTH_LDAP_DENY_GROUP":null,
"AUTH_LDAP_USER_FLAGS_BY_GROUP":{"is_superuser":["cn=superusers"]},
"AUTH_LDAP_ORGANIZATION_MAP":{},
"AUTH_LDAP_TEAM_MAP":{},
"AUTH_LDAP_1_SERVER_URI":"",
"AUTH_LDAP_1_BIND_DN":"",
"AUTH_LDAP_1_BIND_PASSWORD":"",
"AUTH_LDAP_1_START_TLS":true,
"AUTH_LDAP_1_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30},
"AUTH_LDAP_1_USER_SEARCH":[],
"AUTH_LDAP_1_USER_DN_TEMPLATE":null,
"AUTH_LDAP_1_USER_ATTR_MAP":{},
"AUTH_LDAP_1_GROUP_SEARCH":[],
"AUTH_LDAP_1_GROUP_TYPE":"MemberDNGroupType",
"AUTH_LDAP_1_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"},
"AUTH_LDAP_1_REQUIRE_GROUP":null,
"AUTH_LDAP_1_DENY_GROUP":"CN=Disabled1",
"AUTH_LDAP_1_USER_FLAGS_BY_GROUP":{},
"AUTH_LDAP_1_ORGANIZATION_MAP":{},
"AUTH_LDAP_1_TEAM_MAP":{},
"AUTH_LDAP_2_SERVER_URI":"",
"AUTH_LDAP_2_BIND_DN":"",
"AUTH_LDAP_2_BIND_PASSWORD":"",
"AUTH_LDAP_2_START_TLS":false,
"AUTH_LDAP_2_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30},
"AUTH_LDAP_2_USER_SEARCH":[],
"AUTH_LDAP_2_USER_DN_TEMPLATE":null,
"AUTH_LDAP_2_USER_ATTR_MAP":{},
"AUTH_LDAP_2_GROUP_SEARCH":[],
"AUTH_LDAP_2_GROUP_TYPE":"MemberDNGroupType",
"AUTH_LDAP_2_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"},
"AUTH_LDAP_2_REQUIRE_GROUP":null,
"AUTH_LDAP_2_DENY_GROUP":"CN=Disabled2",
"AUTH_LDAP_2_USER_FLAGS_BY_GROUP":{},
"AUTH_LDAP_2_ORGANIZATION_MAP":{},
"AUTH_LDAP_2_TEAM_MAP":{},
"AUTH_LDAP_3_SERVER_URI":"",
"AUTH_LDAP_3_BIND_DN":"",
"AUTH_LDAP_3_BIND_PASSWORD":"",
"AUTH_LDAP_3_START_TLS":false,
"AUTH_LDAP_3_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30},
"AUTH_LDAP_3_USER_SEARCH":[],
"AUTH_LDAP_3_USER_DN_TEMPLATE":null,
"AUTH_LDAP_3_USER_ATTR_MAP":{},
"AUTH_LDAP_3_GROUP_SEARCH":[],
"AUTH_LDAP_3_GROUP_TYPE":"MemberDNGroupType",
"AUTH_LDAP_3_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"},
"AUTH_LDAP_3_REQUIRE_GROUP":null,
"AUTH_LDAP_3_DENY_GROUP":null,
"AUTH_LDAP_3_USER_FLAGS_BY_GROUP":{},
"AUTH_LDAP_3_ORGANIZATION_MAP":{},
"AUTH_LDAP_3_TEAM_MAP":{},
"AUTH_LDAP_4_SERVER_URI":"",
"AUTH_LDAP_4_BIND_DN":"",
"AUTH_LDAP_4_BIND_PASSWORD":"",
"AUTH_LDAP_4_START_TLS":false,
"AUTH_LDAP_4_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30},
"AUTH_LDAP_4_USER_SEARCH":[],
"AUTH_LDAP_4_USER_DN_TEMPLATE":null,
"AUTH_LDAP_4_USER_ATTR_MAP":{},
"AUTH_LDAP_4_GROUP_SEARCH":[],
"AUTH_LDAP_4_GROUP_TYPE":"MemberDNGroupType",
"AUTH_LDAP_4_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"},
"AUTH_LDAP_4_REQUIRE_GROUP":null,
"AUTH_LDAP_4_DENY_GROUP":null,
"AUTH_LDAP_4_USER_FLAGS_BY_GROUP":{},
"AUTH_LDAP_4_ORGANIZATION_MAP":{},
"AUTH_LDAP_4_TEAM_MAP":{},
"AUTH_LDAP_5_SERVER_URI":"",
"AUTH_LDAP_5_BIND_DN":"",
"AUTH_LDAP_5_BIND_PASSWORD":"",
"AUTH_LDAP_5_START_TLS":false,
"AUTH_LDAP_5_CONNECTION_OPTIONS":{"OPT_REFERRALS":0,"OPT_NETWORK_TIMEOUT":30},
"AUTH_LDAP_5_USER_SEARCH":[],
"AUTH_LDAP_5_USER_DN_TEMPLATE":null,
"AUTH_LDAP_5_USER_ATTR_MAP":{},
"AUTH_LDAP_5_GROUP_SEARCH":[],
"AUTH_LDAP_5_GROUP_TYPE":"MemberDNGroupType",
"AUTH_LDAP_5_GROUP_TYPE_PARAMS":{"member_attr":"member","name_attr":"cn"},
"AUTH_LDAP_5_REQUIRE_GROUP":null,
"AUTH_LDAP_5_DENY_GROUP":null,
"AUTH_LDAP_5_USER_FLAGS_BY_GROUP":{},
"AUTH_LDAP_5_ORGANIZATION_MAP":{},
"AUTH_LDAP_5_TEAM_MAP":{},
"RADIUS_SERVER":"example.org",
"RADIUS_PORT":1812,
"RADIUS_SECRET":"$encrypted$",
"TACACSPLUS_HOST":"",
"TACACSPLUS_PORT":49,
"TACACSPLUS_SECRET":"",
"TACACSPLUS_SESSION_TIMEOUT":5,
"TACACSPLUS_AUTH_PROTOCOL":"ascii",
"SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL":"https://localhost:3000/sso/complete/google-oauth2/",
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY":"",
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET":"",
"SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS":[],
"SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS":{},
"SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP":null,
"SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP":null,
"SOCIAL_AUTH_GITHUB_CALLBACK_URL":"https://localhost:3000/sso/complete/github/",
"SOCIAL_AUTH_GITHUB_KEY":"",
"SOCIAL_AUTH_GITHUB_SECRET":"",
"SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP":null,
"SOCIAL_AUTH_GITHUB_TEAM_MAP":null,
"SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL":"https://localhost:3000/sso/complete/github-org/",
"SOCIAL_AUTH_GITHUB_ORG_KEY":"",
"SOCIAL_AUTH_GITHUB_ORG_SECRET":"",
"SOCIAL_AUTH_GITHUB_ORG_NAME":"",
"SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP":null,
"SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP":null,
"SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL":"https://localhost:3000/sso/complete/github-team/",
"SOCIAL_AUTH_GITHUB_TEAM_KEY":"OAuth2 key (Client ID)",
"SOCIAL_AUTH_GITHUB_TEAM_SECRET":"$encrypted$",
"SOCIAL_AUTH_GITHUB_TEAM_ID":"team_id",
"SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP":{},
"SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP":{},
"SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL":"https://localhost:3000/sso/complete/azuread-oauth2/",
"SOCIAL_AUTH_AZUREAD_OAUTH2_KEY":"",
"SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET":"",
"SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP":null,
"SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP":null,
"SAML_AUTO_CREATE_OBJECTS":true,
"SOCIAL_AUTH_SAML_CALLBACK_URL":"https://localhost:3000/sso/complete/saml/",
"SOCIAL_AUTH_SAML_METADATA_URL":"https://localhost:3000/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":{"requestedAuthnContext":false},
"SOCIAL_AUTH_SAML_SP_EXTRA":null,
"SOCIAL_AUTH_SAML_EXTRA_DATA":null,
"SOCIAL_AUTH_SAML_ORGANIZATION_MAP":null,
"SOCIAL_AUTH_SAML_TEAM_MAP":null,
"SOCIAL_AUTH_SAML_ORGANIZATION_ATTR":{},
"SOCIAL_AUTH_SAML_TEAM_ATTR":{},
"NAMED_URL_FORMATS":{
"organizations":"<name>",
"teams":"<name>++<organization.name>",
"credential_types":"<name>+<kind>",
"credentials":"<name>++<credential_type.name>+<credential_type.kind>++<organization.name>",
"notification_templates":"<name>++<organization.name>",
"job_templates":"<name>++<organization.name>",
"projects":"<name>++<organization.name>",
"inventories":"<name>++<organization.name>",
"hosts":"<name>++<inventory.name>++<organization.name>",
"groups":"<name>++<inventory.name>++<organization.name>",
"inventory_sources":"<name>++<inventory.name>++<organization.name>",
"inventory_scripts":"<name>++<organization.name>",
"instance_groups":"<name>",
"labels":"<name>++<organization.name>",
"workflow_job_templates":"<name>++<organization.name>",
"workflow_job_template_nodes":"<identifier>++<workflow_job_template.name>++<organization.name>",
"applications":"<name>++<organization.name>",
"users":"<username>",
"instances":"<hostname>"
},
"NAMED_URL_GRAPH_NODES":{
"organizations":{"fields":["name"],"adj_list":[]},
"teams":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"credential_types":{"fields":["name","kind"],"adj_list":[]},
"credentials":{
"fields":["name"],
"adj_list":[["credential_type","credential_types"],["organization","organizations"]]
},
"notification_templates":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"job_templates":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"projects":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"inventories":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"hosts":{"fields":["name"],"adj_list":[["inventory","inventories"]]},
"groups":{"fields":["name"],"adj_list":[["inventory","inventories"]]},
"inventory_sources":{"fields":["name"],"adj_list":[["inventory","inventories"]]},
"inventory_scripts":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"instance_groups":{"fields":["name"],"adj_list":[]},
"labels":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"workflow_job_templates":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"workflow_job_template_nodes":{
"fields":["identifier"],
"adj_list":[["workflow_job_template","workflow_job_templates"]]
},
"applications":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"users":{"fields":["username"],"adj_list":[]},
"instances":{"fields":["hostname"],"adj_list":[]}
}
}

View File

@ -1 +1,11 @@
export { default } from './SettingDetail';
export { default as LoggingTestAlert } from './LoggingTestAlert';
export { default as SettingDetail } from './SettingDetail';
export { default as RevertAllAlert } from './RevertAllAlert';
export { default as RevertFormActionGroup } from './RevertFormActionGroup';
export {
BooleanField,
ChoiceField,
EncryptedField,
InputField,
ObjectField,
} from './SharedFields';

View File

@ -5,11 +5,9 @@ export function assertDetail(wrapper, label, value) {
export function assertVariableDetail(wrapper, label, value) {
expect(
wrapper.find(`VariablesDetail[label="${label}"] .pf-c-form__label`).text()
wrapper.find(`CodeDetail[label="${label}"] .pf-c-form__label`).text()
).toBe(label);
expect(
wrapper
.find(`VariablesDetail[label="${label}"] CodeMirrorInput`)
.prop('value')
wrapper.find(`CodeDetail[label="${label}"] CodeMirrorInput`).prop('value')
).toBe(value);
}

View File

@ -1,15 +1,18 @@
export function sortNestedDetails(obj = {}) {
const nestedTypes = ['nested object', 'list'];
const nestedTypes = ['nested object', 'list', 'boolean'];
const notNested = Object.entries(obj).filter(
([, value]) => !nestedTypes.includes(value.type)
);
const booleanList = Object.entries(obj).filter(
([, value]) => value.type === 'boolean'
);
const nestedList = Object.entries(obj).filter(
([, value]) => value.type === 'list'
);
const nestedObject = Object.entries(obj).filter(
([, value]) => value.type === 'nested object'
);
return [...notNested, ...nestedList, ...nestedObject];
return [...notNested, ...booleanList, ...nestedList, ...nestedObject];
}
export function pluck(sourceObject, ...keys) {

View File

@ -0,0 +1,28 @@
import { useState } from 'react';
/**
* useModal hook provides a way to read and update modal visibility
* Returns: {
* isModalOpen: boolean that indicates if modal is open
* toggleModal: function that toggles the modal open and close
* closeModal: function that closes modal
* }
*/
export default function useModal() {
const [isModalOpen, setIsModalOpen] = useState(false);
function toggleModal() {
setIsModalOpen(!isModalOpen);
}
function closeModal() {
setIsModalOpen(false);
}
return {
isModalOpen,
toggleModal,
closeModal,
};
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import useModal from './useModal';
const TestHook = ({ callback }) => {
callback();
return null;
};
const testHook = callback => {
mount(<TestHook callback={callback} />);
};
describe('useModal hook', () => {
let closeModal;
let isModalOpen;
let toggleModal;
test('should return expected initial values', () => {
testHook(() => {
({ isModalOpen, toggleModal, closeModal } = useModal());
});
expect(isModalOpen).toEqual(false);
expect(toggleModal).toBeInstanceOf(Function);
expect(closeModal).toBeInstanceOf(Function);
});
test('should return expected isModalOpen value after modal toggle', () => {
testHook(() => {
({ isModalOpen, toggleModal, closeModal } = useModal());
});
expect(isModalOpen).toEqual(false);
act(() => {
toggleModal();
});
expect(isModalOpen).toEqual(true);
});
test('isModalOpen should be false after closeModal is called', () => {
testHook(() => {
({ isModalOpen, toggleModal, closeModal } = useModal());
});
expect(isModalOpen).toEqual(false);
act(() => {
toggleModal();
});
expect(isModalOpen).toEqual(true);
act(() => {
closeModal();
});
expect(isModalOpen).toEqual(false);
});
});