mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 02:19:58 -03:30
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:
parent
b338da40c5
commit
e0feda780b
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />', () => {
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
59
awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx
Normal file
59
awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx
Normal 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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
39
awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
Normal file
39
awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
Normal 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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
66
awx/ui_next/src/screens/Setting/shared/RevertButton.jsx
Normal file
66
awx/ui_next/src/screens/Setting/shared/RevertButton.jsx
Normal 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);
|
||||
77
awx/ui_next/src/screens/Setting/shared/RevertButton.test.jsx
Normal file
77
awx/ui_next/src/screens/Setting/shared/RevertButton.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
152
awx/ui_next/src/screens/Setting/shared/ShareFields.test.jsx
Normal file
152
awx/ui_next/src/screens/Setting/shared/ShareFields.test.jsx
Normal 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('[]');
|
||||
});
|
||||
});
|
||||
258
awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
Normal file
258
awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
Normal 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 };
|
||||
309
awx/ui_next/src/screens/Setting/shared/data.allSettings.json
Normal file
309
awx/ui_next/src/screens/Setting/shared/data.allSettings.json
Normal 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":[]}
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
28
awx/ui_next/src/util/useModal.js
Normal file
28
awx/ui_next/src/util/useModal.js
Normal 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,
|
||||
};
|
||||
}
|
||||
54
awx/ui_next/src/util/useModal.test.jsx
Normal file
54
awx/ui_next/src/util/useModal.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user