Merge pull request #8186 from marshmalien/setting-details

Add setting details and unit tests

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-09-30 19:01:30 +00:00 committed by GitHub
commit 5c0432b979
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 10573 additions and 511 deletions

View File

@ -16,6 +16,7 @@ register(
help_text=_('Number of seconds that a user is inactive before they will need to login again.'),
category=_('Authentication'),
category_slug='authentication',
unit=_('seconds'),
)
register(
'SESSIONS_PER_USER',
@ -49,6 +50,7 @@ register(
'in the number of seconds.'),
category=_('Authentication'),
category_slug='authentication',
unit=_('seconds'),
)
register(
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS',

View File

@ -39,7 +39,7 @@ class Metadata(metadata.SimpleMetadata):
'min_length', 'max_length',
'min_value', 'max_value',
'category', 'category_slug',
'defined_in_file'
'defined_in_file', 'unit',
]
for attr in text_attrs:

View File

@ -129,12 +129,14 @@ class SettingsRegistry(object):
placeholder = field_kwargs.pop('placeholder', empty)
encrypted = bool(field_kwargs.pop('encrypted', False))
defined_in_file = bool(field_kwargs.pop('defined_in_file', False))
unit = field_kwargs.pop('unit', None)
if getattr(field_kwargs.get('child', None), 'source', None) is not None:
field_kwargs['child'].source = None
field_instance = field_class(**field_kwargs)
field_instance.category_slug = category_slug
field_instance.category = category
field_instance.depends_on = depends_on
field_instance.unit = unit
if placeholder is not empty:
field_instance.placeholder = placeholder
field_instance.defined_in_file = defined_in_file

View File

