mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
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:
commit
5c0432b979
@ -16,6 +16,7 @@ register(
|
||||
help_text=_('Number of seconds that a user is inactive before they will need to login again.'),
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
register(
|
||||
'SESSIONS_PER_USER',
|
||||
@ -49,6 +50,7 @@ register(
|
||||
'in the number of seconds.'),
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
register(
|
||||
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS',
|
||||
|
||||
@ -39,7 +39,7 @@ class Metadata(metadata.SimpleMetadata):
|
||||
'min_length', 'max_length',
|
||||
'min_value', 'max_value',
|
||||
'category', 'category_slug',
|
||||
'defined_in_file'
|
||||
'defined_in_file', 'unit',
|
||||
]
|
||||
|
||||
for attr in text_attrs:
|
||||
|
||||
@ -129,12 +129,14 @@ class SettingsRegistry(object):
|
||||
placeholder = field_kwargs.pop('placeholder', empty)
|
||||
encrypted = bool(field_kwargs.pop('encrypted', False))
|
||||
defined_in_file = bool(field_kwargs.pop('defined_in_file', False))
|
||||
unit = field_kwargs.pop('unit', None)
|
||||
if getattr(field_kwargs.get('child', None), 'source', None) is not None:
|
||||
field_kwargs['child'].source = None
|
||||
field_instance = field_class(**field_kwargs)
|
||||
field_instance.category_slug = category_slug
|
||||
field_instance.category = category
|
||||
field_instance.depends_on = depends_on
|
||||
field_instance.unit = unit
|
||||
if placeholder is not empty:
|
||||
field_instance.placeholder = placeholder
|
||||
field_instance.defined_in_file = defined_in_file
|
||||
|
||||
@ -148,7 +148,7 @@ register(
|
||||
default='https://example.com',
|
||||
schemes=('http', 'https'),
|
||||
allow_plain_hostname=True, # Allow hostname only without TLD.
|
||||
label=_('Automation Analytics upload URL.'),
|
||||
label=_('Automation Analytics upload URL'),
|
||||
help_text=_('This setting is used to to configure data collection for the Automation Analytics dashboard'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
@ -253,6 +253,7 @@ register(
|
||||
help_text=_('The number of seconds to sleep between status checks for jobs running on isolated instances.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -264,6 +265,7 @@ register(
|
||||
'This includes the time needed to copy source control files (playbooks) to the isolated instance.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -276,6 +278,7 @@ register(
|
||||
'Value should be substantially greater than expected network latency.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -497,6 +500,7 @@ register(
|
||||
'timeout should be imposed. A timeout set on an individual job template will override this.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -509,6 +513,7 @@ register(
|
||||
'timeout should be imposed. A timeout set on an individual inventory source will override this.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -521,6 +526,7 @@ register(
|
||||
'timeout should be imposed. A timeout set on an individual project will override this.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -535,6 +541,7 @@ register(
|
||||
'Use a value of 0 to indicate that no timeout should be imposed.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@ -542,7 +549,7 @@ register(
|
||||
field_class=fields.IntegerField,
|
||||
allow_null=False,
|
||||
default=200,
|
||||
label=_('Maximum number of forks per job.'),
|
||||
label=_('Maximum number of forks per job'),
|
||||
help_text=_('Saving a Job Template with more than this number of forks will result in an error. '
|
||||
'When set to 0, no limit is applied.'),
|
||||
category=_('Jobs'),
|
||||
@ -672,6 +679,7 @@ register(
|
||||
'aggregator protocols.'),
|
||||
category=_('Logging'),
|
||||
category_slug='logging',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
register(
|
||||
'LOG_AGGREGATOR_VERIFY_CERT',
|
||||
@ -752,7 +760,8 @@ register(
|
||||
default=14400, # every 4 hours
|
||||
min_value=1800, # every 30 minutes
|
||||
category=_('System'),
|
||||
category_slug='system'
|
||||
category_slug='system',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -515,6 +515,7 @@ register(
|
||||
help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'),
|
||||
category=_('TACACS+'),
|
||||
category_slug='tacacsplus',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
|
||||
@ -24,6 +24,7 @@ import Projects from './models/Projects';
|
||||
import Roles from './models/Roles';
|
||||
import Root from './models/Root';
|
||||
import Schedules from './models/Schedules';
|
||||
import Settings from './models/Settings';
|
||||
import SystemJobs from './models/SystemJobs';
|
||||
import Teams from './models/Teams';
|
||||
import Tokens from './models/Tokens';
|
||||
@ -61,6 +62,7 @@ const ProjectsAPI = new Projects();
|
||||
const RolesAPI = new Roles();
|
||||
const RootAPI = new Root();
|
||||
const SchedulesAPI = new Schedules();
|
||||
const SettingsAPI = new Settings();
|
||||
const SystemJobsAPI = new SystemJobs();
|
||||
const TeamsAPI = new Teams();
|
||||
const TokensAPI = new Tokens();
|
||||
@ -99,6 +101,7 @@ export {
|
||||
RolesAPI,
|
||||
RootAPI,
|
||||
SchedulesAPI,
|
||||
SettingsAPI,
|
||||
SystemJobsAPI,
|
||||
TeamsAPI,
|
||||
TokensAPI,
|
||||
|
||||
26
awx/ui_next/src/api/models/Settings.js
Normal file
26
awx/ui_next/src/api/models/Settings.js
Normal 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;
|
||||
@ -1,9 +1,10 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { node, number, oneOfType, shape, string } from 'prop-types';
|
||||
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
|
||||
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from '../DetailList';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import DetailPopover from '../DetailPopover';
|
||||
import {
|
||||
yamlToJson,
|
||||
jsonToYaml,
|
||||
@ -27,7 +28,7 @@ function getValueAsMode(value, mode) {
|
||||
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
|
||||
}
|
||||
|
||||
function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
|
||||
const [mode, setMode] = useState(
|
||||
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
|
||||
);
|
||||
@ -46,9 +47,14 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [value]);
|
||||
|
||||
const labelCy = dataCy ? `${dataCy}-label` : null;
|
||||
const valueCy = dataCy ? `${dataCy}-value` : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailName
|
||||
data-cy={labelCy}
|
||||
id={dataCy}
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1"
|
||||
@ -62,6 +68,9 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{helpText && (
|
||||
<DetailPopover header={label} content={helpText} id={dataCy} />
|
||||
)}
|
||||
</div>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
@ -84,6 +93,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
</Split>
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
data-cy={valueCy}
|
||||
component={TextListItemVariants.dd}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1; margin-top: -20px"
|
||||
@ -109,7 +119,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
);
|
||||
}
|
||||
VariablesDetail.propTypes = {
|
||||
value: oneOfType([shape({}), string]).isRequired,
|
||||
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { node, bool } from 'prop-types';
|
||||
import { node, bool, string } from 'prop-types';
|
||||
import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import DetailPopover from '../DetailPopover';
|
||||
|
||||
const DetailName = styled(({ fullWidth, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
@ -14,9 +15,11 @@ const DetailName = styled(({ fullWidth, ...props }) => (
|
||||
`}
|
||||
`;
|
||||
|
||||
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
))`
|
||||
const DetailValue = styled(
|
||||
({ fullWidth, isEncrypted, isNotConfigured, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
)
|
||||
)`
|
||||
word-break: break-all;
|
||||
${props =>
|
||||
props.fullWidth &&
|
||||
@ -24,9 +27,8 @@ const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
|
||||
grid-column: 2 / -1;
|
||||
`}
|
||||
${props =>
|
||||
props.isEncrypted &&
|
||||
(props.isEncrypted || props.isNotConfigured) &&
|
||||
`
|
||||
text-transform: uppercase
|
||||
color: var(--pf-global--Color--400);
|
||||
`}
|
||||
`;
|
||||
@ -38,7 +40,9 @@ const Detail = ({
|
||||
className,
|
||||
dataCy,
|
||||
alwaysVisible,
|
||||
helpText,
|
||||
isEncrypted,
|
||||
isNotConfigured,
|
||||
}) => {
|
||||
if (!value && typeof value !== 'number' && !alwaysVisible) {
|
||||
return null;
|
||||
@ -54,8 +58,12 @@ const Detail = ({
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth={fullWidth}
|
||||
data-cy={labelCy}
|
||||
id={dataCy}
|
||||
>
|
||||
{label}
|
||||
{helpText && (
|
||||
<DetailPopover header={label} content={helpText} id={dataCy} />
|
||||
)}
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
className={className}
|
||||
@ -63,6 +71,7 @@ const Detail = ({
|
||||
fullWidth={fullWidth}
|
||||
data-cy={valueCy}
|
||||
isEncrypted={isEncrypted}
|
||||
isNotConfigured={isNotConfigured}
|
||||
>
|
||||
{value}
|
||||
</DetailValue>
|
||||
@ -74,11 +83,13 @@ Detail.propTypes = {
|
||||
value: node,
|
||||
fullWidth: bool,
|
||||
alwaysVisible: bool,
|
||||
helpText: string,
|
||||
};
|
||||
Detail.defaultProps = {
|
||||
value: null,
|
||||
fullWidth: false,
|
||||
alwaysVisible: false,
|
||||
helpText: null,
|
||||
};
|
||||
|
||||
export default Detail;
|
||||
|
||||
51
awx/ui_next/src/components/DetailPopover/DetailPopover.jsx
Normal file
51
awx/ui_next/src/components/DetailPopover/DetailPopover.jsx
Normal 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;
|
||||
1
awx/ui_next/src/components/DetailPopover/index.js
Normal file
1
awx/ui_next/src/components/DetailPopover/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './DetailPopover';
|
||||
@ -5,37 +5,57 @@ import { t } from '@lingui/macro';
|
||||
import { ActionGroup, Button } from '@patternfly/react-core';
|
||||
import { FormFullWidthLayout } from '../FormLayout';
|
||||
|
||||
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
aria-label={i18n._(t`Save`)}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
isDisabled={submitDisabled}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
);
|
||||
const FormActionGroup = ({
|
||||
onCancel,
|
||||
onRevert,
|
||||
onSubmit,
|
||||
submitDisabled,
|
||||
i18n,
|
||||
}) => {
|
||||
return (
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
aria-label={i18n._(t`Save`)}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
isDisabled={submitDisabled}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>
|
||||
{onRevert && (
|
||||
<Button
|
||||
aria-label={i18n._(t`Revert`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onRevert}
|
||||
>
|
||||
{i18n._(t`Revert`)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
FormActionGroup.propTypes = {
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onRevert: PropTypes.func,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
submitDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
FormActionGroup.defaultProps = {
|
||||
onRevert: null,
|
||||
submitDisabled: false,
|
||||
};
|
||||
|
||||
|
||||
@ -74,6 +74,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
/>
|
||||
@ -86,6 +87,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@ -138,6 +140,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
/>
|
||||
@ -150,6 +153,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@ -225,6 +229,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
/>
|
||||
@ -237,6 +242,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@ -447,6 +453,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
>
|
||||
@ -463,9 +470,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-htpNat",
|
||||
"componentId": "sc-bxivhb",
|
||||
"isStatic": false,
|
||||
"lastClassName": "iYJcPm",
|
||||
"lastClassName": "gQwVdc",
|
||||
"rules": Array [
|
||||
"
|
||||
font-weight: var(--pf-global--FontWeight--bold);
|
||||
@ -478,7 +485,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-htpNat",
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@ -489,18 +496,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
>
|
||||
<dt
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@ -523,9 +530,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bxivhb",
|
||||
"componentId": "sc-ifAKCX",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gxmPlV",
|
||||
"lastClassName": "boHWLt",
|
||||
"rules": Array [
|
||||
"
|
||||
word-break: break-all;
|
||||
@ -541,7 +548,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"styledComponentId": "sc-ifAKCX",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@ -552,18 +559,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
>
|
||||
<dd
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@ -670,6 +677,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@ -703,9 +711,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-htpNat",
|
||||
"componentId": "sc-bxivhb",
|
||||
"isStatic": false,
|
||||
"lastClassName": "iYJcPm",
|
||||
"lastClassName": "gQwVdc",
|
||||
"rules": Array [
|
||||
"
|
||||
font-weight: var(--pf-global--FontWeight--bold);
|
||||
@ -718,7 +726,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-htpNat",
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@ -729,18 +737,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
>
|
||||
<dt
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@ -763,9 +771,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bxivhb",
|
||||
"componentId": "sc-ifAKCX",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gxmPlV",
|
||||
"lastClassName": "boHWLt",
|
||||
"rules": Array [
|
||||
"
|
||||
word-break: break-all;
|
||||
@ -781,7 +789,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"styledComponentId": "sc-ifAKCX",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@ -792,18 +800,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
>
|
||||
<dd
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
|
||||
@ -32,11 +32,12 @@ function RoutedTabs(props) {
|
||||
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
|
||||
{tabsArray.map(tab => (
|
||||
<Tab
|
||||
aria-label={`${tab.name}`}
|
||||
aria-label={typeof tab.name === 'string' ? tab.name : ''}
|
||||
eventKey={tab.id}
|
||||
key={tab.id}
|
||||
link={tab.link}
|
||||
title={<TabTitleText>{tab.name}</TabTitleText>}
|
||||
role="tab"
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
6
awx/ui_next/src/contexts/Settings.jsx
Normal file
6
awx/ui_next/src/contexts/Settings.jsx
Normal 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);
|
||||
@ -1,25 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import ActivityStreamDetail from './ActivityStreamDetail';
|
||||
import ActivityStreamEdit from './ActivityStreamEdit';
|
||||
|
||||
function ActivityStream({ i18n }) {
|
||||
const baseUrl = '/settings/activity_stream';
|
||||
const baseURL = '/settings/activity_stream';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`Activity stream settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<ActivityStreamDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<ActivityStreamEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View Activity Stream settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,59 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import ActivityStream from './ActivityStream';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
ACTIVITY_STREAM_ENABLED: true,
|
||||
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('<ActivityStream />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<ActivityStream />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('Activity stream settings');
|
||||
|
||||
test('should render activity stream details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/activity_stream/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ActivityStream />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ActivityStreamDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render activity stream edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/activity_stream/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ActivityStream />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ActivityStreamEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/activity_stream/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ActivityStream />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,99 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function ActivityStreamDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: activityStream } = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: {
|
||||
ACTIVITY_STREAM_ENABLED,
|
||||
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
|
||||
},
|
||||
} = await SettingsAPI.readCategory('system');
|
||||
return {
|
||||
ACTIVITY_STREAM_ENABLED,
|
||||
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC,
|
||||
};
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/activity_stream/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/activity_stream/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && activityStream && (
|
||||
<DetailList>
|
||||
{Object.keys(activityStream).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={activityStream?.[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/activity_stream/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,88 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import { assertDetail } from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import ActivityStreamDetail from './ActivityStreamDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
ACTIVITY_STREAM_ENABLED: true,
|
||||
ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('<ActivityStreamDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<ActivityStreamDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<ActivityStreamDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('ActivityStreamDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'Enable Activity Stream', 'On');
|
||||
assertDetail(wrapper, 'Enable Activity Stream for Inventory Sync', 'Off');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<ActivityStreamDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<ActivityStreamDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import AzureADDetail from './AzureADDetail';
|
||||
import AzureADEdit from './AzureADEdit';
|
||||
|
||||
function AzureAD({ i18n }) {
|
||||
const baseUrl = '/settings/azure';
|
||||
|
||||
const baseURL = '/settings/azure';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`Azure AD settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<AzureADDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<AzureADEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View Azure AD settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,56 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import AzureAD from './AzureAD';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<AzureAD />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<AzureAD />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('Azure AD settings');
|
||||
|
||||
test('should render azure details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/azure/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<AzureAD />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('AzureADDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render azure edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/azure/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<AzureAD />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('AzureADEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/azure/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<AzureAD />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,91 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function AzureADDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: azure } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('azuread-oauth2');
|
||||
return data;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/azure/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/azure/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && azure && (
|
||||
<DetailList>
|
||||
{Object.keys(azure).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={azure?.[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/azure/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,110 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import AzureADDetail from './AzureADDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/azuread-oauth2/',
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY: 'mock key',
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP: {},
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP: {
|
||||
'My Team': {
|
||||
users: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('<AzureADDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<AzureADDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<AzureADDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('AzureADDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Azure AD OAuth2 Callback URL',
|
||||
'https://towerhost/sso/complete/azuread-oauth2/'
|
||||
);
|
||||
assertDetail(wrapper, 'Azure AD OAuth2 Key', 'mock key');
|
||||
assertDetail(wrapper, 'Azure AD OAuth2 Secret', 'Encrypted');
|
||||
assertVariableDetail(wrapper, 'Azure AD OAuth2 Organization Map', '{}');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'Azure AD OAuth2 Team Map',
|
||||
'{\n "My Team": {\n "users": []\n }\n}'
|
||||
);
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<AzureADDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<AzureADDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import GitHubDetail from './GitHubDetail';
|
||||
import GitHubEdit from './GitHubEdit';
|
||||
|
||||
function GitHub({ i18n }) {
|
||||
const baseUrl = '/settings/github';
|
||||
const baseURL = '/settings/github';
|
||||
const baseRoute = useRouteMatch({ path: '/settings/github', exact: true });
|
||||
const categoryRoute = useRouteMatch({
|
||||
path: '/settings/github/:category',
|
||||
exact: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`GitHub settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
{baseRoute && <Redirect to={`${baseURL}/default/details`} exact />}
|
||||
{categoryRoute && (
|
||||
<Redirect
|
||||
to={`${baseURL}/${categoryRoute.params.category}/details`}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
<Route path={`${baseURL}/:category/details`}>
|
||||
<GitHubDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/:category/edit`}>
|
||||
<GitHubEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/default/details`}>
|
||||
{i18n._(t`View GitHub Settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import GitHub from './GitHub';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<GitHub />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<GitHub />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('GitHub settings');
|
||||
|
||||
test('should render github details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/github/'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GitHub />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render github edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/github/default/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GitHub />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/github/foo/bar'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GitHub />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link, Redirect, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function GitHubDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const baseURL = '/settings/github';
|
||||
const {
|
||||
path,
|
||||
params: { category },
|
||||
} = useRouteMatch(`${baseURL}/:category/details`);
|
||||
|
||||
const { isLoading, error, request, result: gitHubDetails } = useRequest(
|
||||
useCallback(async () => {
|
||||
const [
|
||||
{ data: gitHubDefault },
|
||||
{ data: gitHubOrganization },
|
||||
{ data: gitHubTeam },
|
||||
] = await Promise.all([
|
||||
SettingsAPI.readCategory('github'),
|
||||
SettingsAPI.readCategory('github-org'),
|
||||
SettingsAPI.readCategory('github-team'),
|
||||
]);
|
||||
return {
|
||||
default: gitHubDefault,
|
||||
organization: gitHubOrganization,
|
||||
team: gitHubTeam,
|
||||
};
|
||||
}, []),
|
||||
{
|
||||
default: null,
|
||||
organization: null,
|
||||
team: null,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`GitHub Default`),
|
||||
link: `${baseURL}/default/details`,
|
||||
id: 0,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`GitHub Organization`),
|
||||
link: `${baseURL}/organization/details`,
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`GitHub Team`),
|
||||
link: `${baseURL}/team/details`,
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
|
||||
if (!Object.keys(gitHubDetails).includes(category)) {
|
||||
return <Redirect from={path} to={`${baseURL}/default/details`} exact />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/github/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && !Object.values(gitHubDetails)?.includes(null) && (
|
||||
<DetailList>
|
||||
{Object.keys(gitHubDetails[category]).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={gitHubDetails[category][key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to={`${baseURL}/${category}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,257 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import GitHubDetail from './GitHubDetail';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
|
||||
const mockDefault = {
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://towerhost/sso/complete/github/',
|
||||
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
|
||||
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
|
||||
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
|
||||
},
|
||||
};
|
||||
const mockOrg = {
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/github-org/',
|
||||
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
|
||||
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
|
||||
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
|
||||
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
|
||||
},
|
||||
};
|
||||
const mockTeam = {
|
||||
data: {
|
||||
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/github-team/',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
|
||||
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
|
||||
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('<GitHubDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<GitHubDetail />);
|
||||
describe('Default', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
|
||||
useRouteMatch.mockImplementation(() => ({
|
||||
url: '/settings/github/default/details',
|
||||
path: '/settings/github/:category/details',
|
||||
params: { category: 'default' },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = [
|
||||
'Back to Settings',
|
||||
'GitHub Default',
|
||||
'GitHub Organization',
|
||||
'GitHub Team',
|
||||
];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'GitHub OAuth2 Callback URL',
|
||||
'https://towerhost/sso/complete/github/'
|
||||
);
|
||||
assertDetail(wrapper, 'GitHub OAuth2 Key', 'mock github key');
|
||||
assertDetail(wrapper, 'GitHub OAuth2 Secret', 'Encrypted');
|
||||
assertVariableDetail(wrapper, 'GitHub OAuth2 Organization Map', '{}');
|
||||
assertVariableDetail(wrapper, 'GitHub OAuth2 Team Map', '{}');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
|
||||
describe('Organization', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
|
||||
useRouteMatch.mockImplementation(() => ({
|
||||
url: '/settings/github/organization/details',
|
||||
path: '/settings/github/:category/details',
|
||||
params: { category: 'organization' },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'GitHub Organization OAuth2 Callback URL',
|
||||
'https://towerhost/sso/complete/github-org/'
|
||||
);
|
||||
assertDetail(wrapper, 'GitHub Organization OAuth2 Key', 'Not configured');
|
||||
assertDetail(wrapper, 'GitHub Organization OAuth2 Secret', 'Encrypted');
|
||||
assertDetail(wrapper, 'GitHub Organization Name', 'Not configured');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'GitHub Organization OAuth2 Organization Map',
|
||||
'{}'
|
||||
);
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'GitHub Organization OAuth2 Team Map',
|
||||
'{}'
|
||||
);
|
||||
});
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||
|
||||
describe('Team', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
|
||||
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
|
||||
useRouteMatch.mockImplementation(() => ({
|
||||
url: '/settings/github/team/details',
|
||||
path: '/settings/github/:category/details',
|
||||
params: { category: 'team' },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'GitHub Team OAuth2 Callback URL',
|
||||
'https://towerhost/sso/complete/github-team/'
|
||||
);
|
||||
assertDetail(wrapper, 'GitHub Team OAuth2 Key', 'OAuth2 key (Client ID)');
|
||||
assertDetail(wrapper, 'GitHub Team OAuth2 Secret', 'Encrypted');
|
||||
assertDetail(wrapper, 'GitHub Team ID', 'team_id');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'GitHub Team OAuth2 Organization Map',
|
||||
'{}'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'GitHub Team OAuth2 Team Map', '{}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redirect', () => {
|
||||
test('should render redirect when user navigates to erroneous category', async () => {
|
||||
let wrapper;
|
||||
useRouteMatch.mockImplementation(() => ({
|
||||
url: '/settings/github/foo/details',
|
||||
path: '/settings/github/:category/details',
|
||||
params: { category: 'foo' },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GitHubDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'Redirect');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import GoogleOAuth2Detail from './GoogleOAuth2Detail';
|
||||
import GoogleOAuth2Edit from './GoogleOAuth2Edit';
|
||||
|
||||
function GoogleOAuth2({ i18n }) {
|
||||
const baseUrl = '/settings/google_oauth2';
|
||||
|
||||
const baseURL = '/settings/google_oauth2';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`Google OAuth 2.0 settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<GoogleOAuth2Detail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<GoogleOAuth2Edit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View Google OAuth 2.0 settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,57 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import GoogleOAuth2 from './GoogleOAuth2';
|
||||
|
||||
import { SettingsAPI } from '../../../api';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<GoogleOAuth2 />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2 />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('Google OAuth 2.0 settings');
|
||||
|
||||
test('should render Google OAuth 2.0 details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/google_oauth2/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render Google OAuth 2.0 edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/google_oauth2/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/google_oauth2/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,91 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function GoogleOAuth2Detail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: googleOAuth2 } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('google-oauth2');
|
||||
return data;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/google_oauth2/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/google_oauth2/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && googleOAuth2 && (
|
||||
<DetailList>
|
||||
{Object.keys(googleOAuth2).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={googleOAuth2?.[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/google_oauth2/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,119 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import GoogleOAuth2Detail from './GoogleOAuth2Detail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
|
||||
'https://towerhost/sso/complete/google-oauth2/',
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
|
||||
'example.com',
|
||||
'example_2.com',
|
||||
],
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
|
||||
Default: {},
|
||||
},
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
|
||||
},
|
||||
});
|
||||
|
||||
describe('<GoogleOAuth2Detail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<GoogleOAuth2Detail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GoogleOAuth2Detail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Google OAuth2 Callback URL',
|
||||
'https://towerhost/sso/complete/google-oauth2/'
|
||||
);
|
||||
assertDetail(wrapper, 'Google OAuth2 Key', 'mock key');
|
||||
assertDetail(wrapper, 'Google OAuth2 Secret', 'Encrypted');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'Google OAuth2 Allowed Domains',
|
||||
'[\n "example.com",\n "example_2.com"\n]'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'Google OAuth2 Extra Arguments', '{}');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'Google OAuth2 Organization Map',
|
||||
'{\n "Default": {}\n}'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'Google OAuth2 Team Map', '{}');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GoogleOAuth2Detail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<GoogleOAuth2Detail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import JobsDetail from './JobsDetail';
|
||||
import JobsEdit from './JobsEdit';
|
||||
|
||||
function Jobs({ i18n }) {
|
||||
const baseUrl = '/settings/jobs';
|
||||
|
||||
const baseURL = '/settings/jobs';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`Jobs settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<JobsDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<JobsEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View Jobs settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,57 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import Jobs from './Jobs';
|
||||
|
||||
import { SettingsAPI } from '../../../api';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<Jobs />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<Jobs />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('Jobs settings');
|
||||
|
||||
test('should render jobs details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/jobs/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Jobs />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('JobsDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render jobs edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/jobs/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Jobs />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('JobsEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/jobs/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Jobs />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,108 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import { sortNestedDetails } from '../../shared/settingUtils';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function JobsDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: jobs } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('jobs');
|
||||
|
||||
const {
|
||||
ALLOW_JINJA_IN_EXTRA_VARS,
|
||||
AWX_ISOLATED_KEY_GENERATION,
|
||||
AWX_ISOLATED_PRIVATE_KEY,
|
||||
AWX_ISOLATED_PUBLIC_KEY,
|
||||
STDOUT_MAX_BYTES_DISPLAY,
|
||||
EVENT_STDOUT_MAX_BYTES_DISPLAY,
|
||||
...jobsData
|
||||
} = data;
|
||||
|
||||
const mergedData = {};
|
||||
Object.keys(jobsData).forEach(key => {
|
||||
mergedData[key] = options[key];
|
||||
mergedData[key].value = jobsData[key];
|
||||
});
|
||||
|
||||
return sortNestedDetails(mergedData);
|
||||
}, [options]),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/jobs/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/jobs/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && jobs && (
|
||||
<DetailList>
|
||||
{jobs.map(([key, detail]) => {
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={detail?.help_text}
|
||||
label={detail?.label}
|
||||
type={detail?.type}
|
||||
unit={detail?.unit}
|
||||
value={detail?.value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/jobs/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,122 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import mockJobSettings from '../../shared/data.jobSettings.json';
|
||||
import JobsDetail from './JobsDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: mockJobSettings,
|
||||
});
|
||||
|
||||
describe('<JobsDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<JobsDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<JobsDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('JobsDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'Enable job isolation', 'On');
|
||||
assertDetail(wrapper, 'Job execution path', '/tmp');
|
||||
assertDetail(wrapper, 'Isolated status check interval', '1 seconds');
|
||||
assertDetail(wrapper, 'Isolated launch timeout', '600 seconds');
|
||||
assertDetail(wrapper, 'Isolated connection timeout', '10 seconds');
|
||||
assertDetail(wrapper, 'Isolated host key checking', 'Off');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Enable detailed resource profiling on all playbook runs',
|
||||
'Off'
|
||||
);
|
||||
assertDetail(wrapper, 'Run Project Updates With Higher Verbosity', 'Off');
|
||||
assertDetail(wrapper, 'Enable Role Download', 'On');
|
||||
assertDetail(wrapper, 'Enable Collection(s) Download', 'On');
|
||||
assertDetail(wrapper, 'Follow symlinks', 'Off');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Ignore Ansible Galaxy SSL Certificate Verification',
|
||||
'Off'
|
||||
);
|
||||
assertDetail(wrapper, 'Maximum Scheduled Jobs', '10');
|
||||
assertDetail(wrapper, 'Default Job Timeout', '0 seconds');
|
||||
assertDetail(wrapper, 'Default Inventory Update Timeout', '0 seconds');
|
||||
assertDetail(wrapper, 'Default Project Update Timeout', '0 seconds');
|
||||
assertDetail(wrapper, 'Per-Host Ansible Fact Cache Timeout', '0 seconds');
|
||||
assertDetail(wrapper, 'Maximum number of forks per job', '200');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'Ansible Modules Allowed for Ad Hoc Jobs',
|
||||
'[\n "command"\n]'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'Paths to hide from isolated jobs', '[]');
|
||||
assertVariableDetail(wrapper, 'Paths to expose to isolated jobs', '[]');
|
||||
assertVariableDetail(wrapper, 'Extra Environment Variables', '{}');
|
||||
assertVariableDetail(wrapper, 'Ansible Callback Plugins', '[]');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<JobsDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<JobsDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import LDAPDetail from './LDAPDetail';
|
||||
import LDAPEdit from './LDAPEdit';
|
||||
|
||||
function LDAP({ i18n }) {
|
||||
const baseUrl = '/settings/ldap';
|
||||
const baseURL = '/settings/ldap';
|
||||
const baseRoute = useRouteMatch({ path: '/settings/ldap', exact: true });
|
||||
const categoryRoute = useRouteMatch({
|
||||
path: '/settings/ldap/:category',
|
||||
exact: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`LDAP settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
{baseRoute && <Redirect to={`${baseURL}/default/details`} exact />}
|
||||
{categoryRoute && (
|
||||
<Redirect
|
||||
to={`${baseURL}/${categoryRoute.params.category}/details`}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
<Route path={`${baseURL}/:category/details`}>
|
||||
<LDAPDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/:category/edit`}>
|
||||
<LDAPEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/default/details`}>
|
||||
{i18n._(t`View LDAP Settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import LDAP from './LDAP';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<LDAP />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<LDAP />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('LDAP settings');
|
||||
|
||||
test('should render ldap details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ldap/'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<LDAP />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('LDAPDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render ldap edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ldap/default/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<LDAP />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('LDAPEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ldap/foo/bar'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<LDAP />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,172 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link, Redirect, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import SettingDetail from '../../shared';
|
||||
import { sortNestedDetails } from '../../shared/settingUtils';
|
||||
|
||||
function filterByPrefix(data, prefix) {
|
||||
return Object.keys(data)
|
||||
.filter(key => key.includes(prefix))
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = data[key];
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function LDAPDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
const {
|
||||
path,
|
||||
params: { category },
|
||||
} = useRouteMatch('/settings/ldap/:category/details');
|
||||
|
||||
const { isLoading, error, request, result: LDAPDetails } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('ldap');
|
||||
|
||||
const mergedData = {};
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key.includes('_CONNECTION_OPTIONS')) {
|
||||
return;
|
||||
}
|
||||
mergedData[key] = options[key];
|
||||
mergedData[key].value = data[key];
|
||||
});
|
||||
|
||||
const ldap1 = filterByPrefix(mergedData, 'AUTH_LDAP_1_');
|
||||
const ldap2 = filterByPrefix(mergedData, 'AUTH_LDAP_2_');
|
||||
const ldap3 = filterByPrefix(mergedData, 'AUTH_LDAP_3_');
|
||||
const ldap4 = filterByPrefix(mergedData, 'AUTH_LDAP_4_');
|
||||
const ldap5 = filterByPrefix(mergedData, 'AUTH_LDAP_5_');
|
||||
const ldapDefault = Object.assign({}, mergedData);
|
||||
Object.keys({ ...ldap1, ...ldap2, ...ldap3, ...ldap4, ...ldap5 }).forEach(
|
||||
keyToOmit => {
|
||||
delete ldapDefault[keyToOmit];
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
default: sortNestedDetails(ldapDefault),
|
||||
1: sortNestedDetails(ldap1),
|
||||
2: sortNestedDetails(ldap2),
|
||||
3: sortNestedDetails(ldap3),
|
||||
4: sortNestedDetails(ldap4),
|
||||
5: sortNestedDetails(ldap5),
|
||||
};
|
||||
}, [options]),
|
||||
{
|
||||
default: null,
|
||||
1: null,
|
||||
2: null,
|
||||
3: null,
|
||||
4: null,
|
||||
5: null,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const baseURL = '/settings/ldap';
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Default`),
|
||||
link: `${baseURL}/default/details`,
|
||||
id: 0,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`LDAP1`),
|
||||
link: `${baseURL}/1/details`,
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`LDAP2`),
|
||||
link: `${baseURL}/2/details`,
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`LDAP3`),
|
||||
link: `${baseURL}/3/details`,
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`LDAP4`),
|
||||
link: `${baseURL}/4/details`,
|
||||
id: 4,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`LDAP5`),
|
||||
link: `${baseURL}/5/details`,
|
||||
id: 5,
|
||||
},
|
||||
];
|
||||
|
||||
if (!Object.keys(LDAPDetails).includes(category)) {
|
||||
return <Redirect from={path} to={`${baseURL}/default/details`} exact />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/ldap/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
<>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && !Object.values(LDAPDetails)?.includes(null) && (
|
||||
<DetailList>
|
||||
{LDAPDetails[category].map(([key, detail]) => {
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={detail?.help_text}
|
||||
label={detail?.label}
|
||||
type={detail?.type}
|
||||
unit={detail?.unit}
|
||||
value={detail?.value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
</>
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to={`${baseURL}/${category}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,151 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import mockLDAP from '../../shared/data.ldapSettings.json';
|
||||
import LDAPDetail from './LDAPDetail';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP });
|
||||
|
||||
describe('<LDAPDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<LDAPDetail />);
|
||||
describe('Default', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
useRouteMatch.mockImplementation(() => ({
|
||||
url: '/settings/ldap/default/details',
|
||||
path: '/settings/ldap/:category/details',
|
||||
params: { category: 'default' },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LDAPDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('LDAPDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = [
|
||||
'Back to Settings',
|
||||
'Default',
|
||||
'LDAP1',
|
||||
'LDAP2',
|
||||
'LDAP3',
|
||||
'LDAP4',
|
||||
'LDAP5',
|
||||
];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'LDAP Server URI', 'ldap://ldap.example.com');
|
||||
assertDetail(wrapper, 'LDAP Bind DN', 'cn=eng_user');
|
||||
assertDetail(wrapper, 'LDAP Bind Password', 'Encrypted');
|
||||
assertDetail(wrapper, 'LDAP Start TLS', 'Off');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'LDAP User DN Template',
|
||||
'uid=%(user)s,OU=Users,DC=example,DC=com'
|
||||
);
|
||||
assertDetail(wrapper, 'LDAP Group Type', 'MemberDNGroupType');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'LDAP Require Group',
|
||||
'CN=Tower Users,OU=Users,DC=example,DC=com'
|
||||
);
|
||||
assertDetail(wrapper, 'LDAP Deny Group', 'Not configured');
|
||||
assertVariableDetail(wrapper, 'LDAP User Search', '[]');
|
||||
assertVariableDetail(wrapper, 'LDAP User Attribute Map', '{}');
|
||||
assertVariableDetail(wrapper, 'LDAP Group Search', '[]');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'LDAP Group Type Parameters',
|
||||
'{\n "name_attr": "cn",\n "member_attr": "member"\n}'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'LDAP User Flags By Group', '{}');
|
||||
assertVariableDetail(wrapper, 'LDAP Organization Map', '{}');
|
||||
assertVariableDetail(wrapper, 'LDAP Team Map', '{}');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LDAPDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LDAPDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('LDAPDetail').length).toBe(1);
|
||||
|
||||
describe('Redirect', () => {
|
||||
test('should render redirect when user navigates to erroneous category', async () => {
|
||||
let wrapper;
|
||||
useRouteMatch.mockImplementation(() => ({
|
||||
url: '/settings/ldap/foo/details',
|
||||
path: '/settings/ldap/:category/details',
|
||||
params: { category: 'foo' },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LDAPDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'Redirect');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import LoggingDetail from './LoggingDetail';
|
||||
import LoggingEdit from './LoggingEdit';
|
||||
|
||||
function Logging({ i18n }) {
|
||||
const baseUrl = '/settings/logging';
|
||||
|
||||
const baseURL = '/settings/logging';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`Logging settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<LoggingDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<LoggingEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}`}>{i18n._(t`View Logging settings`)}</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import Logging from './Logging';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<Logging />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<Logging />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('Logging settings');
|
||||
|
||||
test('should render logging details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/logging/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Logging />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('LoggingDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render logging edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/logging/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Logging />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('LoggingEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/logging/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Logging />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,112 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import SettingDetail from '../../shared';
|
||||
import { sortNestedDetails, pluck } from '../../shared/settingUtils';
|
||||
|
||||
function LoggingDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: logging } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('logging');
|
||||
|
||||
const loggingData = pluck(
|
||||
data,
|
||||
'LOG_AGGREGATOR_ENABLED',
|
||||
'LOG_AGGREGATOR_HOST',
|
||||
'LOG_AGGREGATOR_INDIVIDUAL_FACTS',
|
||||
'LOG_AGGREGATOR_LEVEL',
|
||||
'LOG_AGGREGATOR_LOGGERS',
|
||||
'LOG_AGGREGATOR_PASSWORD',
|
||||
'LOG_AGGREGATOR_PORT',
|
||||
'LOG_AGGREGATOR_PROTOCOL',
|
||||
'LOG_AGGREGATOR_TCP_TIMEOUT',
|
||||
'LOG_AGGREGATOR_TYPE',
|
||||
'LOG_AGGREGATOR_USERNAME',
|
||||
'LOG_AGGREGATOR_VERIFY_CERT'
|
||||
);
|
||||
|
||||
const mergedData = {};
|
||||
Object.keys(loggingData).forEach(key => {
|
||||
mergedData[key] = options[key];
|
||||
mergedData[key].value = loggingData[key];
|
||||
});
|
||||
|
||||
return sortNestedDetails(mergedData);
|
||||
}, [options]),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/logging/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/logging/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && logging && (
|
||||
<DetailList>
|
||||
{logging.map(([key, detail]) => (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={detail?.help_text}
|
||||
label={detail?.label}
|
||||
type={detail?.type}
|
||||
unit={detail?.unit}
|
||||
value={detail?.value}
|
||||
/>
|
||||
))}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/logging/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,107 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import mockLogSettings from '../../shared/data.logSettings.json';
|
||||
import LoggingDetail from './LoggingDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: mockLogSettings,
|
||||
});
|
||||
|
||||
describe('<LoggingDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<LoggingDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LoggingDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('LoggingDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'Enable External Logging', 'Off');
|
||||
assertDetail(wrapper, 'Logging Aggregator', 'https://mocklog');
|
||||
assertDetail(wrapper, 'Logging Aggregator Port', '1234');
|
||||
assertDetail(wrapper, 'Logging Aggregator Type', 'logstash');
|
||||
assertDetail(wrapper, 'Logging Aggregator Username', 'logging_name');
|
||||
assertDetail(wrapper, 'Logging Aggregator Password/Token', 'Encrypted');
|
||||
assertDetail(wrapper, 'Log System Tracking Facts Individually', 'Off');
|
||||
assertDetail(wrapper, 'Logging Aggregator Protocol', 'https');
|
||||
assertDetail(wrapper, 'TCP Connection Timeout', '5 seconds');
|
||||
assertDetail(wrapper, 'Logging Aggregator Level Threshold', 'INFO');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Enable/disable HTTPS certificate verification',
|
||||
'On'
|
||||
);
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'Loggers Sending Data to Log Aggregator Form',
|
||||
'[\n "activity_stream",\n "system_tracking"\n]'
|
||||
);
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LoggingDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<LoggingDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import MiscSystemDetail from './MiscSystemDetail';
|
||||
import MiscSystemEdit from './MiscSystemEdit';
|
||||
|
||||
function MiscSystem({ i18n }) {
|
||||
const baseUrl = '/settings/miscellaneous_system';
|
||||
|
||||
const baseURL = '/settings/miscellaneous_system';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`Miscellaneous system settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<MiscSystemDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<MiscSystemEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View Miscellaneous System settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,18 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import MiscSystem from './MiscSystem';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<MiscSystem />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<MiscSystem />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain(
|
||||
'Miscellaneous system settings'
|
||||
);
|
||||
|
||||
test('should render miscellaneous system details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/miscellaneous_system/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<MiscSystem />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('MiscSystemDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render miscellaneous system edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/miscellaneous_system/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<MiscSystem />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('MiscSystemEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/miscellaneous_system/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<MiscSystem />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,153 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import SettingDetail from '../../shared';
|
||||
import { sortNestedDetails, pluck } from '../../shared/settingUtils';
|
||||
|
||||
function MiscSystemDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: allOptions } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: system } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('all');
|
||||
|
||||
const {
|
||||
OAUTH2_PROVIDER: {
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
AUTHORIZATION_CODE_EXPIRE_SECONDS,
|
||||
},
|
||||
...pluckedSystemData
|
||||
} = pluck(
|
||||
data,
|
||||
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS',
|
||||
'AUTH_BASIC_ENABLED',
|
||||
'AUTOMATION_ANALYTICS_GATHER_INTERVAL',
|
||||
'AUTOMATION_ANALYTICS_URL',
|
||||
'CUSTOM_VENV_PATHS',
|
||||
'INSIGHTS_TRACKING_STATE',
|
||||
'LOGIN_REDIRECT_OVERRIDE',
|
||||
'MANAGE_ORGANIZATION_AUTH',
|
||||
'OAUTH2_PROVIDER',
|
||||
'ORG_ADMINS_CAN_SEE_ALL_USERS',
|
||||
'REDHAT_PASSWORD',
|
||||
'REDHAT_USERNAME',
|
||||
'REMOTE_HOST_HEADERS',
|
||||
'SESSIONS_PER_USER',
|
||||
'SESSION_COOKIE_AGE',
|
||||
'TOWER_URL_BASE'
|
||||
);
|
||||
|
||||
const systemData = {
|
||||
...pluckedSystemData,
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
AUTHORIZATION_CODE_EXPIRE_SECONDS,
|
||||
};
|
||||
|
||||
const {
|
||||
OAUTH2_PROVIDER: OAUTH2_PROVIDER_OPTIONS,
|
||||
...options
|
||||
} = allOptions;
|
||||
|
||||
const systemOptions = {
|
||||
...options,
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS: {
|
||||
...OAUTH2_PROVIDER_OPTIONS,
|
||||
type: OAUTH2_PROVIDER_OPTIONS.child.type,
|
||||
label: i18n._(t`Access Token Expiration`),
|
||||
},
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS: {
|
||||
...OAUTH2_PROVIDER_OPTIONS,
|
||||
type: OAUTH2_PROVIDER_OPTIONS.child.type,
|
||||
label: i18n._(t`Refresh Token Expiration`),
|
||||
},
|
||||
AUTHORIZATION_CODE_EXPIRE_SECONDS: {
|
||||
...OAUTH2_PROVIDER_OPTIONS,
|
||||
type: OAUTH2_PROVIDER_OPTIONS.child.type,
|
||||
label: i18n._(t`Authorization Code Expiration`),
|
||||
},
|
||||
};
|
||||
|
||||
const mergedData = {};
|
||||
Object.keys(systemData).forEach(key => {
|
||||
mergedData[key] = systemOptions[key];
|
||||
mergedData[key].value = systemData[key];
|
||||
});
|
||||
return sortNestedDetails(mergedData);
|
||||
}, [allOptions, i18n]),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/miscellaneous_system/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/miscellaneous_system/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && system && (
|
||||
<DetailList>
|
||||
{system.map(([key, detail]) => (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={detail?.help_text}
|
||||
label={detail?.label}
|
||||
type={detail?.type}
|
||||
unit={detail?.unit}
|
||||
value={detail?.value}
|
||||
/>
|
||||
))}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/miscellaneous_system/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,148 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import MiscSystemDetail from './MiscSystemDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: false,
|
||||
AUTH_BASIC_ENABLED: true,
|
||||
AUTOMATION_ANALYTICS_GATHER_INTERVAL: 14400,
|
||||
AUTOMATION_ANALYTICS_URL: 'https://example.com',
|
||||
CUSTOM_VENV_PATHS: [],
|
||||
INSIGHTS_TRACKING_STATE: false,
|
||||
LOGIN_REDIRECT_OVERRIDE: 'https://redirect.com',
|
||||
MANAGE_ORGANIZATION_AUTH: true,
|
||||
OAUTH2_PROVIDER: {
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS: 1,
|
||||
AUTHORIZATION_CODE_EXPIRE_SECONDS: 2,
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS: 3,
|
||||
},
|
||||
ORG_ADMINS_CAN_SEE_ALL_USERS: true,
|
||||
REDHAT_PASSWORD: '$encrypted$',
|
||||
REDHAT_USERNAME: 'mock name',
|
||||
REMOTE_HOST_HEADERS: [],
|
||||
SESSIONS_PER_USER: -1,
|
||||
SESSION_COOKIE_AGE: 30000000000,
|
||||
TOWER_URL_BASE: 'https://towerhost',
|
||||
},
|
||||
});
|
||||
|
||||
describe('<MiscSystemDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<MiscSystemDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<MiscSystemDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('MiscSystemDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'Access Token Expiration', '1 seconds');
|
||||
assertDetail(wrapper, 'All Users Visible to Organization Admins', 'On');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Allow External Users to Create OAuth2 Tokens',
|
||||
'Off'
|
||||
);
|
||||
assertDetail(wrapper, 'Authorization Code Expiration', '2 seconds');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Automation Analytics Gather Interval',
|
||||
'14400 seconds'
|
||||
);
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Automation Analytics upload URL',
|
||||
'https://example.com'
|
||||
);
|
||||
assertDetail(wrapper, 'Base URL of the Tower host', 'https://towerhost');
|
||||
assertDetail(wrapper, 'Enable HTTP Basic Auth', 'On');
|
||||
assertDetail(wrapper, 'Gather data for Automation Analytics', 'Off');
|
||||
assertDetail(wrapper, 'Idle Time Force Log Out', '30000000000 seconds');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Login redirect override URL',
|
||||
'https://redirect.com'
|
||||
);
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Maximum number of simultaneous logged in sessions',
|
||||
'-1'
|
||||
);
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'Organization Admins Can Manage Users and Teams',
|
||||
'On'
|
||||
);
|
||||
assertDetail(wrapper, 'Red Hat customer password', 'Encrypted');
|
||||
assertDetail(wrapper, 'Red Hat customer username', 'mock name');
|
||||
assertDetail(wrapper, 'Refresh Token Expiration', '3 seconds');
|
||||
assertVariableDetail(wrapper, 'Remote Host Headers', '[]');
|
||||
assertVariableDetail(wrapper, 'Custom virtual environment paths', '[]');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<MiscSystemDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<MiscSystemDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
36
awx/ui_next/src/screens/Setting/RADIUS/RADIUS.jsx
Normal file
36
awx/ui_next/src/screens/Setting/RADIUS/RADIUS.jsx
Normal 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);
|
||||
56
awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx
Normal file
56
awx/ui_next/src/screens/Setting/RADIUS/RADIUS.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './RADIUSDetail';
|
||||
@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
|
||||
function RadiusEdit({ i18n }) {
|
||||
function RADIUSEdit({ i18n }) {
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Edit form coming soon :)`)}
|
||||
@ -22,4 +22,4 @@ function RadiusEdit({ i18n }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(RadiusEdit);
|
||||
export default withI18n()(RADIUSEdit);
|
||||
@ -1,16 +1,16 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import RadiusEdit from './RadiusEdit';
|
||||
import RADIUSEdit from './RADIUSEdit';
|
||||
|
||||
describe('<RadiusEdit />', () => {
|
||||
describe('<RADIUSEdit />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<RadiusEdit />);
|
||||
wrapper = mountWithContexts(<RADIUSEdit />);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('RadiusEdit').length).toBe(1);
|
||||
expect(wrapper.find('RADIUSEdit').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './RADIUSEdit';
|
||||
1
awx/ui_next/src/screens/Setting/RADIUS/index.js
Normal file
1
awx/ui_next/src/screens/Setting/RADIUS/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './RADIUS';
|
||||
@ -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);
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './RadiusDetail';
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './RadiusEdit';
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './Radius';
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import SAMLDetail from './SAMLDetail';
|
||||
import SAMLEdit from './SAMLEdit';
|
||||
|
||||
function SAML({ i18n }) {
|
||||
const baseUrl = '/settings/saml';
|
||||
|
||||
const baseURL = '/settings/saml';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`SAML settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<SAMLDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<SAMLEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View SAML settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,56 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import SAML from './SAML';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<SAML />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<SAML />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('SAML settings');
|
||||
|
||||
test('should render SAML details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/saml/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<SAML />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('SAMLDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render SAML edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/saml/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<SAML />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/saml/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<SAML />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,91 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function SAMLDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: saml } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('saml');
|
||||
return data;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/saml/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/saml/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && saml && (
|
||||
<DetailList>
|
||||
{Object.keys(saml).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={saml?.[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/saml/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,148 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import {
|
||||
assertDetail,
|
||||
assertVariableDetail,
|
||||
} from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import SAMLDetail from './SAMLDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/',
|
||||
SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/',
|
||||
SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id',
|
||||
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
|
||||
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '',
|
||||
SOCIAL_AUTH_SAML_ORG_INFO: {},
|
||||
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
|
||||
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
|
||||
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
|
||||
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {},
|
||||
SOCIAL_AUTH_SAML_SP_EXTRA: {},
|
||||
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
|
||||
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||
},
|
||||
});
|
||||
|
||||
describe('<SAMLDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<SAMLDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<SAMLDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('SAMLDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'SAML Assertion Consumer Service (ACS) URL',
|
||||
'https://towerhost/sso/complete/saml/'
|
||||
);
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider Metadata URL',
|
||||
'https://towerhost/sso/metadata/saml/'
|
||||
);
|
||||
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider Public Certificate',
|
||||
'mock_cert'
|
||||
);
|
||||
assertDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider Private Key',
|
||||
'Not configured'
|
||||
);
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider Organization Info',
|
||||
'{}'
|
||||
);
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider Technical Contact',
|
||||
'{}'
|
||||
);
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider Support Contact',
|
||||
'{}'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'SAML Enabled Identity Providers', '{}');
|
||||
assertVariableDetail(wrapper, 'SAML Security Config', '{}');
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'SAML Service Provider extra configuration data',
|
||||
'{}'
|
||||
);
|
||||
assertVariableDetail(
|
||||
wrapper,
|
||||
'SAML IDP to extra_data attribute mapping',
|
||||
'[]'
|
||||
);
|
||||
assertVariableDetail(wrapper, 'SAML Organization Map', '{}');
|
||||
assertVariableDetail(wrapper, 'SAML Team Map', '{}');
|
||||
assertVariableDetail(wrapper, 'SAML Organization Attribute Mapping', '{}');
|
||||
assertVariableDetail(wrapper, 'SAML Team Attribute Mapping', '{}');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<SAMLDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<SAMLDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -75,7 +75,7 @@ function SettingList({ i18n }) {
|
||||
path: '/settings/ldap',
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Radius settings`),
|
||||
title: i18n._(t`RADIUS settings`),
|
||||
path: '/settings/radius',
|
||||
},
|
||||
{
|
||||
@ -107,11 +107,11 @@ function SettingList({ i18n }) {
|
||||
id: 'system',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`Miscellaneous system settings`),
|
||||
title: i18n._(t`Miscellaneous System settings`),
|
||||
path: '/settings/miscellaneous_system',
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Activity stream settings`),
|
||||
title: i18n._(t`Activity Stream settings`),
|
||||
path: '/settings/activity_stream',
|
||||
},
|
||||
{
|
||||
@ -121,15 +121,15 @@ function SettingList({ i18n }) {
|
||||
],
|
||||
},
|
||||
{
|
||||
header: i18n._(t`User interface`),
|
||||
header: i18n._(t`User Interface`),
|
||||
description: i18n._(
|
||||
t`Set preferences for data collection, logos, and logins`
|
||||
),
|
||||
id: 'user_interface',
|
||||
id: 'ui',
|
||||
routes: [
|
||||
{
|
||||
title: i18n._(t`User interface settings`),
|
||||
path: '/settings/user_interface',
|
||||
title: i18n._(t`User Interface settings`),
|
||||
path: '/settings/ui',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Link, Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||
import ActivityStream from './ActivityStream';
|
||||
import AzureAD from './AzureAD';
|
||||
@ -14,34 +15,120 @@ import LDAP from './LDAP';
|
||||
import License from './License';
|
||||
import Logging from './Logging';
|
||||
import MiscSystem from './MiscSystem';
|
||||
import Radius from './Radius';
|
||||
import RADIUS from './RADIUS';
|
||||
import SAML from './SAML';
|
||||
import SettingList from './SettingList';
|
||||
import TACACS from './TACACS';
|
||||
import UI from './UI';
|
||||
import { SettingsProvider } from '../../contexts/Settings';
|
||||
import { useConfig } from '../../contexts/Config';
|
||||
import { SettingsAPI } from '../../api';
|
||||
import useRequest from '../../util/useRequest';
|
||||
|
||||
function Settings({ i18n }) {
|
||||
const { license_info = {} } = useConfig();
|
||||
const { license_info = {}, me } = useConfig();
|
||||
|
||||
const { request, result, isLoading, error } = useRequest(
|
||||
useCallback(async () => {
|
||||
const response = await SettingsAPI.readAllOptions();
|
||||
return response.data.actions;
|
||||
}, [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const breadcrumbConfig = {
|
||||
'/settings': i18n._(t`Settings`),
|
||||
'/settings/activity_stream': i18n._(t`Activity stream`),
|
||||
'/settings/activity_stream': i18n._(t`Activity Stream`),
|
||||
'/settings/activity_stream/details': i18n._(t`Details`),
|
||||
'/settings/activity_stream/edit': i18n._(t`Edit Details`),
|
||||
'/settings/azure': i18n._(t`Azure AD`),
|
||||
'/settings/github': i18n._(t`GitHub`),
|
||||
'/settings/azure/details': i18n._(t`Details`),
|
||||
'/settings/azure/edit': i18n._(t`Edit Details`),
|
||||
'/settings/github': null,
|
||||
'/settings/github/default': i18n._(t`GitHub Default`),
|
||||
'/settings/github/default/details': i18n._(t`Details`),
|
||||
'/settings/github/default/edit': i18n._(t`Edit Details`),
|
||||
'/settings/github/organization': i18n._(t`GitHub Organization`),
|
||||
'/settings/github/organization/details': i18n._(t`Details`),
|
||||
'/settings/github/organization/edit': i18n._(t`Edit Details`),
|
||||
'/settings/github/team': i18n._(t`GitHub Team`),
|
||||
'/settings/github/team/details': i18n._(t`Details`),
|
||||
'/settings/github/team/edit': i18n._(t`Edit Details`),
|
||||
'/settings/google_oauth2': i18n._(t`Google OAuth2`),
|
||||
'/settings/google_oauth2/details': i18n._(t`Details`),
|
||||
'/settings/google_oauth2/edit': i18n._(t`Edit Details`),
|
||||
'/settings/jobs': i18n._(t`Jobs`),
|
||||
'/settings/ldap': i18n._(t`LDAP`),
|
||||
'/settings/jobs/details': i18n._(t`Details`),
|
||||
'/settings/jobs/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ldap': null,
|
||||
'/settings/ldap/default': i18n._(t`LDAP Default`),
|
||||
'/settings/ldap/1': i18n._(t`LDAP 1`),
|
||||
'/settings/ldap/2': i18n._(t`LDAP 2`),
|
||||
'/settings/ldap/3': i18n._(t`LDAP 3`),
|
||||
'/settings/ldap/4': i18n._(t`LDAP 4`),
|
||||
'/settings/ldap/5': i18n._(t`LDAP 5`),
|
||||
'/settings/ldap/default/details': i18n._(t`Details`),
|
||||
'/settings/ldap/1/details': i18n._(t`Details`),
|
||||
'/settings/ldap/2/details': i18n._(t`Details`),
|
||||
'/settings/ldap/3/details': i18n._(t`Details`),
|
||||
'/settings/ldap/4/details': i18n._(t`Details`),
|
||||
'/settings/ldap/5/details': i18n._(t`Details`),
|
||||
'/settings/ldap/default/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ldap/1/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ldap/2/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ldap/3/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ldap/4/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ldap/5/edit': i18n._(t`Edit Details`),
|
||||
'/settings/license': i18n._(t`License`),
|
||||
'/settings/logging': i18n._(t`Logging`),
|
||||
'/settings/miscellaneous_system': i18n._(t`Miscellaneous system`),
|
||||
'/settings/radius': i18n._(t`Radius`),
|
||||
'/settings/logging/details': i18n._(t`Details`),
|
||||
'/settings/logging/edit': i18n._(t`Edit Details`),
|
||||
'/settings/miscellaneous_system': i18n._(t`Miscellaneous System`),
|
||||
'/settings/miscellaneous_system/details': i18n._(t`Details`),
|
||||
'/settings/miscellaneous_system/edit': i18n._(t`Edit Details`),
|
||||
'/settings/radius': i18n._(t`RADIUS`),
|
||||
'/settings/radius/details': i18n._(t`Details`),
|
||||
'/settings/radius/edit': i18n._(t`Edit Details`),
|
||||
'/settings/saml': i18n._(t`SAML`),
|
||||
'/settings/saml/details': i18n._(t`Details`),
|
||||
'/settings/saml/edit': i18n._(t`Edit Details`),
|
||||
'/settings/tacacs': i18n._(t`TACACS+`),
|
||||
'/settings/user_interface': i18n._(t`User interface`),
|
||||
'/settings/tacacs/details': i18n._(t`Details`),
|
||||
'/settings/tacacs/edit': i18n._(t`Edit Details`),
|
||||
'/settings/ui': i18n._(t`User Interface`),
|
||||
'/settings/ui/details': i18n._(t`Details`),
|
||||
'/settings/ui/edit': i18n._(t`Edit Details`),
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentError error={error} />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !result || !me) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me?.is_superuser && !me?.is_system_auditor) {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsProvider value={result}>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path="/settings/activity_stream">
|
||||
@ -76,7 +163,7 @@ function Settings({ i18n }) {
|
||||
<MiscSystem />
|
||||
</Route>
|
||||
<Route path="/settings/radius">
|
||||
<Radius />
|
||||
<RADIUS />
|
||||
</Route>
|
||||
<Route path="/settings/saml">
|
||||
<SAML />
|
||||
@ -84,7 +171,7 @@ function Settings({ i18n }) {
|
||||
<Route path="/settings/tacacs">
|
||||
<TACACS />
|
||||
</Route>
|
||||
<Route path="/settings/user_interface">
|
||||
<Route path="/settings/ui">
|
||||
<UI />
|
||||
</Route>
|
||||
<Route path="/settings" exact>
|
||||
@ -100,7 +187,7 @@ function Settings({ i18n }) {
|
||||
</PageSection>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
</SettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import TACACSDetail from './TACACSDetail';
|
||||
import TACACSEdit from './TACACSEdit';
|
||||
|
||||
function TACACS({ i18n }) {
|
||||
const baseUrl = '/settings/tacacs';
|
||||
|
||||
const baseURL = '/settings/tacacs';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`TACACS+ settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<TACACSDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<TACACSEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View TACACS+ settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,56 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import TACACS from './TACACS';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<TACACS />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<TACACS />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('TACACS+ settings');
|
||||
|
||||
test('should render TACACS+ details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/tacacs/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TACACS />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('TACACSDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render TACACS+ edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/tacacs/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TACACS />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('TACACSEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/tacacs/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<TACACS />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,91 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function TACACSDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: tacacs } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('tacacsplus');
|
||||
return data;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/tacacs/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/tacacs/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && tacacs && (
|
||||
<DetailList>
|
||||
{Object.keys(tacacs).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={tacacs?.[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/tacacs/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,94 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import { assertDetail } from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import TACACSDetail from './TACACSDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
TACACSPLUS_HOST: 'mockhost',
|
||||
TACACSPLUS_PORT: 49,
|
||||
TACACSPLUS_SECRET: '$encrypted$',
|
||||
TACACSPLUS_SESSION_TIMEOUT: 5,
|
||||
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
|
||||
},
|
||||
});
|
||||
|
||||
describe('<TACACSDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<TACACSDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<TACACSDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('TACACSDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'TACACS+ Server', 'mockhost');
|
||||
assertDetail(wrapper, 'TACACS+ Port', '49');
|
||||
assertDetail(wrapper, 'TACACS+ Secret', 'Encrypted');
|
||||
assertDetail(wrapper, 'TACACS+ Auth Session Timeout', '5 seconds');
|
||||
assertDetail(wrapper, 'TACACS+ Authentication Protocol', 'ascii');
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<TACACSDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<TACACSDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Link, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PageSection, Card } from '@patternfly/react-core';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import UIDetail from './UIDetail';
|
||||
import UIEdit from './UIEdit';
|
||||
|
||||
function UI({ i18n }) {
|
||||
const baseUrl = '/settings/ui';
|
||||
|
||||
const baseURL = '/settings/ui';
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{i18n._(t`User interface settings`)}
|
||||
<Switch>
|
||||
<Redirect from={baseUrl} to={`${baseUrl}/details`} exact />
|
||||
<Route path={`${baseUrl}/details`}>
|
||||
<Redirect from={baseURL} to={`${baseURL}/details`} exact />
|
||||
<Route path={`${baseURL}/details`}>
|
||||
<UIDetail />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/edit`}>
|
||||
<Route path={`${baseURL}/edit`}>
|
||||
<UIEdit />
|
||||
</Route>
|
||||
<Route key="not-found" path={`${baseURL}/*`}>
|
||||
<ContentError isNotFound>
|
||||
<Link to={`${baseURL}/details`}>
|
||||
{i18n._(t`View User Interface settings`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,16 +1,61 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsAPI } from '../../../api';
|
||||
import UI from './UI';
|
||||
|
||||
jest.mock('../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {},
|
||||
});
|
||||
|
||||
describe('<UI />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<UI />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('Card').text()).toContain('User interface settings');
|
||||
|
||||
test('should render user interface details', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ui/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UI />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('UIDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render user interface edit', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ui/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UI />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('UIEdit').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show content error when user navigates to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/settings/ui/foo'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<UI />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,106 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
||||
import ContentLoading from '../../../../components/ContentLoading';
|
||||
import ContentError from '../../../../components/ContentError';
|
||||
import RoutedTabs from '../../../../components/RoutedTabs';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { DetailList } from '../../../../components/DetailList';
|
||||
import { useConfig } from '../../../../contexts/Config';
|
||||
import { useSettings } from '../../../../contexts/Settings';
|
||||
import { pluck } from '../../shared/settingUtils';
|
||||
import SettingDetail from '../../shared';
|
||||
|
||||
function UIDetail({ i18n }) {
|
||||
const { me } = useConfig();
|
||||
const { GET: options } = useSettings();
|
||||
|
||||
const { isLoading, error, request, result: ui } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SettingsAPI.readCategory('ui');
|
||||
|
||||
const uiData = pluck(
|
||||
data,
|
||||
'PENDO_TRACKING_STATE',
|
||||
'CUSTOM_LOGO',
|
||||
'CUSTOM_LOGIN_INFO'
|
||||
);
|
||||
|
||||
return uiData;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Settings`)}
|
||||
</>
|
||||
),
|
||||
link: `/settings`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/settings/ui/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Change CUSTOM_LOGO type from string to image
|
||||
// to help SettingDetail render it as an <img>
|
||||
if (options?.CUSTOM_LOGO) {
|
||||
options.CUSTOM_LOGO.type = 'image';
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
{i18n._(t`Detail coming soon :)`)}
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/ui/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
</CardBody>
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
{isLoading && <ContentLoading />}
|
||||
{!isLoading && error && <ContentError error={error} />}
|
||||
{!isLoading && ui && (
|
||||
<DetailList>
|
||||
{Object.keys(ui).map(key => {
|
||||
const record = options?.[key];
|
||||
return (
|
||||
<SettingDetail
|
||||
key={key}
|
||||
id={key}
|
||||
helpText={record?.help_text}
|
||||
label={record?.label}
|
||||
type={record?.type}
|
||||
unit={record?.unit}
|
||||
value={ui?.[key]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DetailList>
|
||||
)}
|
||||
{me?.is_superuser && (
|
||||
<CardActionsRow>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to="/settings/ui/edit"
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
</CardActionsRow>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,93 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../../testUtils/enzymeHelpers';
|
||||
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||
import { SettingsAPI } from '../../../../api';
|
||||
import { assertDetail } from '../../shared/settingTestUtils';
|
||||
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||
import UIDetail from './UIDetail';
|
||||
|
||||
jest.mock('../../../../api/models/Settings');
|
||||
SettingsAPI.readCategory.mockResolvedValue({
|
||||
data: {
|
||||
CUSTOM_LOGIN_INFO: 'mock info',
|
||||
CUSTOM_LOGO: 'data:image/png',
|
||||
PENDO_TRACKING_STATE: 'off',
|
||||
},
|
||||
});
|
||||
|
||||
describe('<UIDetail />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<UIDetail />);
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UIDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
expect(wrapper.find('UIDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render expected tabs', () => {
|
||||
const expectedTabs = ['Back to Settings', 'Details'];
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected details', () => {
|
||||
assertDetail(wrapper, 'User Analytics Tracking State', 'off');
|
||||
assertDetail(wrapper, 'Custom Login Info', 'mock info');
|
||||
expect(wrapper.find('Detail[label="Custom Logo"] dt').text()).toBe(
|
||||
'Custom Logo'
|
||||
);
|
||||
expect(wrapper.find('Detail[label="Custom Logo"] dd img').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should hide edit button from non-superusers', async () => {
|
||||
const config = {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UIDetail />
|
||||
</SettingsProvider>,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should display content error when api throws error on initial render', async () => {
|
||||
SettingsAPI.readCategory.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SettingsProvider value={mockAllOptions.actions}>
|
||||
<UIDetail />
|
||||
</SettingsProvider>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
117
awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx
Normal file
117
awx/ui_next/src/screens/Setting/shared/SettingDetail.jsx
Normal 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;
|
||||
}
|
||||
);
|
||||
6500
awx/ui_next/src/screens/Setting/shared/data.allSettingOptions.json
Normal file
6500
awx/ui_next/src/screens/Setting/shared/data.allSettingOptions.json
Normal file
File diff suppressed because it is too large
Load Diff
37
awx/ui_next/src/screens/Setting/shared/data.jobSettings.json
Normal file
37
awx/ui_next/src/screens/Setting/shared/data.jobSettings.json
Normal 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
|
||||
}
|
||||
134
awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json
Normal file
134
awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json
Normal 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": {}
|
||||
}
|
||||
21
awx/ui_next/src/screens/Setting/shared/data.logSettings.json
Normal file
21
awx/ui_next/src/screens/Setting/shared/data.logSettings.json
Normal 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
|
||||
}
|
||||
1
awx/ui_next/src/screens/Setting/shared/index.js
Normal file
1
awx/ui_next/src/screens/Setting/shared/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './SettingDetail';
|
||||
15
awx/ui_next/src/screens/Setting/shared/settingTestUtils.js
Normal file
15
awx/ui_next/src/screens/Setting/shared/settingTestUtils.js
Normal 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);
|
||||
}
|
||||
17
awx/ui_next/src/screens/Setting/shared/settingUtils.js
Normal file
17
awx/ui_next/src/screens/Setting/shared/settingUtils.js
Normal 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] })));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user