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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import Projects from './models/Projects';
import Roles from './models/Roles'; import Roles from './models/Roles';
import Root from './models/Root'; import Root from './models/Root';
import Schedules from './models/Schedules'; import Schedules from './models/Schedules';
import Settings from './models/Settings';
import SystemJobs from './models/SystemJobs'; import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams'; import Teams from './models/Teams';
import Tokens from './models/Tokens'; import Tokens from './models/Tokens';
@@ -61,6 +62,7 @@ const ProjectsAPI = new Projects();
const RolesAPI = new Roles(); const RolesAPI = new Roles();
const RootAPI = new Root(); const RootAPI = new Root();
const SchedulesAPI = new Schedules(); const SchedulesAPI = new Schedules();
const SettingsAPI = new Settings();
const SystemJobsAPI = new SystemJobs(); const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams(); const TeamsAPI = new Teams();
const TokensAPI = new Tokens(); const TokensAPI = new Tokens();
@@ -99,6 +101,7 @@ export {
RolesAPI, RolesAPI,
RootAPI, RootAPI,
SchedulesAPI, SchedulesAPI,
SettingsAPI,
SystemJobsAPI, SystemJobsAPI,
TeamsAPI, TeamsAPI,
TokensAPI, 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 'styled-components/macro';
import React, { useState, useEffect } from 'react'; 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 { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList'; import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle'; import MultiButtonToggle from '../MultiButtonToggle';
import DetailPopover from '../DetailPopover';
import { import {
yamlToJson, yamlToJson,
jsonToYaml, jsonToYaml,
@@ -27,7 +28,7 @@ function getValueAsMode(value, mode) {
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value); 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( const [mode, setMode] = useState(
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE 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 */ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [value]); }, [value]);
const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null;
return ( return (
<> <>
<DetailName <DetailName
data-cy={labelCy}
id={dataCy}
component={TextListItemVariants.dt} component={TextListItemVariants.dt}
fullWidth fullWidth
css="grid-column: 1 / -1" css="grid-column: 1 / -1"
@@ -62,6 +68,9 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
> >
{label} {label}
</span> </span>
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
</div> </div>
</SplitItem> </SplitItem>
<SplitItem> <SplitItem>
@@ -84,6 +93,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
</Split> </Split>
</DetailName> </DetailName>
<DetailValue <DetailValue
data-cy={valueCy}
component={TextListItemVariants.dd} component={TextListItemVariants.dd}
fullWidth fullWidth
css="grid-column: 1 / -1; margin-top: -20px" css="grid-column: 1 / -1; margin-top: -20px"
@@ -109,7 +119,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
); );
} }
VariablesDetail.propTypes = { VariablesDetail.propTypes = {
value: oneOfType([shape({}), string]).isRequired, value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired, label: node.isRequired,
rows: number, rows: number,
}; };

View File

@@ -1,7 +1,8 @@
import React from 'react'; 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 { TextListItem, TextListItemVariants } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import DetailPopover from '../DetailPopover';
const DetailName = styled(({ fullWidth, ...props }) => ( const DetailName = styled(({ fullWidth, ...props }) => (
<TextListItem {...props} /> <TextListItem {...props} />
@@ -14,9 +15,11 @@ const DetailName = styled(({ fullWidth, ...props }) => (
`} `}
`; `;
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => ( const DetailValue = styled(
<TextListItem {...props} /> ({ fullWidth, isEncrypted, isNotConfigured, ...props }) => (
))` <TextListItem {...props} />
)
)`
word-break: break-all; word-break: break-all;
${props => ${props =>
props.fullWidth && props.fullWidth &&
@@ -24,9 +27,8 @@ const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
grid-column: 2 / -1; grid-column: 2 / -1;
`} `}
${props => ${props =>
props.isEncrypted && (props.isEncrypted || props.isNotConfigured) &&
` `
text-transform: uppercase
color: var(--pf-global--Color--400); color: var(--pf-global--Color--400);
`} `}
`; `;
@@ -38,7 +40,9 @@ const Detail = ({
className, className,
dataCy, dataCy,
alwaysVisible, alwaysVisible,
helpText,
isEncrypted, isEncrypted,
isNotConfigured,
}) => { }) => {
if (!value && typeof value !== 'number' && !alwaysVisible) { if (!value && typeof value !== 'number' && !alwaysVisible) {
return null; return null;
@@ -54,8 +58,12 @@ const Detail = ({
component={TextListItemVariants.dt} component={TextListItemVariants.dt}
fullWidth={fullWidth} fullWidth={fullWidth}
data-cy={labelCy} data-cy={labelCy}
id={dataCy}
> >
{label} {label}
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
</DetailName> </DetailName>
<DetailValue <DetailValue
className={className} className={className}
@@ -63,6 +71,7 @@ const Detail = ({
fullWidth={fullWidth} fullWidth={fullWidth}
data-cy={valueCy} data-cy={valueCy}
isEncrypted={isEncrypted} isEncrypted={isEncrypted}
isNotConfigured={isNotConfigured}
> >
{value} {value}
</DetailValue> </DetailValue>
@@ -74,11 +83,13 @@ Detail.propTypes = {
value: node, value: node,
fullWidth: bool, fullWidth: bool,
alwaysVisible: bool, alwaysVisible: bool,
helpText: string,
}; };
Detail.defaultProps = { Detail.defaultProps = {
value: null, value: null,
fullWidth: false, fullWidth: false,
alwaysVisible: false, alwaysVisible: false,
helpText: null,
}; };
export default Detail; 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 { ActionGroup, Button } from '@patternfly/react-core';
import { FormFullWidthLayout } from '../FormLayout'; import { FormFullWidthLayout } from '../FormLayout';
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => ( const FormActionGroup = ({
<FormFullWidthLayout> onCancel,
<ActionGroup> onRevert,
<Button onSubmit,
aria-label={i18n._(t`Save`)} submitDisabled,
variant="primary" i18n,
type="button" }) => {
onClick={onSubmit} return (
isDisabled={submitDisabled} <FormFullWidthLayout>
> <ActionGroup>
{i18n._(t`Save`)} <Button
</Button> aria-label={i18n._(t`Save`)}
<Button variant="primary"
aria-label={i18n._(t`Cancel`)} type="button"
variant="secondary" onClick={onSubmit}
type="button" isDisabled={submitDisabled}
onClick={onCancel} >
> {i18n._(t`Save`)}
{i18n._(t`Cancel`)} </Button>
</Button> {onRevert && (
</ActionGroup> <Button
</FormFullWidthLayout> 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 = { FormActionGroup.propTypes = {
onCancel: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired,
onRevert: PropTypes.func,
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
submitDisabled: PropTypes.bool, submitDisabled: PropTypes.bool,
}; };
FormActionGroup.defaultProps = { FormActionGroup.defaultProps = {
onRevert: null,
submitDisabled: false, submitDisabled: false,
}; };

View File

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

View File

@@ -32,11 +32,12 @@ function RoutedTabs(props) {
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}> <Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
{tabsArray.map(tab => ( {tabsArray.map(tab => (
<Tab <Tab
aria-label={`${tab.name}`} aria-label={typeof tab.name === 'string' ? tab.name : ''}
eventKey={tab.id} eventKey={tab.id}
key={tab.id} key={tab.id}
link={tab.link} link={tab.link}
title={<TabTitleText>{tab.name}</TabTitleText>} title={<TabTitleText>{tab.name}</TabTitleText>}
role="tab"
/> />
))} ))}
</Tabs> </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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import ActivityStreamDetail from './ActivityStreamDetail'; import ActivityStreamDetail from './ActivityStreamDetail';
import ActivityStreamEdit from './ActivityStreamEdit'; import ActivityStreamEdit from './ActivityStreamEdit';
function ActivityStream({ i18n }) { function ActivityStream({ i18n }) {
const baseUrl = '/settings/activity_stream'; const baseURL = '/settings/activity_stream';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`Activity stream settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<ActivityStreamDetail /> <ActivityStreamDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<ActivityStreamEdit /> <ActivityStreamEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Activity Stream settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,59 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ActivityStream from './ActivityStream'; 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 />', () => { describe('<ActivityStream />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<ActivityStream />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && activityStream && (
to="/settings/activity_stream/edit" <DetailList>
> {Object.keys(activityStream).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<ActivityStreamDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('ActivityStreamDetail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import AzureADDetail from './AzureADDetail'; import AzureADDetail from './AzureADDetail';
import AzureADEdit from './AzureADEdit'; import AzureADEdit from './AzureADEdit';
function AzureAD({ i18n }) { function AzureAD({ i18n }) {
const baseUrl = '/settings/azure'; const baseURL = '/settings/azure';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`Azure AD settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<AzureADDetail /> <AzureADDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<AzureADEdit /> <AzureADEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Azure AD settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,56 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import AzureAD from './AzureAD'; import AzureAD from './AzureAD';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<AzureAD />', () => { describe('<AzureAD />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<AzureAD />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && azure && (
to="/settings/azure/edit" <DetailList>
> {Object.keys(azure).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<AzureADDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('AzureADDetail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import GitHubDetail from './GitHubDetail'; import GitHubDetail from './GitHubDetail';
import GitHubEdit from './GitHubEdit'; import GitHubEdit from './GitHubEdit';
function GitHub({ i18n }) { 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 ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`GitHub settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> {baseRoute && <Redirect to={`${baseURL}/default/details`} exact />}
<Route path={`${baseUrl}/details`}> {categoryRoute && (
<Redirect
to={`${baseURL}/${categoryRoute.params.category}/details`}
exact
/>
)}
<Route path={`${baseURL}/:category/details`}>
<GitHubDetail /> <GitHubDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/:category/edit`}>
<GitHubEdit /> <GitHubEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/default/details`}>
{i18n._(t`View GitHub Settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,61 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import GitHub from './GitHub'; import GitHub from './GitHub';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<GitHub />', () => { describe('<GitHub />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<GitHub />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 React, { useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link, Redirect, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && !Object.values(gitHubDetails)?.includes(null) && (
to="/settings/github/edit" <DetailList>
> {Object.keys(gitHubDetails[category]).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<GitHubDetail />', () => {
let wrapper; describe('Default', () => {
beforeEach(() => { let wrapper;
wrapper = mountWithContexts(<GitHubDetail />);
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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import GoogleOAuth2Detail from './GoogleOAuth2Detail'; import GoogleOAuth2Detail from './GoogleOAuth2Detail';
import GoogleOAuth2Edit from './GoogleOAuth2Edit'; import GoogleOAuth2Edit from './GoogleOAuth2Edit';
function GoogleOAuth2({ i18n }) { function GoogleOAuth2({ i18n }) {
const baseUrl = '/settings/google_oauth2'; const baseURL = '/settings/google_oauth2';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`Google OAuth 2.0 settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<GoogleOAuth2Detail /> <GoogleOAuth2Detail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<GoogleOAuth2Edit /> <GoogleOAuth2Edit />
</Route> </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> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,57 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import GoogleOAuth2 from './GoogleOAuth2'; import GoogleOAuth2 from './GoogleOAuth2';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<GoogleOAuth2 />', () => { describe('<GoogleOAuth2 />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<GoogleOAuth2 />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && googleOAuth2 && (
to="/settings/google_oauth2/edit" <DetailList>
> {Object.keys(googleOAuth2).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<GoogleOAuth2Detail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import JobsDetail from './JobsDetail'; import JobsDetail from './JobsDetail';
import JobsEdit from './JobsEdit'; import JobsEdit from './JobsEdit';
function Jobs({ i18n }) { function Jobs({ i18n }) {
const baseUrl = '/settings/jobs'; const baseURL = '/settings/jobs';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`Jobs settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<JobsDetail /> <JobsDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<JobsEdit /> <JobsEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Jobs settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,57 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import Jobs from './Jobs'; import Jobs from './Jobs';
import { SettingsAPI } from '../../../api';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<Jobs />', () => { describe('<Jobs />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<Jobs />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && jobs && (
to="/settings/jobs/edit" <DetailList>
> {jobs.map(([key, detail]) => {
{i18n._(t`Edit`)} return (
</Button> <SettingDetail
</CardActionsRow> key={key}
</CardBody> 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 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'; import JobsDetail from './JobsDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: mockJobSettings,
});
describe('<JobsDetail />', () => { describe('<JobsDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('JobsDetail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import LDAPDetail from './LDAPDetail'; import LDAPDetail from './LDAPDetail';
import LDAPEdit from './LDAPEdit'; import LDAPEdit from './LDAPEdit';
function LDAP({ i18n }) { 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 ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`LDAP settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> {baseRoute && <Redirect to={`${baseURL}/default/details`} exact />}
<Route path={`${baseUrl}/details`}> {categoryRoute && (
<Redirect
to={`${baseURL}/${categoryRoute.params.category}/details`}
exact
/>
)}
<Route path={`${baseURL}/:category/details`}>
<LDAPDetail /> <LDAPDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/:category/edit`}>
<LDAPEdit /> <LDAPEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/default/details`}>
{i18n._(t`View LDAP Settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,61 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import LDAP from './LDAP'; import LDAP from './LDAP';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<LDAP />', () => { describe('<LDAP />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LDAP />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 React, { useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link, Redirect, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button <>
aria-label={i18n._(t`Edit`)} {isLoading && <ContentLoading />}
component={Link} {!isLoading && error && <ContentError error={error} />}
to="/settings/ldap/edit" {!isLoading && !Object.values(LDAPDetails)?.includes(null) && (
> <DetailList>
{i18n._(t`Edit`)} {LDAPDetails[category].map(([key, detail]) => {
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<LDAPDetail />', () => {
let wrapper; describe('Default', () => {
beforeEach(() => { let wrapper;
wrapper = mountWithContexts(<LDAPDetail />);
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(); describe('Redirect', () => {
}); test('should render redirect when user navigates to erroneous category', async () => {
test('initially renders without crashing', () => { let wrapper;
expect(wrapper.find('LDAPDetail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import LoggingDetail from './LoggingDetail'; import LoggingDetail from './LoggingDetail';
import LoggingEdit from './LoggingEdit'; import LoggingEdit from './LoggingEdit';
function Logging({ i18n }) { function Logging({ i18n }) {
const baseUrl = '/settings/logging'; const baseURL = '/settings/logging';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`Logging settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<LoggingDetail /> <LoggingDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<LoggingEdit /> <LoggingEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}`}>{i18n._(t`View Logging settings`)}</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,61 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import Logging from './Logging'; import Logging from './Logging';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<Logging />', () => { describe('<Logging />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<Logging />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && logging && (
to="/settings/logging/edit" <DetailList>
> {logging.map(([key, detail]) => (
{i18n._(t`Edit`)} <SettingDetail
</Button> key={key}
</CardActionsRow> id={key}
</CardBody> 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 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'; import LoggingDetail from './LoggingDetail';
jest.mock('../../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: mockLogSettings,
});
describe('<LoggingDetail />', () => { describe('<LoggingDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('LoggingDetail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import MiscSystemDetail from './MiscSystemDetail'; import MiscSystemDetail from './MiscSystemDetail';
import MiscSystemEdit from './MiscSystemEdit'; import MiscSystemEdit from './MiscSystemEdit';
function MiscSystem({ i18n }) { function MiscSystem({ i18n }) {
const baseUrl = '/settings/miscellaneous_system'; const baseURL = '/settings/miscellaneous_system';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`Miscellaneous system settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<MiscSystemDetail /> <MiscSystemDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<MiscSystemEdit /> <MiscSystemEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View Miscellaneous System settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,18 +1,61 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import MiscSystem from './MiscSystem'; import MiscSystem from './MiscSystem';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<MiscSystem />', () => { describe('<MiscSystem />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<MiscSystem />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => {
expect(wrapper.find('Card').text()).toContain( test('should render miscellaneous system details', async () => {
'Miscellaneous system settings' 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && system && (
to="/settings/miscellaneous_system/edit" <DetailList>
> {system.map(([key, detail]) => (
{i18n._(t`Edit`)} <SettingDetail
</Button> key={key}
</CardActionsRow> id={key}
</CardBody> 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 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'; 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 />', () => { describe('<MiscSystemDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('MiscSystemDetail').length).toBe(1); 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 { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card'; import { CardBody, CardActionsRow } from '../../../../components/Card';
function RadiusEdit({ i18n }) { function RADIUSEdit({ i18n }) {
return ( return (
<CardBody> <CardBody>
{i18n._(t`Edit form coming soon :)`)} {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 React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import RadiusEdit from './RadiusEdit'; import RADIUSEdit from './RADIUSEdit';
describe('<RadiusEdit />', () => { describe('<RADIUSEdit />', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = mountWithContexts(<RadiusEdit />); wrapper = mountWithContexts(<RADIUSEdit />);
}); });
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
}); });
test('initially renders without crashing', () => { 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import SAMLDetail from './SAMLDetail'; import SAMLDetail from './SAMLDetail';
import SAMLEdit from './SAMLEdit'; import SAMLEdit from './SAMLEdit';
function SAML({ i18n }) { function SAML({ i18n }) {
const baseUrl = '/settings/saml'; const baseURL = '/settings/saml';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`SAML settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<SAMLDetail /> <SAMLDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<SAMLEdit /> <SAMLEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View SAML settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,56 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import SAML from './SAML'; import SAML from './SAML';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<SAML />', () => { describe('<SAML />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<SAML />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && saml && (
to="/settings/saml/edit" <DetailList>
> {Object.keys(saml).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<SAMLDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('SAMLDetail').length).toBe(1); 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', path: '/settings/ldap',
}, },
{ {
title: i18n._(t`Radius settings`), title: i18n._(t`RADIUS settings`),
path: '/settings/radius', path: '/settings/radius',
}, },
{ {
@@ -107,11 +107,11 @@ function SettingList({ i18n }) {
id: 'system', id: 'system',
routes: [ routes: [
{ {
title: i18n._(t`Miscellaneous system settings`), title: i18n._(t`Miscellaneous System settings`),
path: '/settings/miscellaneous_system', path: '/settings/miscellaneous_system',
}, },
{ {
title: i18n._(t`Activity stream settings`), title: i18n._(t`Activity Stream settings`),
path: '/settings/activity_stream', path: '/settings/activity_stream',
}, },
{ {
@@ -121,15 +121,15 @@ function SettingList({ i18n }) {
], ],
}, },
{ {
header: i18n._(t`User interface`), header: i18n._(t`User Interface`),
description: i18n._( description: i18n._(
t`Set preferences for data collection, logos, and logins` t`Set preferences for data collection, logos, and logins`
), ),
id: 'user_interface', id: 'ui',
routes: [ routes: [
{ {
title: i18n._(t`User interface settings`), title: i18n._(t`User Interface settings`),
path: '/settings/user_interface', 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 { Link, Route, Switch, Redirect } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import Breadcrumbs from '../../components/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs';
import ActivityStream from './ActivityStream'; import ActivityStream from './ActivityStream';
import AzureAD from './AzureAD'; import AzureAD from './AzureAD';
@@ -14,34 +15,120 @@ import LDAP from './LDAP';
import License from './License'; import License from './License';
import Logging from './Logging'; import Logging from './Logging';
import MiscSystem from './MiscSystem'; import MiscSystem from './MiscSystem';
import Radius from './Radius'; import RADIUS from './RADIUS';
import SAML from './SAML'; import SAML from './SAML';
import SettingList from './SettingList'; import SettingList from './SettingList';
import TACACS from './TACACS'; import TACACS from './TACACS';
import UI from './UI'; import UI from './UI';
import { SettingsProvider } from '../../contexts/Settings';
import { useConfig } from '../../contexts/Config'; import { useConfig } from '../../contexts/Config';
import { SettingsAPI } from '../../api';
import useRequest from '../../util/useRequest';
function Settings({ i18n }) { 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 = { const breadcrumbConfig = {
'/settings': i18n._(t`Settings`), '/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/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': 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/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/license': i18n._(t`License`),
'/settings/logging': i18n._(t`Logging`), '/settings/logging': i18n._(t`Logging`),
'/settings/miscellaneous_system': i18n._(t`Miscellaneous system`), '/settings/logging/details': i18n._(t`Details`),
'/settings/radius': i18n._(t`Radius`), '/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': i18n._(t`SAML`),
'/settings/saml/details': i18n._(t`Details`),
'/settings/saml/edit': i18n._(t`Edit Details`),
'/settings/tacacs': i18n._(t`TACACS+`), '/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 ( return (
<> <SettingsProvider value={result}>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/settings/activity_stream"> <Route path="/settings/activity_stream">
@@ -76,7 +163,7 @@ function Settings({ i18n }) {
<MiscSystem /> <MiscSystem />
</Route> </Route>
<Route path="/settings/radius"> <Route path="/settings/radius">
<Radius /> <RADIUS />
</Route> </Route>
<Route path="/settings/saml"> <Route path="/settings/saml">
<SAML /> <SAML />
@@ -84,7 +171,7 @@ function Settings({ i18n }) {
<Route path="/settings/tacacs"> <Route path="/settings/tacacs">
<TACACS /> <TACACS />
</Route> </Route>
<Route path="/settings/user_interface"> <Route path="/settings/ui">
<UI /> <UI />
</Route> </Route>
<Route path="/settings" exact> <Route path="/settings" exact>
@@ -100,7 +187,7 @@ function Settings({ i18n }) {
</PageSection> </PageSection>
</Route> </Route>
</Switch> </Switch>
</> </SettingsProvider>
); );
} }

View File

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

View File

@@ -1,16 +1,56 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import TACACS from './TACACS'; import TACACS from './TACACS';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<TACACS />', () => { describe('<TACACS />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<TACACS />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && tacacs && (
to="/settings/tacacs/edit" <DetailList>
> {Object.keys(tacacs).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<TACACSDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('TACACSDetail').length).toBe(1); 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 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import UIDetail from './UIDetail'; import UIDetail from './UIDetail';
import UIEdit from './UIEdit'; import UIEdit from './UIEdit';
function UI({ i18n }) { function UI({ i18n }) {
const baseUrl = '/settings/ui'; const baseURL = '/settings/ui';
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{i18n._(t`User interface settings`)}
<Switch> <Switch>
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact /> <Redirect from={baseURL} to={`${baseURL}/details`} exact />
<Route path={`${baseUrl}/details`}> <Route path={`${baseURL}/details`}>
<UIDetail /> <UIDetail />
</Route> </Route>
<Route path={`${baseUrl}/edit`}> <Route path={`${baseURL}/edit`}>
<UIEdit /> <UIEdit />
</Route> </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/details`}>
{i18n._(t`View User Interface settings`)}
</Link>
</ContentError>
</Route>
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,16 +1,61 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import UI from './UI'; import UI from './UI';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
describe('<UI />', () => { describe('<UI />', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<UI />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); 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 { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { CardBody, CardActionsRow } from '../../../../components/Card'; 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 }) { 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 ( return (
<CardBody> <>
{i18n._(t`Detail coming soon :)`)} <RoutedTabs tabsArray={tabsArray} />
<CardActionsRow> <CardBody>
<Button {isLoading && <ContentLoading />}
aria-label={i18n._(t`Edit`)} {!isLoading && error && <ContentError error={error} />}
component={Link} {!isLoading && ui && (
to="/settings/ui/edit" <DetailList>
> {Object.keys(ui).map(key => {
{i18n._(t`Edit`)} const record = options?.[key];
</Button> return (
</CardActionsRow> <SettingDetail
</CardBody> 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 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'; 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 />', () => { describe('<UIDetail />', () => {
let wrapper; 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(); wrapper.unmount();
jest.clearAllMocks();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('UIDetail').length).toBe(1); 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] })));
}