@ -148,7 +148,7 @@ register(
default='https://example.com',
schemes=('http', 'https'),
allow_plain_hostname=True, # Allow hostname only without TLD.
label=_('Automation Analytics upload URL.'),
label=_('Automation Analytics upload URL'),
help_text=_('This setting is used to to configure data collection for the Automation Analytics dashboard'),
category=_('System'),
category_slug='system',
@ -253,6 +253,7 @@ register(
help_text=_('The number of seconds to sleep between status checks for jobs running on isolated instances.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -264,6 +265,7 @@ register(
'This includes the time needed to copy source control files (playbooks) to the isolated instance.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -276,6 +278,7 @@ register(
'Value should be substantially greater than expected network latency.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -497,6 +500,7 @@ register(
'timeout should be imposed. A timeout set on an individual job template will override this.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -509,6 +513,7 @@ register(
'timeout should be imposed. A timeout set on an individual inventory source will override this.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -521,6 +526,7 @@ register(
'timeout should be imposed. A timeout set on an individual project will override this.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -535,6 +541,7 @@ register(
'Use a value of 0 to indicate that no timeout should be imposed.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@ -542,7 +549,7 @@ register(
field_class=fields.IntegerField,
allow_null=False,
default=200,
label=_('Maximum number of forks per job.'),
label=_('Maximum number of forks per job'),
help_text=_('Saving a Job Template with more than this number of forks will result in an error. '
'When set to 0, no limit is applied.'),
category=_('Jobs'),
@ -672,6 +679,7 @@ register(
'aggregator protocols.'),
category=_('Logging'),
category_slug='logging',
unit=_('seconds'),
)
register(
'LOG_AGGREGATOR_VERIFY_CERT',
@ -752,7 +760,8 @@ register(
default=14400, # every 4 hours
min_value=1800, # every 30 minutes
category=_('System'),
category_slug='system'
category_slug='system',
unit=_('seconds'),
)

View File

@ -515,6 +515,7 @@ register(
help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'),
category=_('TACACS+'),
category_slug='tacacsplus',
unit=_('seconds'),
)
register(

View File

@ -24,6 +24,7 @@ import Projects from './models/Projects';
import Roles from './models/Roles';
import Root from './models/Root';
import Schedules from './models/Schedules';
import Settings from './models/Settings';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
import Tokens from './models/Tokens';
@ -61,6 +62,7 @@ const ProjectsAPI = new Projects();
const RolesAPI = new Roles();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SettingsAPI = new Settings();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
const TokensAPI = new Tokens();
@ -99,6 +101,7 @@ export {
RolesAPI,
RootAPI,
SchedulesAPI,
SettingsAPI,
SystemJobsAPI,
TeamsAPI,
TokensAPI,

View File

@ -0,0 +1,26 @@
import Base from '../Base';
class Settings extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/settings/';
}
readAllOptions() {
return this.http.options(`${this.baseUrl}all/`);
}
updateAll(data) {
return this.http.patch(`${this.baseUrl}all/`, data);
}
readCategory(category) {
return this.http.get(`${this.baseUrl}${category}/`);
}
readCategoryOptions(category) {
return this.http.options(`${this.baseUrl}${category}/`);
}
}
export default Settings;

View File

@ -1,9 +1,10 @@
import 'styled-components/macro';
import React, { useState, useEffect } from 'react';
import { node, number, oneOfType, shape, string } from 'prop-types';
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle';
import DetailPopover from '../DetailPopover';
import {
yamlToJson,
jsonToYaml,
@ -27,7 +28,7 @@ function getValueAsMode(value, mode) {
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
}
function VariablesDetail({ value, label, rows, fullHeight }) {
function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
const [mode, setMode] = useState(
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
);
@ -46,9 +47,14 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [value]);
const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null;
return (
<>
<DetailName
data-cy={labelCy}
id={dataCy}
component={TextListItemVariants.dt}
fullWidth
css="grid-column: 1 / -1"
@ -62,6 +68,9 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
>
{label}
</span>
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
</div>
</SplitItem>
<SplitItem>
@ -84,6 +93,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
</Split>
</DetailName>
<DetailValue
data-cy={valueCy}
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
@ -109,7 +119,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
);
}
VariablesDetail.propTypes = {
value: oneOfType([shape({}), string]).isRequired,
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired,
rows: number,
};

View File

@ -1,7 +1,8 @@
import React from 'react';
import { node, bool } from 'prop-types';
import { node, bool, string } from 'prop-types';
import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
import styled from 'styled-components';
import DetailPopover from '../DetailPopover';
const DetailName = styled(({ fullWidth, ...props }) => (
<TextListItem {...props} />
@ -14,9 +15,11 @@ const DetailName = styled(({ fullWidth, ...props }) => (
`}
`;
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
<TextListItem {...props} />
))`
const DetailValue = styled(
({ fullWidth, isEncrypted, isNotConfigured, ...props }) => (
<TextListItem {...props} />
)
)`
word-break: break-all;
${props =>
props.fullWidth &&
@ -24,9 +27,8 @@ const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
grid-column: 2 / -1;
`}
${props =>
props.isEncrypted &&
(props.isEncrypted || props.isNotConfigured) &&
`
text-transform: uppercase
color: var(--pf-global--Color--400);
`}
`;
@ -38,7 +40,9 @@ const Detail = ({
className,
dataCy,
alwaysVisible,
helpText,
isEncrypted,
isNotConfigured,
}) => {
if (!value && typeof value !== 'number' && !alwaysVisible) {
return null;
@ -54,8 +58,12 @@ const Detail = ({
component={TextListItemVariants.dt}
fullWidth={fullWidth}
data-cy={labelCy}
id={dataCy}
>
{label}
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
</DetailName>
<DetailValue
className={className}
@ -63,6 +71,7 @@ const Detail = ({
fullWidth={fullWidth}
data-cy={valueCy}
isEncrypted={isEncrypted}
isNotConfigured={isNotConfigured}
>
{value}
</DetailValue>
@ -74,11 +83,13 @@ Detail.propTypes = {
value: node,
fullWidth: bool,
alwaysVisible: bool,
helpText: string,
};
Detail.defaultProps = {
value: null,
fullWidth: false,
alwaysVisible: false,
helpText: null,
};
export default Detail;

View File

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { node, string } from 'prop-types';
import { Button as _Button, Popover } from '@patternfly/react-core';
import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const Button = styled(_Button)`
--pf-c-button--PaddingTop: 0;
--pf-c-button--PaddingBottom: 0;
`;
function DetailPopover({ header, content, id }) {
const [showPopover, setShowPopover] = useState(false);
if (!content) {
return null;
}
return (
<Popover
bodyContent={content}
headerContent={header}
hideOnOutsideClick
id={id}
isVisible={showPopover}
shouldClose={() => setShowPopover(false)}
>
<Button
onClick={() => setShowPopover(!showPopover)}
variant="plain"
aria-haspopup="true"
aria-expanded={showPopover}
>
<OutlinedQuestionCircleIcon
onClick={() => setShowPopover(!showPopover)}
/>
</Button>
</Popover>
);
}
DetailPopover.propTypes = {
content: node,
header: node,
id: string,
};
DetailPopover.defaultProps = {
content: null,
header: null,
id: 'detail-popover',
};
export default DetailPopover;

View File

@ -0,0 +1 @@
export { default } from './DetailPopover';

View File

@ -5,37 +5,57 @@ import { t } from '@lingui/macro';
import { ActionGroup, Button } from '@patternfly/react-core';
import { FormFullWidthLayout } from '../FormLayout';
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={onSubmit}
isDisabled={submitDisabled}
>
{i18n._(t`Save`)}
</Button>
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
);
const FormActionGroup = ({
onCancel,
onRevert,
onSubmit,
submitDisabled,
i18n,
}) => {
return (
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={onSubmit}
isDisabled={submitDisabled}
>
{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"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
);
};
FormActionGroup.propTypes = {
onCancel: PropTypes.func.isRequired,
onRevert: PropTypes.func,
onSubmit: PropTypes.func.isRequired,
submitDisabled: PropTypes.bool,
};
FormActionGroup.defaultProps = {
onRevert: null,
submitDisabled: false,
};

View File

@ -74,6 +74,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
/>
@ -86,6 +87,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@ -138,6 +140,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
/>
@ -150,6 +153,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@ -225,6 +229,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
/>
@ -237,6 +242,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@ -447,6 +453,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
>
@ -463,9 +470,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-htpNat",
"componentId": "sc-bxivhb",
"isStatic": false,
"lastClassName": "iYJcPm",
"lastClassName": "gQwVdc",
"rules": Array [
"
font-weight: var(--pf-global--FontWeight--bold);
@ -478,7 +485,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-htpNat",
"styledComponentId": "sc-bxivhb",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -489,18 +496,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
>
<dt
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
data-cy={null}
data-pf-content={true}
>
@ -523,9 +530,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-bxivhb",
"componentId": "sc-ifAKCX",
"isStatic": false,
"lastClassName": "gxmPlV",
"lastClassName": "boHWLt",
"rules": Array [
"
word-break: break-all;
@ -541,7 +548,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-bxivhb",
"styledComponentId": "sc-ifAKCX",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -552,18 +559,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
>
<dd
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
data-cy={null}
data-pf-content={true}
>
@ -670,6 +677,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@ -703,9 +711,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-htpNat",
"componentId": "sc-bxivhb",
"isStatic": false,
"lastClassName": "iYJcPm",
"lastClassName": "gQwVdc",
"rules": Array [
"
font-weight: var(--pf-global--FontWeight--bold);
@ -718,7 +726,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-htpNat",
"styledComponentId": "sc-bxivhb",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -729,18 +737,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
>
<dt
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
data-cy={null}
data-pf-content={true}
>
@ -763,9 +771,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-bxivhb",
"componentId": "sc-ifAKCX",
"isStatic": false,
"lastClassName": "gxmPlV",
"lastClassName": "boHWLt",
"rules": Array [
"
word-break: break-all;
@ -781,7 +789,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-bxivhb",
"styledComponentId": "sc-ifAKCX",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -792,18 +800,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
>
<dd
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
data-cy={null}
data-pf-content={true}
>

View File

@ -32,11 +32,12 @@ function RoutedTabs(props) {
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
{tabsArray.map(tab => (
<Tab
aria-label={`${tab.name}`}
aria-label={typeof tab.name === 'string' ? tab.name : ''}
eventKey={tab.id}
key={tab.id}
link={tab.link}
title={<TabTitleText>{tab.name}</TabTitleText>}
role="tab"
/>
))}
</Tabs>

View File

@ -0,0 +1,6 @@
import React, { useContext } from 'react';
export const SettingsContext = React.createContext({});
export const SettingsProvider = SettingsContext.Provider;
export const useSettings = () => useContext(SettingsContext);

View File

@ -1,25 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import ActivityStreamDetail from './ActivityStreamDetail';
import ActivityStreamEdit from './ActivityStreamEdit';
function ActivityStream({ i18n }) {
const baseUrl = '/settings/activity_stream';
const baseURL = '/settings/activity_stream';
return (
<PageSection>
<Card>
{i18n._(t`Activity stream settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<ActivityStreamDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<ActivityStreamEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Activity Stream settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,59 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ActivityStream from './ActivityStream';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
ACTIVITY_STREAM_ENABLED: true,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: false,
},
});
describe('<ActivityStream />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<ActivityStream />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('Activity stream settings');
test('should render activity stream details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/activity_stream/details'],
});
await act(async () => {
wrapper = mountWithContexts(<ActivityStream />, {
context: { router: { history } },
});
});
expect(wrapper.find('ActivityStreamDetail').length).toBe(1);
});
test('should render activity stream edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/activity_stream/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<ActivityStream />, {
context: { router: { history } },
});
});
expect(wrapper.find('ActivityStreamEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/activity_stream/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<ActivityStream />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,99 @@
import React from 'react';
import React, { useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs';
import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import SettingDetail from '../../shared';
function ActivityStreamDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: activityStream } = useRequest(
useCallback(async () => {
const {
data: {
ACTIVITY_STREAM_ENABLED,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
},
} = await SettingsAPI.readCategory('system');
return {
ACTIVITY_STREAM_ENABLED,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
};
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/activity_stream/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/activity_stream/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && activityStream && (
<DetailList>
{Object.keys(activityStream).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={activityStream?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/activity_stream/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,88 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import { assertDetail } from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import ActivityStreamDetail from './ActivityStreamDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
ACTIVITY_STREAM_ENABLED: true,
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: false,
},
});
describe('<ActivityStreamDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<ActivityStreamDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<ActivityStreamDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('ActivityStreamDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'Enable Activity Stream', 'On');
assertDetail(wrapper, 'Enable Activity Stream for Inventory Sync', 'Off');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<ActivityStreamDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<ActivityStreamDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import AzureADDetail from './AzureADDetail';
import AzureADEdit from './AzureADEdit';
function AzureAD({ i18n }) {
const baseUrl = '/settings/azure';
const baseURL = '/settings/azure';
return (
<PageSection>
<Card>
{i18n._(t`Azure AD settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<AzureADDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<AzureADEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Azure AD settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,56 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import AzureAD from './AzureAD';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<AzureAD />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<AzureAD />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('Azure AD settings');
test('should render azure details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/azure/details'],
});
await act(async () => {
wrapper = mountWithContexts(<AzureAD />, {
context: { router: { history } },
});
});
expect(wrapper.find('AzureADDetail').length).toBe(1);
});
test('should render azure edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/azure/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<AzureAD />, {
context: { router: { history } },
});
});
expect(wrapper.find('AzureADEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/azure/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<AzureAD />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,91 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
import SettingDetail from '../../shared';
function AzureADDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: azure } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('azuread-oauth2');
return data;
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/azure/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/azure/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && azure && (
<DetailList>
{Object.keys(azure).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={azure?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/azure/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,110 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import AzureADDetail from './AzureADDetail';
jest.mock('../../../../api/models/Settings');
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': {
users: [],
},
},
},
});
describe('<AzureADDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<AzureADDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<AzureADDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('AzureADDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(
wrapper,
'Azure AD OAuth2 Callback URL',
'https://towerhost/sso/complete/azuread-oauth2/'
);
assertDetail(wrapper, 'Azure AD OAuth2 Key', 'mock key');
assertDetail(wrapper, 'Azure AD OAuth2 Secret', 'Encrypted');
assertVariableDetail(wrapper, 'Azure AD OAuth2 Organization Map', '{}');
assertVariableDetail(
wrapper,
'Azure AD OAuth2 Team Map',
'{\n "My Team": {\n "users": []\n }\n}'
);
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<AzureADDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<AzureADDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,26 +1,44 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import GitHubDetail from './GitHubDetail';
import GitHubEdit from './GitHubEdit';
function GitHub({ i18n }) {
const baseUrl = '/settings/github';
const baseURL = '/settings/github';
const baseRoute = useRouteMatch({ path: '/settings/github', exact: true });
const categoryRoute = useRouteMatch({
path: '/settings/github/:category',
exact: true,
});
return (
<PageSection>
<Card>
{i18n._(t`GitHub settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
{baseRoute && <Redirect to={`${baseURL}/default/details`} exact />}
{categoryRoute && (
<Redirect
to={`${baseURL}/${categoryRoute.params.category}/details`}
exact
/>
)}
<Route path={`${baseURL}/:category/details`}>
<GitHubDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/:category/edit`}>
<GitHubEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/default/details`}>
{i18n._(t`View GitHub Settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,61 @@
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 GitHub from './GitHub';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<GitHub />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<GitHub />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('GitHub settings');
test('should render github details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/github/'],
});
await act(async () => {
wrapper = mountWithContexts(<GitHub />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubDetail').length).toBe(1);
});
test('should render github edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/github/default/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<GitHub />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/github/foo/bar'],
});
await act(async () => {
wrapper = mountWithContexts(<GitHub />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,127 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useEffect, useCallback } from 'react';
import { Link, Redirect, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
import SettingDetail from '../../shared';
function GitHubDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const baseURL = '/settings/github';
const {
path,
params: { category },
} = useRouteMatch(`${baseURL}/:category/details`);
const { isLoading, error, request, result: gitHubDetails } = useRequest(
useCallback(async () => {
const [
{ data: gitHubDefault },
{ data: gitHubOrganization },
{ data: gitHubTeam },
] = await Promise.all([
SettingsAPI.readCategory('github'),
SettingsAPI.readCategory('github-org'),
SettingsAPI.readCategory('github-team'),
]);
return {
default: gitHubDefault,
organization: gitHubOrganization,
team: gitHubTeam,
};
}, []),
{
default: null,
organization: null,
team: null,
}
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`GitHub Default`),
link: `${baseURL}/default/details`,
id: 0,
},
{
name: i18n._(t`GitHub Organization`),
link: `${baseURL}/organization/details`,
id: 1,
},
{
name: i18n._(t`GitHub Team`),
link: `${baseURL}/team/details`,
id: 2,
},
];
if (!Object.keys(gitHubDetails).includes(category)) {
return <Redirect from={path} to={`${baseURL}/default/details`} exact />;
}
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/github/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && !Object.values(gitHubDetails)?.includes(null) && (
<DetailList>
{Object.keys(gitHubDetails[category]).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={gitHubDetails[category][key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to={`${baseURL}/${category}/edit`}
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,257 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { useRouteMatch } from 'react-router-dom';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import GitHubDetail from './GitHubDetail';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: jest.fn(),
}));
jest.mock('../../../../api/models/Settings');
const mockDefault = {
data: {
SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://towerhost/sso/complete/github/',
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
},
};
const mockOrg = {
data: {
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
'https://towerhost/sso/complete/github-org/',
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
},
};
const mockTeam = {
data: {
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
'https://towerhost/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: {},
},
};
describe('<GitHubDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<GitHubDetail />);
describe('Default', () => {
let wrapper;
beforeAll(async () => {
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/default/details',
path: '/settings/github/:category/details',
params: { category: 'default' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('GitHubDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = [
'Back to Settings',
'GitHub Default',
'GitHub Organization',
'GitHub Team',
];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(
wrapper,
'GitHub OAuth2 Callback URL',
'https://towerhost/sso/complete/github/'
);
assertDetail(wrapper, 'GitHub OAuth2 Key', 'mock github key');
assertDetail(wrapper, 'GitHub OAuth2 Secret', 'Encrypted');
assertVariableDetail(wrapper, 'GitHub OAuth2 Organization Map', '{}');
assertVariableDetail(wrapper, 'GitHub OAuth2 Team Map', '{}');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});
afterEach(() => {
wrapper.unmount();
describe('Organization', () => {
let wrapper;
beforeAll(async () => {
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/organization/details',
path: '/settings/github/:category/details',
params: { category: 'organization' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render expected details', () => {
assertDetail(
wrapper,
'GitHub Organization OAuth2 Callback URL',
'https://towerhost/sso/complete/github-org/'
);
assertDetail(wrapper, 'GitHub Organization OAuth2 Key', 'Not configured');
assertDetail(wrapper, 'GitHub Organization OAuth2 Secret', 'Encrypted');
assertDetail(wrapper, 'GitHub Organization Name', 'Not configured');
assertVariableDetail(
wrapper,
'GitHub Organization OAuth2 Organization Map',
'{}'
);
assertVariableDetail(
wrapper,
'GitHub Organization OAuth2 Team Map',
'{}'
);
});
});
test('initially renders without crashing', () => {
expect(wrapper.find('GitHubDetail').length).toBe(1);
describe('Team', () => {
let wrapper;
beforeAll(async () => {
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/team/details',
path: '/settings/github/:category/details',
params: { category: 'team' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render expected details', () => {
assertDetail(
wrapper,
'GitHub Team OAuth2 Callback URL',
'https://towerhost/sso/complete/github-team/'
);
assertDetail(wrapper, 'GitHub Team OAuth2 Key', 'OAuth2 key (Client ID)');
assertDetail(wrapper, 'GitHub Team OAuth2 Secret', 'Encrypted');
assertDetail(wrapper, 'GitHub Team ID', 'team_id');
assertVariableDetail(
wrapper,
'GitHub Team OAuth2 Organization Map',
'{}'
);
assertVariableDetail(wrapper, 'GitHub Team OAuth2 Team Map', '{}');
});
});
describe('Redirect', () => {
test('should render redirect when user navigates to erroneous category', async () => {
let wrapper;
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/foo/details',
path: '/settings/github/:category/details',
params: { category: 'foo' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GitHubDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'Redirect');
});
});
});

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import GoogleOAuth2Detail from './GoogleOAuth2Detail';
import GoogleOAuth2Edit from './GoogleOAuth2Edit';
function GoogleOAuth2({ i18n }) {
const baseUrl = '/settings/google_oauth2';
const baseURL = '/settings/google_oauth2';
return (
<PageSection>
<Card>
{i18n._(t`Google OAuth 2.0 settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<GoogleOAuth2Detail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<GoogleOAuth2Edit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Google OAuth 2.0 settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,57 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import GoogleOAuth2 from './GoogleOAuth2';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<GoogleOAuth2 />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<GoogleOAuth2 />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('Google OAuth 2.0 settings');
test('should render Google OAuth 2.0 details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/google_oauth2/details'],
});
await act(async () => {
wrapper = mountWithContexts(<GoogleOAuth2 />, {
context: { router: { history } },
});
});
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
});
test('should render Google OAuth 2.0 edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/google_oauth2/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<GoogleOAuth2 />, {
context: { router: { history } },
});
});
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/google_oauth2/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<GoogleOAuth2 />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,91 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
function GoogleOAuth2Detail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: googleOAuth2 } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('google-oauth2');
return data;
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/google_oauth2/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/google_oauth2/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && googleOAuth2 && (
<DetailList>
{Object.keys(googleOAuth2).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={googleOAuth2?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/google_oauth2/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,119 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import GoogleOAuth2Detail from './GoogleOAuth2Detail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
'https://towerhost/sso/complete/google-oauth2/',
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
'example.com',
'example_2.com',
],
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
Default: {},
},
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
},
});
describe('<GoogleOAuth2Detail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<GoogleOAuth2Detail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GoogleOAuth2Detail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(
wrapper,
'Google OAuth2 Callback URL',
'https://towerhost/sso/complete/google-oauth2/'
);
assertDetail(wrapper, 'Google OAuth2 Key', 'mock key');
assertDetail(wrapper, 'Google OAuth2 Secret', 'Encrypted');
assertVariableDetail(
wrapper,
'Google OAuth2 Allowed Domains',
'[\n "example.com",\n "example_2.com"\n]'
);
assertVariableDetail(wrapper, 'Google OAuth2 Extra Arguments', '{}');
assertVariableDetail(
wrapper,
'Google OAuth2 Organization Map',
'{\n "Default": {}\n}'
);
assertVariableDetail(wrapper, 'Google OAuth2 Team Map', '{}');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GoogleOAuth2Detail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<GoogleOAuth2Detail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import JobsDetail from './JobsDetail';
import JobsEdit from './JobsEdit';
function Jobs({ i18n }) {
const baseUrl = '/settings/jobs';
const baseURL = '/settings/jobs';
return (
<PageSection>
<Card>
{i18n._(t`Jobs settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<JobsDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<JobsEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Jobs settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,57 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import Jobs from './Jobs';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<Jobs />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<Jobs />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('Jobs settings');
test('should render jobs details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/jobs/details'],
});
await act(async () => {
wrapper = mountWithContexts(<Jobs />, {
context: { router: { history } },
});
});
expect(wrapper.find('JobsDetail').length).toBe(1);
});
test('should render jobs edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/jobs/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<Jobs />, {
context: { router: { history } },
});
});
expect(wrapper.find('JobsEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/jobs/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<Jobs />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,108 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs';
import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import { sortNestedDetails } from '../../shared/settingUtils';
import SettingDetail from '../../shared';
function JobsDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: jobs } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('jobs');
const {
ALLOW_JINJA_IN_EXTRA_VARS,
AWX_ISOLATED_KEY_GENERATION,
AWX_ISOLATED_PRIVATE_KEY,
AWX_ISOLATED_PUBLIC_KEY,
STDOUT_MAX_BYTES_DISPLAY,
EVENT_STDOUT_MAX_BYTES_DISPLAY,
...jobsData
} = data;
const mergedData = {};
Object.keys(jobsData).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = jobsData[key];
});
return sortNestedDetails(mergedData);
}, [options]),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/jobs/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/jobs/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && jobs && (
<DetailList>
{jobs.map(([key, detail]) => {
return (
<SettingDetail
key={key}
id={key}
helpText={detail?.help_text}
label={detail?.label}
type={detail?.type}
unit={detail?.unit}
value={detail?.value}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/jobs/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,122 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import mockJobSettings from '../../shared/data.jobSettings.json';
import JobsDetail from './JobsDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: mockJobSettings,
});
describe('<JobsDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<JobsDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<JobsDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('JobsDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'Enable job isolation', 'On');
assertDetail(wrapper, 'Job execution path', '/tmp');
assertDetail(wrapper, 'Isolated status check interval', '1 seconds');
assertDetail(wrapper, 'Isolated launch timeout', '600 seconds');
assertDetail(wrapper, 'Isolated connection timeout', '10 seconds');
assertDetail(wrapper, 'Isolated host key checking', 'Off');
assertDetail(
wrapper,
'Enable detailed resource profiling on all playbook runs',
'Off'
);
assertDetail(wrapper, 'Run Project Updates With Higher Verbosity', 'Off');
assertDetail(wrapper, 'Enable Role Download', 'On');
assertDetail(wrapper, 'Enable Collection(s) Download', 'On');
assertDetail(wrapper, 'Follow symlinks', 'Off');
assertDetail(
wrapper,
'Ignore Ansible Galaxy SSL Certificate Verification',
'Off'
);
assertDetail(wrapper, 'Maximum Scheduled Jobs', '10');
assertDetail(wrapper, 'Default Job Timeout', '0 seconds');
assertDetail(wrapper, 'Default Inventory Update Timeout', '0 seconds');
assertDetail(wrapper, 'Default Project Update Timeout', '0 seconds');
assertDetail(wrapper, 'Per-Host Ansible Fact Cache Timeout', '0 seconds');
assertDetail(wrapper, 'Maximum number of forks per job', '200');
assertVariableDetail(
wrapper,
'Ansible Modules Allowed for Ad Hoc Jobs',
'[\n "command"\n]'
);
assertVariableDetail(wrapper, 'Paths to hide from isolated jobs', '[]');
assertVariableDetail(wrapper, 'Paths to expose to isolated jobs', '[]');
assertVariableDetail(wrapper, 'Extra Environment Variables', '{}');
assertVariableDetail(wrapper, 'Ansible Callback Plugins', '[]');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<JobsDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<JobsDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,26 +1,44 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import LDAPDetail from './LDAPDetail';
import LDAPEdit from './LDAPEdit';
function LDAP({ i18n }) {
const baseUrl = '/settings/ldap';
const baseURL = '/settings/ldap';
const baseRoute = useRouteMatch({ path: '/settings/ldap', exact: true });
const categoryRoute = useRouteMatch({
path: '/settings/ldap/:category',
exact: true,
});
return (
<PageSection>
<Card>
{i18n._(t`LDAP settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
{baseRoute && <Redirect to={`${baseURL}/default/details`} exact />}
{categoryRoute && (
<Redirect
to={`${baseURL}/${categoryRoute.params.category}/details`}
exact
/>
)}
<Route path={`${baseURL}/:category/details`}>
<LDAPDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/:category/edit`}>
<LDAPEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/default/details`}>
{i18n._(t`View LDAP Settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,61 @@
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 LDAP from './LDAP';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<LDAP />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LDAP />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('LDAP settings');
test('should render ldap details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/ldap/'],
});
await act(async () => {
wrapper = mountWithContexts(<LDAP />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('LDAPDetail').length).toBe(1);
});
test('should render ldap edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/ldap/default/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<LDAP />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('LDAPEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/ldap/foo/bar'],
});
await act(async () => {
wrapper = mountWithContexts(<LDAP />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,172 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useEffect, useCallback } from 'react';
import { Link, Redirect, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { sortNestedDetails } from '../../shared/settingUtils';
function filterByPrefix(data, prefix) {
return Object.keys(data)
.filter(key => key.includes(prefix))
.reduce((obj, key) => {
obj[key] = data[key];
return obj;
}, {});
}
function LDAPDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const {
path,
params: { category },
} = useRouteMatch('/settings/ldap/:category/details');
const { isLoading, error, request, result: LDAPDetails } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('ldap');
const mergedData = {};
Object.keys(data).forEach(key => {
if (key.includes('_CONNECTION_OPTIONS')) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
const ldap1 = filterByPrefix(mergedData, 'AUTH_LDAP_1_');
const ldap2 = filterByPrefix(mergedData, 'AUTH_LDAP_2_');
const ldap3 = filterByPrefix(mergedData, 'AUTH_LDAP_3_');
const ldap4 = filterByPrefix(mergedData, 'AUTH_LDAP_4_');
const ldap5 = filterByPrefix(mergedData, 'AUTH_LDAP_5_');
const ldapDefault = Object.assign({}, mergedData);
Object.keys({ ...ldap1, ...ldap2, ...ldap3, ...ldap4, ...ldap5 }).forEach(
keyToOmit => {
delete ldapDefault[keyToOmit];
}
);
return {
default: sortNestedDetails(ldapDefault),
1: sortNestedDetails(ldap1),
2: sortNestedDetails(ldap2),
3: sortNestedDetails(ldap3),
4: sortNestedDetails(ldap4),
5: sortNestedDetails(ldap5),
};
}, [options]),
{
default: null,
1: null,
2: null,
3: null,
4: null,
5: null,
}
);
useEffect(() => {
request();
}, [request]);
const baseURL = '/settings/ldap';
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Default`),
link: `${baseURL}/default/details`,
id: 0,
},
{
name: i18n._(t`LDAP1`),
link: `${baseURL}/1/details`,
id: 1,
},
{
name: i18n._(t`LDAP2`),
link: `${baseURL}/2/details`,
id: 2,
},
{
name: i18n._(t`LDAP3`),
link: `${baseURL}/3/details`,
id: 3,
},
{
name: i18n._(t`LDAP4`),
link: `${baseURL}/4/details`,
id: 4,
},
{
name: i18n._(t`LDAP5`),
link: `${baseURL}/5/details`,
id: 5,
},
];
if (!Object.keys(LDAPDetails).includes(category)) {
return <Redirect from={path} to={`${baseURL}/default/details`} exact />;
}
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/ldap/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
<>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && !Object.values(LDAPDetails)?.includes(null) && (
<DetailList>
{LDAPDetails[category].map(([key, detail]) => {
return (
<SettingDetail
key={key}
id={key}
helpText={detail?.help_text}
label={detail?.label}
type={detail?.type}
unit={detail?.unit}
value={detail?.value}
/>
);
})}
</DetailList>
)}
</>
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to={`${baseURL}/${category}/edit`}
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,151 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { useRouteMatch } from 'react-router-dom';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import mockLDAP from '../../shared/data.ldapSettings.json';
import LDAPDetail from './LDAPDetail';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: jest.fn(),
}));
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP });
describe('<LDAPDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LDAPDetail />);
describe('Default', () => {
let wrapper;
beforeAll(async () => {
useRouteMatch.mockImplementation(() => ({
url: '/settings/ldap/default/details',
path: '/settings/ldap/:category/details',
params: { category: 'default' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAPDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('LDAPDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = [
'Back to Settings',
'Default',
'LDAP1',
'LDAP2',
'LDAP3',
'LDAP4',
'LDAP5',
];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'LDAP Server URI', 'ldap://ldap.example.com');
assertDetail(wrapper, 'LDAP Bind DN', 'cn=eng_user');
assertDetail(wrapper, 'LDAP Bind Password', 'Encrypted');
assertDetail(wrapper, 'LDAP Start TLS', 'Off');
assertDetail(
wrapper,
'LDAP User DN Template',
'uid=%(user)s,OU=Users,DC=example,DC=com'
);
assertDetail(wrapper, 'LDAP Group Type', 'MemberDNGroupType');
assertDetail(
wrapper,
'LDAP Require Group',
'CN=Tower Users,OU=Users,DC=example,DC=com'
);
assertDetail(wrapper, 'LDAP Deny Group', 'Not configured');
assertVariableDetail(wrapper, 'LDAP User Search', '[]');
assertVariableDetail(wrapper, 'LDAP User Attribute Map', '{}');
assertVariableDetail(wrapper, 'LDAP Group Search', '[]');
assertVariableDetail(
wrapper,
'LDAP Group Type Parameters',
'{\n "name_attr": "cn",\n "member_attr": "member"\n}'
);
assertVariableDetail(wrapper, 'LDAP User Flags By Group', '{}');
assertVariableDetail(wrapper, 'LDAP Organization Map', '{}');
assertVariableDetail(wrapper, 'LDAP Team Map', '{}');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAPDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAPDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('LDAPDetail').length).toBe(1);
describe('Redirect', () => {
test('should render redirect when user navigates to erroneous category', async () => {
let wrapper;
useRouteMatch.mockImplementation(() => ({
url: '/settings/ldap/foo/details',
path: '/settings/ldap/:category/details',
params: { category: 'foo' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAPDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'Redirect');
});
});
});

View File

@ -1,26 +1,30 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import LoggingDetail from './LoggingDetail';
import LoggingEdit from './LoggingEdit';
function Logging({ i18n }) {
const baseUrl = '/settings/logging';
const baseURL = '/settings/logging';
return (
<PageSection>
<Card>
{i18n._(t`Logging settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<LoggingDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<LoggingEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}`}>{i18n._(t`View Logging settings`)}</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,61 @@
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 Logging from './Logging';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<Logging />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<Logging />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('Logging settings');
test('should render logging details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/logging/details'],
});
await act(async () => {
wrapper = mountWithContexts(<Logging />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('LoggingDetail').length).toBe(1);
});
test('should render logging edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/logging/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<Logging />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('LoggingEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/logging/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<Logging />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,112 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
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 { sortNestedDetails, pluck } from '../../shared/settingUtils';
function LoggingDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: logging } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('logging');
const loggingData = pluck(
data,
'LOG_AGGREGATOR_ENABLED',
'LOG_AGGREGATOR_HOST',
'LOG_AGGREGATOR_INDIVIDUAL_FACTS',
'LOG_AGGREGATOR_LEVEL',
'LOG_AGGREGATOR_LOGGERS',
'LOG_AGGREGATOR_PASSWORD',
'LOG_AGGREGATOR_PORT',
'LOG_AGGREGATOR_PROTOCOL',
'LOG_AGGREGATOR_TCP_TIMEOUT',
'LOG_AGGREGATOR_TYPE',
'LOG_AGGREGATOR_USERNAME',
'LOG_AGGREGATOR_VERIFY_CERT'
);
const mergedData = {};
Object.keys(loggingData).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = loggingData[key];
});
return sortNestedDetails(mergedData);
}, [options]),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/logging/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/logging/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && logging && (
<DetailList>
{logging.map(([key, detail]) => (
<SettingDetail
key={key}
id={key}
helpText={detail?.help_text}
label={detail?.label}
type={detail?.type}
unit={detail?.unit}
value={detail?.value}
/>
))}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/logging/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,107 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import mockLogSettings from '../../shared/data.logSettings.json';
import LoggingDetail from './LoggingDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: mockLogSettings,
});
describe('<LoggingDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LoggingDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LoggingDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('LoggingDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'Enable External Logging', 'Off');
assertDetail(wrapper, 'Logging Aggregator', 'https://mocklog');
assertDetail(wrapper, 'Logging Aggregator Port', '1234');
assertDetail(wrapper, 'Logging Aggregator Type', 'logstash');
assertDetail(wrapper, 'Logging Aggregator Username', 'logging_name');
assertDetail(wrapper, 'Logging Aggregator Password/Token', 'Encrypted');
assertDetail(wrapper, 'Log System Tracking Facts Individually', 'Off');
assertDetail(wrapper, 'Logging Aggregator Protocol', 'https');
assertDetail(wrapper, 'TCP Connection Timeout', '5 seconds');
assertDetail(wrapper, 'Logging Aggregator Level Threshold', 'INFO');
assertDetail(
wrapper,
'Enable/disable HTTPS certificate verification',
'On'
);
assertVariableDetail(
wrapper,
'Loggers Sending Data to Log Aggregator Form',
'[\n "activity_stream",\n "system_tracking"\n]'
);
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LoggingDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LoggingDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import MiscSystemDetail from './MiscSystemDetail';
import MiscSystemEdit from './MiscSystemEdit';
function MiscSystem({ i18n }) {
const baseUrl = '/settings/miscellaneous_system';
const baseURL = '/settings/miscellaneous_system';
return (
<PageSection>
<Card>
{i18n._(t`Miscellaneous system settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<MiscSystemDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<MiscSystemEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Miscellaneous System settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,18 +1,61 @@
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 MiscSystem from './MiscSystem';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<MiscSystem />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<MiscSystem />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain(
'Miscellaneous system settings'
);
test('should render miscellaneous system details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/miscellaneous_system/details'],
});
await act(async () => {
wrapper = mountWithContexts(<MiscSystem />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('MiscSystemDetail').length).toBe(1);
});
test('should render miscellaneous system edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/miscellaneous_system/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<MiscSystem />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('MiscSystemEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/miscellaneous_system/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<MiscSystem />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,153 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
import { sortNestedDetails, pluck } from '../../shared/settingUtils';
function MiscSystemDetail({ i18n }) {
const { me } = useConfig();
const { GET: allOptions } = useSettings();
const { isLoading, error, request, 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,
...options
} = allOptions;
const systemOptions = {
...options,
ACCESS_TOKEN_EXPIRE_SECONDS: {
...OAUTH2_PROVIDER_OPTIONS,
type: OAUTH2_PROVIDER_OPTIONS.child.type,
label: i18n._(t`Access Token Expiration`),
},
REFRESH_TOKEN_EXPIRE_SECONDS: {
...OAUTH2_PROVIDER_OPTIONS,
type: OAUTH2_PROVIDER_OPTIONS.child.type,
label: i18n._(t`Refresh Token Expiration`),
},
AUTHORIZATION_CODE_EXPIRE_SECONDS: {
...OAUTH2_PROVIDER_OPTIONS,
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 sortNestedDetails(mergedData);
}, [allOptions, i18n]),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/miscellaneous_system/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/miscellaneous_system/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && system && (
<DetailList>
{system.map(([key, detail]) => (
<SettingDetail
key={key}
id={key}
helpText={detail?.help_text}
label={detail?.label}
type={detail?.type}
unit={detail?.unit}
value={detail?.value}
/>
))}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/miscellaneous_system/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,148 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import MiscSystemDetail from './MiscSystemDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: false,
AUTH_BASIC_ENABLED: true,
AUTOMATION_ANALYTICS_GATHER_INTERVAL: 14400,
AUTOMATION_ANALYTICS_URL: 'https://example.com',
CUSTOM_VENV_PATHS: [],
INSIGHTS_TRACKING_STATE: false,
LOGIN_REDIRECT_OVERRIDE: 'https://redirect.com',
MANAGE_ORGANIZATION_AUTH: true,
OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS: 1,
AUTHORIZATION_CODE_EXPIRE_SECONDS: 2,
REFRESH_TOKEN_EXPIRE_SECONDS: 3,
},
ORG_ADMINS_CAN_SEE_ALL_USERS: true,
REDHAT_PASSWORD: '$encrypted$',
REDHAT_USERNAME: 'mock name',
REMOTE_HOST_HEADERS: [],
SESSIONS_PER_USER: -1,
SESSION_COOKIE_AGE: 30000000000,
TOWER_URL_BASE: 'https://towerhost',
},
});
describe('<MiscSystemDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<MiscSystemDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<MiscSystemDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('MiscSystemDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'Access Token Expiration', '1 seconds');
assertDetail(wrapper, 'All Users Visible to Organization Admins', 'On');
assertDetail(
wrapper,
'Allow External Users to Create OAuth2 Tokens',
'Off'
);
assertDetail(wrapper, 'Authorization Code Expiration', '2 seconds');
assertDetail(
wrapper,
'Automation Analytics Gather Interval',
'14400 seconds'
);
assertDetail(
wrapper,
'Automation Analytics upload URL',
'https://example.com'
);
assertDetail(wrapper, 'Base URL of the Tower host', 'https://towerhost');
assertDetail(wrapper, 'Enable HTTP Basic Auth', 'On');
assertDetail(wrapper, 'Gather data for Automation Analytics', 'Off');
assertDetail(wrapper, 'Idle Time Force Log Out', '30000000000 seconds');
assertDetail(
wrapper,
'Login redirect override URL',
'https://redirect.com'
);
assertDetail(
wrapper,
'Maximum number of simultaneous logged in sessions',
'-1'
);
assertDetail(
wrapper,
'Organization Admins Can Manage Users and Teams',
'On'
);
assertDetail(wrapper, 'Red Hat customer password', 'Encrypted');
assertDetail(wrapper, 'Red Hat customer username', 'mock name');
assertDetail(wrapper, 'Refresh Token Expiration', '3 seconds');
assertVariableDetail(wrapper, 'Remote Host Headers', '[]');
assertVariableDetail(wrapper, 'Custom virtual environment paths', '[]');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<MiscSystemDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<MiscSystemDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import RADIUSDetail from './RADIUSDetail';
import RADIUSEdit from './RADIUSEdit';
function RADIUS({ i18n }) {
const baseURL = '/settings/radius';
return (
<PageSection>
<Card>
<Switch>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<RADIUSDetail />
</Route>
<Route path={`${baseURL}/edit`}>
<RADIUSEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View RADIUS settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(RADIUS);

View File

@ -0,0 +1,56 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import RADIUS from './RADIUS';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<RADIUS />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render RADIUS details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/radius/details'],
});
await act(async () => {
wrapper = mountWithContexts(<RADIUS />, {
context: { router: { history } },
});
});
expect(wrapper.find('RADIUSDetail').length).toBe(1);
});
test('should render RADIUS edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/radius/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<RADIUS />, {
context: { router: { history } },
});
});
expect(wrapper.find('RADIUSEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/radius/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<RADIUS />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -0,0 +1,92 @@
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
function RADIUSDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: radius } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('radius');
return data;
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/radius/details`,
id: 0,
},
];
return (
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && radius && (
<DetailList>
{Object.keys(radius).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={radius?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/radius/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}
export default withI18n()(RADIUSDetail);

View File

@ -0,0 +1,90 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import { assertDetail } from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import RADIUSDetail from './RADIUSDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
RADIUS_SERVER: 'example.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '$encrypted$',
},
});
describe('<RADIUSDetail />', () => {
let wrapper;
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('RADIUSDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'RADIUS Server', 'example.org');
assertDetail(wrapper, 'RADIUS Port', '1812');
assertDetail(wrapper, 'RADIUS Secret', 'Encrypted');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './RADIUSDetail';

View File

@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
function RadiusEdit({ i18n }) {
function RADIUSEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
@ -22,4 +22,4 @@ function RadiusEdit({ i18n }) {
);
}
export default withI18n()(RadiusEdit);
export default withI18n()(RADIUSEdit);

View File

@ -1,16 +1,16 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import RadiusEdit from './RadiusEdit';
import RADIUSEdit from './RADIUSEdit';
describe('<RadiusEdit />', () => {
describe('<RADIUSEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<RadiusEdit />);
wrapper = mountWithContexts(<RADIUSEdit />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('RadiusEdit').length).toBe(1);
expect(wrapper.find('RADIUSEdit').length).toBe(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './RADIUSEdit';

View File

@ -0,0 +1 @@
export { default } from './RADIUS';

View File

@ -1,30 +0,0 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import RadiusDetail from './RadiusDetail';
import RadiusEdit from './RadiusEdit';
function Radius({ i18n }) {
const baseUrl = '/settings/radius';
return (
<PageSection>
<Card>
{i18n._(t`Radius settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<RadiusDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<RadiusEdit />
</Route>
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(Radius);

View File

@ -1,16 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import Radius from './Radius';
describe('<Radius />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<Radius />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('Radius settings');
});
});

View File

@ -1,25 +0,0 @@
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';
function RadiusDetail({ i18n }) {
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/radius/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
);
}
export default withI18n()(RadiusDetail);

View File

@ -1,16 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import RadiusDetail from './RadiusDetail';
describe('<RadiusDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<RadiusDetail />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(wrapper.find('RadiusDetail').length).toBe(1);
});
});

View File

@ -1 +0,0 @@
export { default } from './RadiusDetail';

View File

@ -1 +0,0 @@
export { default } from './RadiusEdit';

View File

@ -1 +0,0 @@
export { default } from './Radius';

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import SAMLDetail from './SAMLDetail';
import SAMLEdit from './SAMLEdit';
function SAML({ i18n }) {
const baseUrl = '/settings/saml';
const baseURL = '/settings/saml';
return (
<PageSection>
<Card>
{i18n._(t`SAML settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<SAMLDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<SAMLEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View SAML settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,56 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import SAML from './SAML';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<SAML />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<SAML />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('SAML settings');
test('should render SAML details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/saml/details'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
});
expect(wrapper.find('SAMLDetail').length).toBe(1);
});
test('should render SAML edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/saml/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
});
expect(wrapper.find('SAMLEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/saml/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,91 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
function SAMLDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: saml } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('saml');
return data;
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/saml/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/saml/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && saml && (
<DetailList>
{Object.keys(saml).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={saml?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/saml/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,148 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import {
assertDetail,
assertVariableDetail,
} from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import SAMLDetail from './SAMLDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/',
SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/',
SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '',
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {},
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
},
});
describe('<SAMLDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<SAMLDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAMLDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('SAMLDetail').length).toBe(1);
});
test('should render expected details', () => {
assertDetail(
wrapper,
'SAML Assertion Consumer Service (ACS) URL',
'https://towerhost/sso/complete/saml/'
);
assertDetail(
wrapper,
'SAML Service Provider Metadata URL',
'https://towerhost/sso/metadata/saml/'
);
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
assertDetail(
wrapper,
'SAML Service Provider Public Certificate',
'mock_cert'
);
assertDetail(
wrapper,
'SAML Service Provider Private Key',
'Not configured'
);
assertVariableDetail(
wrapper,
'SAML Service Provider Organization Info',
'{}'
);
assertVariableDetail(
wrapper,
'SAML Service Provider Technical Contact',
'{}'
);
assertVariableDetail(
wrapper,
'SAML Service Provider Support Contact',
'{}'
);
assertVariableDetail(wrapper, 'SAML Enabled Identity Providers', '{}');
assertVariableDetail(wrapper, 'SAML Security Config', '{}');
assertVariableDetail(
wrapper,
'SAML Service Provider extra configuration data',
'{}'
);
assertVariableDetail(
wrapper,
'SAML IDP to extra_data attribute mapping',
'[]'
);
assertVariableDetail(wrapper, 'SAML Organization Map', '{}');
assertVariableDetail(wrapper, 'SAML Team Map', '{}');
assertVariableDetail(wrapper, 'SAML Organization Attribute Mapping', '{}');
assertVariableDetail(wrapper, 'SAML Team Attribute Mapping', '{}');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAMLDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAMLDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -75,7 +75,7 @@ function SettingList({ i18n }) {
path: '/settings/ldap',
},
{
title: i18n._(t`Radius settings`),
title: i18n._(t`RADIUS settings`),
path: '/settings/radius',
},
{
@ -107,11 +107,11 @@ function SettingList({ i18n }) {
id: 'system',
routes: [
{
title: i18n._(t`Miscellaneous system settings`),
title: i18n._(t`Miscellaneous System settings`),
path: '/settings/miscellaneous_system',
},
{
title: i18n._(t`Activity stream settings`),
title: i18n._(t`Activity Stream settings`),
path: '/settings/activity_stream',
},
{
@ -121,15 +121,15 @@ function SettingList({ i18n }) {
],
},
{
header: i18n._(t`User interface`),
header: i18n._(t`User Interface`),
description: i18n._(
t`Set preferences for data collection, logos, and logins`
),
id: 'user_interface',
id: 'ui',
routes: [
{
title: i18n._(t`User interface settings`),
path: '/settings/user_interface',
title: i18n._(t`User Interface settings`),
path: '/settings/ui',
},
],
},

View File

@ -1,9 +1,10 @@
import React from 'react';
import React, { useCallback, useEffect } from 'react';
import { Link, Route, Switch, Redirect } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import Breadcrumbs from '../../components/Breadcrumbs';
import ActivityStream from './ActivityStream';
import AzureAD from './AzureAD';
@ -14,34 +15,120 @@ import LDAP from './LDAP';
import License from './License';
import Logging from './Logging';
import MiscSystem from './MiscSystem';
import Radius from './Radius';
import RADIUS from './RADIUS';
import SAML from './SAML';
import SettingList from './SettingList';
import TACACS from './TACACS';
import UI from './UI';
import { SettingsProvider } from '../../contexts/Settings';
import { useConfig } from '../../contexts/Config';
import { SettingsAPI } from '../../api';
import useRequest from '../../util/useRequest';
function Settings({ i18n }) {
const { license_info = {} } = useConfig();
const { license_info = {}, me } = useConfig();
const { request, result, isLoading, error } = useRequest(
useCallback(async () => {
const response = await SettingsAPI.readAllOptions();
return response.data.actions;
}, [])
);
useEffect(() => {
request();
}, [request]);
const breadcrumbConfig = {
'/settings': i18n._(t`Settings`),
'/settings/activity_stream': i18n._(t`Activity stream`),
'/settings/activity_stream': i18n._(t`Activity Stream`),
'/settings/activity_stream/details': i18n._(t`Details`),
'/settings/activity_stream/edit': i18n._(t`Edit Details`),
'/settings/azure': i18n._(t`Azure AD`),
'/settings/github': i18n._(t`GitHub`),
'/settings/azure/details': i18n._(t`Details`),
'/settings/azure/edit': i18n._(t`Edit Details`),
'/settings/github': null,
'/settings/github/default': i18n._(t`GitHub Default`),
'/settings/github/default/details': i18n._(t`Details`),
'/settings/github/default/edit': i18n._(t`Edit Details`),
'/settings/github/organization': i18n._(t`GitHub Organization`),
'/settings/github/organization/details': i18n._(t`Details`),
'/settings/github/organization/edit': i18n._(t`Edit Details`),
'/settings/github/team': i18n._(t`GitHub Team`),
'/settings/github/team/details': i18n._(t`Details`),
'/settings/github/team/edit': i18n._(t`Edit Details`),
'/settings/google_oauth2': i18n._(t`Google OAuth2`),
'/settings/google_oauth2/details': i18n._(t`Details`),
'/settings/google_oauth2/edit': i18n._(t`Edit Details`),
'/settings/jobs': i18n._(t`Jobs`),
'/settings/ldap': i18n._(t`LDAP`),
'/settings/jobs/details': i18n._(t`Details`),
'/settings/jobs/edit': i18n._(t`Edit Details`),
'/settings/ldap': null,
'/settings/ldap/default': i18n._(t`LDAP Default`),
'/settings/ldap/1': i18n._(t`LDAP 1`),
'/settings/ldap/2': i18n._(t`LDAP 2`),
'/settings/ldap/3': i18n._(t`LDAP 3`),
'/settings/ldap/4': i18n._(t`LDAP 4`),
'/settings/ldap/5': i18n._(t`LDAP 5`),
'/settings/ldap/default/details': i18n._(t`Details`),
'/settings/ldap/1/details': i18n._(t`Details`),
'/settings/ldap/2/details': i18n._(t`Details`),
'/settings/ldap/3/details': i18n._(t`Details`),
'/settings/ldap/4/details': i18n._(t`Details`),
'/settings/ldap/5/details': i18n._(t`Details`),
'/settings/ldap/default/edit': i18n._(t`Edit Details`),
'/settings/ldap/1/edit': i18n._(t`Edit Details`),
'/settings/ldap/2/edit': i18n._(t`Edit Details`),
'/settings/ldap/3/edit': i18n._(t`Edit Details`),
'/settings/ldap/4/edit': i18n._(t`Edit Details`),
'/settings/ldap/5/edit': i18n._(t`Edit Details`),
'/settings/license': i18n._(t`License`),
'/settings/logging': i18n._(t`Logging`),
'/settings/miscellaneous_system': i18n._(t`Miscellaneous system`),
'/settings/radius': i18n._(t`Radius`),
'/settings/logging/details': i18n._(t`Details`),
'/settings/logging/edit': i18n._(t`Edit Details`),
'/settings/miscellaneous_system': i18n._(t`Miscellaneous System`),
'/settings/miscellaneous_system/details': i18n._(t`Details`),
'/settings/miscellaneous_system/edit': i18n._(t`Edit Details`),
'/settings/radius': i18n._(t`RADIUS`),
'/settings/radius/details': i18n._(t`Details`),
'/settings/radius/edit': i18n._(t`Edit Details`),
'/settings/saml': i18n._(t`SAML`),
'/settings/saml/details': i18n._(t`Details`),
'/settings/saml/edit': i18n._(t`Edit Details`),
'/settings/tacacs': i18n._(t`TACACS+`),
'/settings/user_interface': i18n._(t`User interface`),
'/settings/tacacs/details': i18n._(t`Details`),
'/settings/tacacs/edit': i18n._(t`Edit Details`),
'/settings/ui': i18n._(t`User Interface`),
'/settings/ui/details': i18n._(t`Details`),
'/settings/ui/edit': i18n._(t`Edit Details`),
};
if (error) {
return (
<PageSection>
<Card>
<ContentError error={error} />
</Card>
</PageSection>
);
}
if (isLoading || !result || !me) {
return (
<PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
}
if (!me?.is_superuser && !me?.is_system_auditor) {
return <Redirect to="/" />;
}
return (
<>
<SettingsProvider value={result}>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/settings/activity_stream">
@ -76,7 +163,7 @@ function Settings({ i18n }) {
<MiscSystem />
</Route>
<Route path="/settings/radius">
<Radius />
<RADIUS />
</Route>
<Route path="/settings/saml">
<SAML />
@ -84,7 +171,7 @@ function Settings({ i18n }) {
<Route path="/settings/tacacs">
<TACACS />
</Route>
<Route path="/settings/user_interface">
<Route path="/settings/ui">
<UI />
</Route>
<Route path="/settings" exact>
@ -100,7 +187,7 @@ function Settings({ i18n }) {
</PageSection>
</Route>
</Switch>
</>
</SettingsProvider>
);
}

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import TACACSDetail from './TACACSDetail';
import TACACSEdit from './TACACSEdit';
function TACACS({ i18n }) {
const baseUrl = '/settings/tacacs';
const baseURL = '/settings/tacacs';
return (
<PageSection>
<Card>
{i18n._(t`TACACS+ settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<TACACSDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<TACACSEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View TACACS+ settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,56 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import TACACS from './TACACS';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<TACACS />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<TACACS />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('TACACS+ settings');
test('should render TACACS+ details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/tacacs/details'],
});
await act(async () => {
wrapper = mountWithContexts(<TACACS />, {
context: { router: { history } },
});
});
expect(wrapper.find('TACACSDetail').length).toBe(1);
});
test('should render TACACS+ edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/tacacs/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<TACACS />, {
context: { router: { history } },
});
});
expect(wrapper.find('TACACSEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/tacacs/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<TACACS />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,91 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import SettingDetail from '../../shared';
function TACACSDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: tacacs } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('tacacsplus');
return data;
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/tacacs/details`,
id: 0,
},
];
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/tacacs/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && tacacs && (
<DetailList>
{Object.keys(tacacs).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={tacacs?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/tacacs/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,94 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import { assertDetail } from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import TACACSDetail from './TACACSDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
TACACSPLUS_HOST: 'mockhost',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
},
});
describe('<TACACSDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<TACACSDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('TACACSDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'TACACS+ Server', 'mockhost');
assertDetail(wrapper, 'TACACS+ Port', '49');
assertDetail(wrapper, 'TACACS+ Secret', 'Encrypted');
assertDetail(wrapper, 'TACACS+ Auth Session Timeout', '5 seconds');
assertDetail(wrapper, 'TACACS+ Authentication Protocol', 'ascii');
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,26 +1,32 @@
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import UIDetail from './UIDetail';
import UIEdit from './UIEdit';
function UI({ i18n }) {
const baseUrl = '/settings/ui';
const baseURL = '/settings/ui';
return (
<PageSection>
<Card>
{i18n._(t`User interface settings`)}
<Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
<Route path={`${baseUrl}/details`}>
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseURL}/details`}>
<UIDetail />
</Route>
<Route path={`${baseUrl}/edit`}>
<Route path={`${baseURL}/edit`}>
<UIEdit />
</Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View User Interface settings`)}
</Link>
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>

View File

@ -1,16 +1,61 @@
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 UI from './UI';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<UI />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<UI />);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain('User interface settings');
test('should render user interface details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/ui/details'],
});
await act(async () => {
wrapper = mountWithContexts(<UI />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('UIDetail').length).toBe(1);
});
test('should render user interface edit', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/ui/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<UI />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('UIEdit').length).toBe(1);
});
test('should show content error when user navigates to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/ui/foo'],
});
await act(async () => {
wrapper = mountWithContexts(<UI />, {
context: { router: { history } },
});
});
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -1,24 +1,106 @@
import React from 'react';
import React, { useEffect, useCallback } 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 { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError';
import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest';
import { DetailList } from '../../../../components/DetailList';
import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings';
import { pluck } from '../../shared/settingUtils';
import SettingDetail from '../../shared';
function UIDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
const { isLoading, error, request, result: ui } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('ui');
const uiData = pluck(
data,
'PENDO_TRACKING_STATE',
'CUSTOM_LOGO',
'CUSTOM_LOGIN_INFO'
);
return uiData;
}, []),
null
);
useEffect(() => {
request();
}, [request]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Settings`)}
</>
),
link: `/settings`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `/settings/ui/details`,
id: 0,
},
];
// Change CUSTOM_LOGO type from string to image
// to help SettingDetail render it as an <img>
if (options?.CUSTOM_LOGO) {
options.CUSTOM_LOGO.type = 'image';
}
return (
<CardBody>
{i18n._(t`Detail coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/ui/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
</CardBody>
<>
<RoutedTabs tabsArray={tabsArray} />
<CardBody>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && ui && (
<DetailList>
{Object.keys(ui).map(key => {
const record = options?.[key];
return (
<SettingDetail
key={key}
id={key}
helpText={record?.help_text}
label={record?.label}
type={record?.type}
unit={record?.unit}
value={ui?.[key]}
/>
);
})}
</DetailList>
)}
{me?.is_superuser && (
<CardActionsRow>
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/ui/edit"
>
{i18n._(t`Edit`)}
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);
}

View File

@ -1,16 +1,93 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import { assertDetail } from '../../shared/settingTestUtils';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import UIDetail from './UIDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {
CUSTOM_LOGIN_INFO: 'mock info',
CUSTOM_LOGO: 'data:image/png',
PENDO_TRACKING_STATE: 'off',
},
});
describe('<UIDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<UIDetail />);
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UIDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('UIDetail').length).toBe(1);
});
test('should render expected tabs', () => {
const expectedTabs = ['Back to Settings', 'Details'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should render expected details', () => {
assertDetail(wrapper, 'User Analytics Tracking State', 'off');
assertDetail(wrapper, 'Custom Login Info', 'mock info');
expect(wrapper.find('Detail[label="Custom Logo"] dt').text()).toBe(
'Custom Logo'
);
expect(wrapper.find('Detail[label="Custom Logo"] dd img').length).toBe(1);
});
test('should hide edit button from non-superusers', async () => {
const config = {
me: {
is_superuser: false,
},
};
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UIDetail />
</SettingsProvider>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
});
test('should display content error when api throws error on initial render', async () => {
SettingsAPI.readCategory.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UIDetail />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -0,0 +1,117 @@
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 DetailPopover from '../../../components/DetailList/DetailPopover';
export default withI18n()(
({ i18n, helpText, id, label, type, unit = '', value }) => {
const dataType = value === '$encrypted$' ? 'encrypted' : type;
let detail = null;
switch (dataType) {
case 'nested object':
detail = (
<VariablesDetail
dataCy={id}
label={label}
helpText={helpText}
rows={4}
value={JSON.stringify(value || {}, undefined, 2)}
/>
);
break;
case 'list':
detail = (
<VariablesDetail
dataCy={id}
helpText={helpText}
rows={4}
label={label}
value={value}
/>
);
break;
case 'image':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
isNotConfigured={!value}
label={label}
value={
!value ? (
i18n._(t`Not configured`)
) : (
<img src={value} alt={label} height="40" width="40" />
)
}
/>
);
break;
case 'encrypted':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
isEncrypted
label={label}
value={i18n._(t`Encrypted`)}
/>
);
break;
case 'boolean':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
label={label}
value={value ? i18n._(t`On`) : i18n._(t`Off`)}
/>
);
break;
case 'choice':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
isNotConfigured={!value}
label={label}
value={!value ? i18n._(t`Not configured`) : value}
/>
);
break;
case 'integer':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
label={label}
value={unit ? `${value} ${unit}` : `${value}`}
/>
);
break;
case 'string':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
isNotConfigured={!value}
label={label}
value={!value ? i18n._(t`Not configured`) : value}
/>
);
break;
default:
detail = null;
}
return detail;
}
);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"AD_HOC_COMMANDS": [
"command"
],
"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": {},
"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
}

View File

@ -0,0 +1,134 @@
{
"AUTH_LDAP_SERVER_URI": "ldap://ldap.example.com",
"AUTH_LDAP_BIND_DN": "cn=eng_user",
"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": [],
"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": {},
"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": {}
}

View File

@ -0,0 +1,21 @@
{
"LOG_AGGREGATOR_HOST": "https://mocklog",
"LOG_AGGREGATOR_PORT": 1234,
"LOG_AGGREGATOR_TYPE": "logstash",
"LOG_AGGREGATOR_USERNAME": "logging_name",
"LOG_AGGREGATOR_PASSWORD": "$encrypted$",
"LOG_AGGREGATOR_LOGGERS": [
"activity_stream",
"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
}

View File

@ -0,0 +1 @@
export { default } from './SettingDetail';

View File

@ -0,0 +1,15 @@
export function assertDetail(wrapper, label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
export function assertVariableDetail(wrapper, label, value) {
expect(
wrapper.find(`VariablesDetail[label="${label}"] .pf-c-form__label`).text()
).toBe(label);
expect(
wrapper
.find(`VariablesDetail[label="${label}"] CodeMirrorInput`)
.prop('value')
).toBe(value);
}

View File

@ -0,0 +1,17 @@
export function sortNestedDetails(obj = {}) {
const nestedTypes = ['nested object', 'list'];
const notNested = Object.entries(obj).filter(
([, value]) => !nestedTypes.includes(value.type)
);
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];
}
export function pluck(sourceObject, ...keys) {
return Object.assign({}, ...keys.map(key => ({ [key]: sourceObject[key] })));
}