Merge pull request #10465 from shanemcd/downstream-fixes

Downstream fixes

Reviewed-by: Alan Rominger <arominge@redhat.com>
Reviewed-by: Alex Corey <Alex.swansboro@gmail.com>
Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-06-16 21:44:52 +00:00 committed by GitHub
commit 026d5e6bdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 847 additions and 319 deletions

View File

@ -288,6 +288,11 @@ swagger: reports
check: black
api-lint:
BLACK_ARGS="--check" make black
flake8 awx
yamllint -s .
awx-link:
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/setup.py egg_info_dev
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link

View File

@ -729,11 +729,12 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
if self.is_detail_view:
resolved_ee = obj.resolve_execution_environment()
summary_fields['resolved_environment'] = {
field: getattr(resolved_ee, field, None)
for field in SUMMARIZABLE_FK_FIELDS['execution_environment']
if getattr(resolved_ee, field, None) is not None
}
if resolved_ee is not None:
summary_fields['resolved_environment'] = {
field: getattr(resolved_ee, field, None)
for field in SUMMARIZABLE_FK_FIELDS['execution_environment']
if getattr(resolved_ee, field, None) is not None
}
return summary_fields
@ -3425,6 +3426,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
'ask_limit_on_launch',
'webhook_service',
'webhook_credential',
'-execution_environment',
)
def get_related(self, obj):
@ -3451,6 +3453,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
survey_spec=self.reverse('api:workflow_job_template_survey_spec', kwargs={'pk': obj.pk}),
copy=self.reverse('api:workflow_job_template_copy', kwargs={'pk': obj.pk}),
)
res.pop('execution_environment', None) # EEs aren't meaningful for workflows
if obj.organization:
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
if obj.webhook_credential_id:
@ -3502,6 +3505,7 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
'allow_simultaneous',
'job_template',
'is_sliced_job',
'-execution_environment',
'-execution_node',
'-event_processing_finished',
'-controller_node',
@ -3515,6 +3519,7 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
def get_related(self, obj):
res = super(WorkflowJobSerializer, self).get_related(obj)
res.pop('execution_environment', None) # EEs aren't meaningful for workflows
if obj.workflow_job_template:
res['workflow_job_template'] = self.reverse('api:workflow_job_template_detail', kwargs={'pk': obj.workflow_job_template.pk})
res['notifications'] = self.reverse('api:workflow_job_notifications_list', kwargs={'pk': obj.pk})
@ -3539,7 +3544,7 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
class WorkflowJobListSerializer(WorkflowJobSerializer, UnifiedJobListSerializer):
class Meta:
fields = ('*', '-execution_node', '-controller_node')
fields = ('*', '-execution_environment', '-execution_node', '-controller_node')
class WorkflowJobCancelSerializer(WorkflowJobSerializer):

View File

@ -46,7 +46,7 @@ class TimingMiddleware(threading.local, MiddlewareMixin):
response['X-API-Total-Time'] = '%0.3fs' % total_time
if settings.AWX_REQUEST_PROFILE:
response['X-API-Profile-File'] = self.prof.stop()
perf_logger.info(
perf_logger.debug(
f'request: {request}, response_time: {response["X-API-Total-Time"]}',
extra=dict(python_objects=dict(request=request, response=response, X_API_TOTAL_TIME=response["X-API-Total-Time"])),
)

View File

@ -1225,6 +1225,10 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
def is_container_group_task(self):
return bool(self.instance_group and self.instance_group.is_container_group)
@property
def can_run_containerized(self):
return True
def _get_parent_field_name(self):
return 'inventory_source'

View File

@ -471,13 +471,6 @@ class ExecutionEnvironmentMixin(models.Model):
template = getattr(self, 'unified_job_template', None)
if template is not None and template.execution_environment is not None:
return template.execution_environment
wf_node = getattr(self, 'unified_job_node', None)
while wf_node is not None:
wf_template = wf_node.workflow_job.workflow_job_template
# NOTE: sliced workflow_jobs have a null workflow_job_template
if wf_template and wf_template.execution_environment is not None:
return wf_template.execution_environment
wf_node = getattr(wf_node.workflow_job, 'unified_job_node', None)
if getattr(self, 'project_id', None) and self.project.default_environment is not None:
return self.project.default_environment
if getattr(self, 'organization_id', None) and self.organization.default_environment is not None:

View File

@ -595,6 +595,9 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
def _get_related_jobs(self):
return WorkflowJob.objects.filter(workflow_job_template=self)
def resolve_execution_environment(self):
return None # EEs are not meaningful for workflows
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, WebhookMixin):
class Meta:

View File

@ -12,7 +12,11 @@ import { ErrorBoundary } from 'react-error-boundary';
import { I18nProvider } from '@lingui/react';
import { i18n } from '@lingui/core';
import { Card, PageSection } from '@patternfly/react-core';
import { ConfigProvider, useAuthorizedPath } from './contexts/Config';
import {
ConfigProvider,
useAuthorizedPath,
useUserProfile,
} from './contexts/Config';
import { SessionProvider, useSession } from './contexts/Session';
import AppContainer from './components/AppContainer';
import Background from './components/Background';
@ -38,6 +42,17 @@ function ErrorFallback({ error }) {
);
}
const RenderAppContainer = () => {
const userProfile = useUserProfile();
const navRouteConfig = getRouteConfig(userProfile);
return (
<AppContainer navRouteConfig={navRouteConfig}>
<AuthorizedRoutes routeConfig={navRouteConfig} />
</AppContainer>
);
};
const AuthorizedRoutes = ({ routeConfig }) => {
const isAuthorized = useAuthorizedPath();
const match = useRouteMatch();
@ -150,9 +165,7 @@ function App() {
</Route>
<ProtectedRoute>
<ConfigProvider>
<AppContainer navRouteConfig={getRouteConfig()}>
<AuthorizedRoutes routeConfig={getRouteConfig()} />
</AppContainer>
<RenderAppContainer />
</ConfigProvider>
</ProtectedRoute>
</Switch>

View File

@ -27,7 +27,7 @@ class NavExpandableGroup extends Component {
render() {
const { groupId, groupTitle, routes } = this.props;
if (routes.length === 1) {
if (routes.length === 1 && groupId === 'settings') {
const [{ path }] = routes;
return (
<NavItem itemId={groupId} isActive={this.isActivePath(path)} key={path}>

View File

@ -127,48 +127,44 @@ function PageHeaderToolbar({
]}
/>
</PageHeaderToolsItem>
<Tooltip position="left" content={<div>{t`User`}</div>}>
<PageHeaderToolsItem>
<Dropdown
id="toolbar-user-dropdown"
isPlain
isOpen={isUserOpen}
position={DropdownPosition.right}
onSelect={handleUserSelect}
toggle={
<DropdownToggle onToggle={setIsUserOpen}>
<UserIcon />
{loggedInUser && (
<span style={{ marginLeft: '10px' }}>
{loggedInUser.username}
</span>
)}
</DropdownToggle>
}
dropdownItems={[
<DropdownItem
key="user"
aria-label={t`User details`}
href={
loggedInUser
? `#/users/${loggedInUser.id}/details`
: '#/home'
}
>
{t`User Details`}
</DropdownItem>,
<DropdownItem
key="logout"
component="button"
onClick={onLogoutClick}
id="logout-button"
>
{t`Logout`}
</DropdownItem>,
]}
/>
</PageHeaderToolsItem>
</Tooltip>
<PageHeaderToolsItem>
<Dropdown
id="toolbar-user-dropdown"
isPlain
isOpen={isUserOpen}
position={DropdownPosition.right}
onSelect={handleUserSelect}
toggle={
<DropdownToggle onToggle={setIsUserOpen}>
<UserIcon />
{loggedInUser && (
<span style={{ marginLeft: '10px' }}>
{loggedInUser.username}
</span>
)}
</DropdownToggle>
}
dropdownItems={[
<DropdownItem
key="user"
aria-label={t`User details`}
href={
loggedInUser ? `#/users/${loggedInUser.id}/details` : '#/home'
}
>
{t`User Details`}
</DropdownItem>,
<DropdownItem
key="logout"
component="button"
onClick={onLogoutClick}
id="logout-button"
>
{t`Logout`}
</DropdownItem>,
]}
/>
</PageHeaderToolsItem>
</PageHeaderToolsGroup>
</PageHeaderTools>
);

View File

@ -54,6 +54,7 @@ describe('<AssociateModal />', () => {
test('should fetch and render list items', () => {
expect(fetchRequest).toHaveBeenCalledTimes(1);
expect(optionsRequest).toHaveBeenCalledTimes(1);
expect(wrapper.find('CheckboxListItem').length).toBe(3);
});

View File

@ -21,6 +21,7 @@ function ExecutionEnvironmentDetail({
isDefaultEnvironment,
virtualEnvironment,
verifyMissingVirtualEnv,
helpText,
}) {
const label = isDefaultEnvironment
? t`Default Execution Environment`
@ -37,6 +38,7 @@ function ExecutionEnvironmentDetail({
{executionEnvironment.name}
</Link>
}
helpText={helpText}
dataCy="execution-environment-detail"
/>
);
@ -95,6 +97,7 @@ ExecutionEnvironmentDetail.propTypes = {
isDefaultEnvironment: bool,
virtualEnvironment: string,
verifyMissingVirtualEnv: bool,
helpText: string,
};
ExecutionEnvironmentDetail.defaultProps = {
@ -102,6 +105,7 @@ ExecutionEnvironmentDetail.defaultProps = {
executionEnvironment: null,
virtualEnvironment: '',
verifyMissingVirtualEnv: true,
helpText: '',
};
export default ExecutionEnvironmentDetail;

View File

@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { bool, func, shape } from 'prop-types';
import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import { Form, FormGroup } from '@patternfly/react-core';
import { Form, FormGroup, Tooltip } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../FormField';
import FormActionGroup from '../FormActionGroup/FormActionGroup';
import { VariablesField } from '../CodeEditor';
@ -11,7 +11,7 @@ import { FormColumnLayout, FormFullWidthLayout } from '../FormLayout';
import Popover from '../Popover';
import { required } from '../../util/validators';
const InventoryLookupField = () => {
const InventoryLookupField = ({ isDisabled }) => {
const { setFieldValue, setFieldTouched } = useFormikContext();
const [inventoryField, inventoryMeta, inventoryHelpers] = useField(
'inventory'
@ -25,6 +25,23 @@ const InventoryLookupField = () => {
[setFieldValue, setFieldTouched]
);
const renderInventoryLookup = (
<InventoryLookup
fieldId="inventory-lookup"
value={inventoryField.value}
onBlur={() => inventoryHelpers.setTouched()}
tooltip={t`Select the inventory that this host will belong to.`}
isValid={!inventoryMeta.touched || !inventoryMeta.error}
helperTextInvalid={inventoryMeta.error}
onChange={handleInventoryUpdate}
required
touched={inventoryMeta.touched}
error={inventoryMeta.error}
validate={required(t`Select a value for this field`)}
isDisabled={isDisabled}
/>
);
return (
<FormGroup
label={t`Inventory`}
@ -40,19 +57,13 @@ const InventoryLookupField = () => {
}
helperTextInvalid={inventoryMeta.error}
>
<InventoryLookup
fieldId="inventory-lookup"
value={inventoryField.value}
onBlur={() => inventoryHelpers.setTouched()}
tooltip={t`Select the inventory that this host will belong to.`}
isValid={!inventoryMeta.touched || !inventoryMeta.error}
helperTextInvalid={inventoryMeta.error}
onChange={handleInventoryUpdate}
required
touched={inventoryMeta.touched}
error={inventoryMeta.error}
validate={required(t`Select a value for this field`)}
/>
{isDisabled ? (
<Tooltip content={t`Unable to change inventory on a host`}>
{renderInventoryLookup}
</Tooltip>
) : (
renderInventoryLookup
)}
</FormGroup>
);
};
@ -63,6 +74,7 @@ const HostForm = ({
host,
isInventoryVisible,
submitError,
disableInventoryLookup,
}) => {
return (
<Formik
@ -91,7 +103,9 @@ const HostForm = ({
type="text"
label={t`Description`}
/>
{isInventoryVisible && <InventoryLookupField />}
{isInventoryVisible && (
<InventoryLookupField isDisabled={disableInventoryLookup} />
)}
<FormFullWidthLayout>
<VariablesField
id="host-variables"
@ -117,6 +131,7 @@ HostForm.propTypes = {
host: shape({}),
isInventoryVisible: bool,
submitError: shape({}),
disableInventoryLookup: bool,
};
HostForm.defaultProps = {
@ -131,6 +146,7 @@ HostForm.defaultProps = {
},
isInventoryVisible: true,
submitError: null,
disableInventoryLookup: false,
};
export { HostForm as _HostForm };

View File

@ -55,6 +55,7 @@ describe('<HostForm />', () => {
expect(wrapper.find('input#host-description').prop('value')).toEqual(
'new bar'
);
expect(wrapper.find('InventoryLookup').prop('isDisabled')).toEqual(false);
});
test('calls handleSubmit when form submitted', async () => {
@ -84,4 +85,18 @@ describe('<HostForm />', () => {
});
expect(wrapper.find('InventoryLookupField').length).toBe(0);
});
test('inventory lookup field should be disabled', async () => {
await act(async () => {
wrapper = mountWithContexts(
<HostForm
host={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
disableInventoryLookup
/>
);
});
expect(wrapper.find('InventoryLookup').prop('isDisabled')).toEqual(true);
});
});

View File

@ -14,6 +14,7 @@ import ChipGroup from '../ChipGroup';
import CredentialChip from '../CredentialChip';
import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail';
import { formatDateString } from '../../util/dates';
import { isJobRunning } from '../../util/jobs';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
import JobCancelButton from '../JobCancelButton';
@ -32,7 +33,7 @@ function JobListItem({
const jobTypes = {
project_update: t`Source Control Update`,
inventory_update: t`Inventory Sync`,
job: t`Playbook Run`,
job: job.job_type === 'check' ? t`Playbook Check` : t`Playbook Run`,
ad_hoc_command: t`Command`,
system_job: t`Management Job`,
workflow_job: t`Workflow Job`,
@ -202,10 +203,12 @@ function JobListItem({
dataCy={`job-${job.id}-project`}
/>
)}
<ExecutionEnvironmentDetail
executionEnvironment={execution_environment}
verifyMissingVirtualEnv={false}
/>
{job.type !== 'workflow_job' && !isJobRunning(job.status) && (
<ExecutionEnvironmentDetail
executionEnvironment={execution_environment}
verifyMissingVirtualEnv={false}
/>
)}
{credentials && credentials.length > 0 && (
<Detail
fullWidth

View File

@ -31,6 +31,7 @@ function InventoryLookup({
isOverrideDisabled,
validate,
fieldName,
isDisabled,
}) {
const {
result: {
@ -105,7 +106,7 @@ function InventoryLookup({
label={t`Inventory`}
promptId={promptId}
promptName={promptName}
isDisabled={!canEdit}
isDisabled={!canEdit || isDisabled}
tooltip={t`Select the inventory containing the hosts
you want this job to manage.`}
>
@ -120,7 +121,7 @@ function InventoryLookup({
fieldName={fieldName}
validate={validate}
isLoading={isLoading}
isDisabled={!canEdit}
isDisabled={!canEdit || isDisabled}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
@ -176,7 +177,7 @@ function InventoryLookup({
onBlur={onBlur}
required={required}
isLoading={isLoading}
isDisabled={!canEdit}
isDisabled={!canEdit || isDisabled}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
@ -228,6 +229,7 @@ InventoryLookup.propTypes = {
isOverrideDisabled: bool,
validate: func,
fieldName: string,
isDisabled: bool,
};
InventoryLookup.defaultProps = {
@ -236,6 +238,7 @@ InventoryLookup.defaultProps = {
isOverrideDisabled: false,
validate: () => {},
fieldName: 'inventory',
isDisabled: false,
};
export default withRouter(InventoryLookup);

View File

@ -215,6 +215,7 @@ Lookup.propTypes = {
fieldName: string.isRequired,
validate: func,
onDebounce: func,
isDisabled: bool,
};
Lookup.defaultProps = {
@ -235,6 +236,7 @@ Lookup.defaultProps = {
),
validate: () => undefined,
onDebounce: () => undefined,
isDisabled: false,
};
export { Lookup as _Lookup };

View File

@ -1,9 +1,13 @@
import React from 'react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import { Chip, List, ListItem } from '@patternfly/react-core';
import {
Chip,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
} from '@patternfly/react-core';
import { Detail, DeletedDetail } from '../DetailList';
import { VariablesDetail } from '../CodeEditor';
import CredentialChip from '../CredentialChip';
@ -44,14 +48,28 @@ function PromptInventorySourceDetail({ resource }) {
update_on_project_update
) {
optionsList = (
<List>
{overwrite && <ListItem>{t`Overwrite`}</ListItem>}
{overwrite_vars && <ListItem>{t`Overwrite Variables`}</ListItem>}
{update_on_launch && <ListItem>{t`Update on Launch`}</ListItem>}
{update_on_project_update && (
<ListItem>{t`Update on Project Update`}</ListItem>
<TextList component={TextListVariants.ul}>
{overwrite && (
<TextListItem component={TextListItemVariants.li}>
{t`Overwrite local groups and hosts from remote inventory source`}
</TextListItem>
)}
</List>
{overwrite_vars && (
<TextListItem component={TextListItemVariants.li}>
{t`Overwrite local variables from remote inventory source`}
</TextListItem>
)}
{update_on_launch && (
<TextListItem component={TextListItemVariants.li}>
{t`Update on launch`}
</TextListItem>
)}
{update_on_project_update && (
<TextListItem component={TextListItemVariants.li}>
{t`Update on project update`}
</TextListItem>
)}
</TextList>
);
}
@ -162,7 +180,9 @@ function PromptInventorySourceDetail({ resource }) {
}
/>
)}
{optionsList && <Detail label={t`Options`} value={optionsList} />}
{optionsList && (
<Detail fullWidth label={t`Enabled Options`} value={optionsList} />
)}
{source_vars && (
<VariablesDetail
label={t`Source Variables`}

View File

@ -60,12 +60,14 @@ describe('PromptInventorySourceDetail', () => {
);
expect(
wrapper
.find('Detail[label="Options"]')
.find('Detail[label="Enabled Options"]')
.containsAllMatchingElements([
<li>Overwrite</li>,
<li>Overwrite Variables</li>,
<li>Update on Launch</li>,
<li>Update on Project Update</li>,
<li>
Overwrite local groups and hosts from remote inventory source
</li>,
<li>Overwrite local variables from remote inventory source</li>,
<li>Update on launch</li>,
<li>Update on project update</li>,
])
).toEqual(true);
});

View File

@ -1,9 +1,13 @@
import React from 'react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import { Chip, List, ListItem } from '@patternfly/react-core';
import {
Chip,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
} from '@patternfly/react-core';
import CredentialChip from '../CredentialChip';
import ChipGroup from '../ChipGroup';
import Sparkline from '../Sparkline';
@ -51,19 +55,37 @@ function PromptJobTemplateDetail({ resource }) {
become_enabled ||
host_config_key ||
allow_simultaneous ||
use_fact_cache
use_fact_cache ||
webhook_service
) {
optionsList = (
<List>
<TextList component={TextListVariants.ul}>
{become_enabled && (
<ListItem>{t`Enable Privilege Escalation`}</ListItem>
<TextListItem component={TextListItemVariants.li}>
{t`Privilege Escalation`}
</TextListItem>
)}
{host_config_key && (
<ListItem>{t`Allow Provisioning Callbacks`}</ListItem>
<TextListItem component={TextListItemVariants.li}>
{t`Provisioning Callbacks`}
</TextListItem>
)}
{allow_simultaneous && <ListItem>{t`Enable Concurrent Jobs`}</ListItem>}
{use_fact_cache && <ListItem>{t`Use Fact Storage`}</ListItem>}
</List>
{allow_simultaneous && (
<TextListItem component={TextListItemVariants.li}>
{t`Concurrent Jobs`}
</TextListItem>
)}
{use_fact_cache && (
<TextListItem component={TextListItemVariants.li}>
{t`Fact Storage`}
</TextListItem>
)}
{webhook_service && (
<TextListItem component={TextListItemVariants.li}>
{t`Webhooks`}
</TextListItem>
)}
</TextList>
);
}
@ -164,7 +186,7 @@ function PromptJobTemplateDetail({ resource }) {
}
/>
)}
{optionsList && <Detail label={t`Options`} value={optionsList} />}
{optionsList && <Detail label={t`Enabled Options`} value={optionsList} />}
{summary_fields?.credentials?.length > 0 && (
<Detail
fullWidth

View File

@ -96,12 +96,13 @@ describe('PromptJobTemplateDetail', () => {
).toEqual(true);
expect(
wrapper
.find('Detail[label="Options"]')
.find('Detail[label="Enabled Options"]')
.containsAllMatchingElements([
<li>Enable Privilege Escalation</li>,
<li>Allow Provisioning Callbacks</li>,
<li>Enable Concurrent Jobs</li>,
<li>Use Fact Storage</li>,
<li>Privilege Escalation</li>,
<li>Provisioning Callbacks</li>,
<li>Concurrent Jobs</li>,
<li>Fact Storage</li>,
<li>Webhooks</li>,
])
).toEqual(true);
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(

View File

@ -1,10 +1,13 @@
import React from 'react';
import { t } from '@lingui/macro';
import { List, ListItem } from '@patternfly/react-core';
import {
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { Config } from '../../contexts/Config';
import { Detail, DeletedDetail } from '../DetailList';
import CredentialChip from '../CredentialChip';
import { toTitleCase } from '../../util/strings';
@ -36,17 +39,33 @@ function PromptProjectDetail({ resource }) {
allow_override
) {
optionsList = (
<List>
{scm_clean && <ListItem>{t`Clean`}</ListItem>}
{scm_delete_on_update && <ListItem>{t`Delete on Update`}</ListItem>}
<TextList component={TextListVariants.ul}>
{scm_clean && (
<TextListItem
component={TextListItemVariants.li}
>{t`Discard local changes before syncing`}</TextListItem>
)}
{scm_delete_on_update && (
<TextListItem
component={TextListItemVariants.li}
>{t`Delete the project before syncing`}</TextListItem>
)}
{scm_track_submodules && (
<ListItem>{t`Track submodules latest commit on branch`}</ListItem>
<TextListItem
component={TextListItemVariants.li}
>{t`Track submodules latest commit on branch`}</TextListItem>
)}
{scm_update_on_launch && (
<ListItem>{t`Update Revision on Launch`}</ListItem>
<TextListItem
component={TextListItemVariants.li}
>{t`Update revision on job launch`}</TextListItem>
)}
{allow_override && <ListItem>{t`Allow Branch Override`}</ListItem>}
</List>
{allow_override && (
<TextListItem
component={TextListItemVariants.li}
>{t`Allow branch override`}</TextListItem>
)}
</TextList>
);
}
@ -90,7 +109,7 @@ function PromptProjectDetail({ resource }) {
}
/>
)}
{optionsList && <Detail label={t`Options`} value={optionsList} />}
{optionsList && <Detail label={t`Enabled Options`} value={optionsList} />}
<Detail
label={t`Cache Timeout`}
value={`${scm_update_cache_timeout} ${t`Seconds`}`}

View File

@ -16,7 +16,9 @@ describe('PromptProjectDetail', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<PromptProjectDetail resource={mockProject} />,
<PromptProjectDetail
resource={{ ...mockProject, scm_track_submodules: true }}
/>,
{
context: { config },
}
@ -54,12 +56,13 @@ describe('PromptProjectDetail', () => {
);
expect(
wrapper
.find('Detail[label="Options"]')
.find('Detail[label="Enabled Options"]')
.containsAllMatchingElements([
<li>Clean</li>,
<li>Delete on Update</li>,
<li>Update Revision on Launch</li>,
<li>Allow Branch Override</li>,
<li>Discard local changes before syncing</li>,
<li>Delete the project before syncing</li>,
<li>Track submodules latest commit on branch</li>,
<li>Update revision on job launch</li>,
<li>Allow branch override</li>,
])
).toEqual(true);
});

View File

@ -1,9 +1,13 @@
import React from 'react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import { Chip, List, ListItem } from '@patternfly/react-core';
import {
Chip,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
} from '@patternfly/react-core';
import CredentialChip from '../CredentialChip';
import ChipGroup from '../ChipGroup';
import { Detail } from '../DetailList';
@ -26,10 +30,18 @@ function PromptWFJobTemplateDetail({ resource }) {
let optionsList = '';
if (allow_simultaneous || webhook_service) {
optionsList = (
<List>
{allow_simultaneous && <ListItem>{t`Enable Concurrent Jobs`}</ListItem>}
{webhook_service && <ListItem>{t`Enable Webhooks`}</ListItem>}
</List>
<TextList component={TextListVariants.ul}>
{allow_simultaneous && (
<TextListItem component={TextListItemVariants.li}>
{t`Concurrent Jobs`}
</TextListItem>
)}
{webhook_service && (
<TextListItem component={TextListItemVariants.li}>
{t`Webhooks`}
</TextListItem>
)}
</TextList>
);
}
@ -82,7 +94,7 @@ function PromptWFJobTemplateDetail({ resource }) {
value={`${window.location.origin}${related.webhook_receiver}`}
/>
)}
{optionsList && <Detail label={t`Options`} value={optionsList} />}
{optionsList && <Detail label={t`Enabled Options`} value={optionsList} />}
{summary_fields?.webhook_credential && (
<Detail
fullWidth

View File

@ -38,10 +38,10 @@ describe('PromptWFJobTemplateDetail', () => {
);
expect(
wrapper
.find('Detail[label="Options"]')
.find('Detail[label="Enabled Options"]')
.containsAllMatchingElements([
<li>Enable Concurrent Jobs</li>,
<li>Enable Webhooks</li>,
<li>Concurrent Jobs</li>,
<li>Webhooks</li>,
])
).toEqual(true);
expect(

View File

@ -3,7 +3,7 @@ import { useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro';
import { ConfigAPI, MeAPI } from '../api';
import { ConfigAPI, MeAPI, UsersAPI, OrganizationsAPI } from '../api';
import useRequest, { useDismissableError } from '../util/useRequest';
import AlertModal from '../components/AlertModal';
import ErrorDetail from '../components/ErrorDetail';
@ -35,9 +35,32 @@ export const ConfigProvider = ({ children }) => {
},
},
] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
return { ...data, me };
const [
{
data: { count: adminOrgCount },
},
{
data: { count: notifAdminCount },
},
{
data: { count: execEnvAdminCount },
},
] = await Promise.all([
UsersAPI.readAdminOfOrganizations(me?.id),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
OrganizationsAPI.read({
page_size: 1,
role_level: 'execution_environment_admin_role',
}),
]);
return { ...data, me, adminOrgCount, notifAdminCount, execEnvAdminCount };
}, []),
{}
{ adminOrgCount: 0, notifAdminCount: 0, execEnvAdminCount: 0 }
);
const { error, dismissError } = useDismissableError(configError);
@ -77,6 +100,17 @@ export const ConfigProvider = ({ children }) => {
);
};
export const useUserProfile = () => {
const config = useConfig();
return {
isSuperUser: !!config.me?.is_superuser,
isSystemAuditor: !!config.me?.is_system_auditor,
isOrgAdmin: config.adminOrgCount,
isNotificationAdmin: config.notifAdminCount,
isExecEnvAdmin: config.execEnvAdminCount,
};
};
export const useAuthorizedPath = () => {
const config = useConfig();
const subscriptionMgmtRoute = useRouteMatch({

View File

@ -22,8 +22,8 @@ import Users from './screens/User';
import WorkflowApprovals from './screens/WorkflowApproval';
import { Jobs } from './screens/Job';
function getRouteConfig() {
return [
function getRouteConfig(userProfile = {}) {
let routeConfig = [
{
groupTitle: <Trans>Views</Trans>,
groupId: 'views_group',
@ -155,6 +155,29 @@ function getRouteConfig() {
],
},
];
const deleteRoute = name => {
routeConfig.forEach(group => {
group.routes = group.routes.filter(({ path }) => !path.includes(name));
});
routeConfig = routeConfig.filter(groups => groups.routes.length);
};
const deleteRouteGroup = name => {
routeConfig = routeConfig.filter(({ groupId }) => !groupId.includes(name));
};
if (userProfile?.isSuperUser || userProfile?.isSystemAuditor)
return routeConfig;
deleteRouteGroup('settings');
deleteRoute('management_jobs');
deleteRoute('credential_types');
if (userProfile?.isOrgAdmin) return routeConfig;
deleteRoute('applications');
deleteRoute('instance_groups');
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
return routeConfig;
}
export default getRouteConfig;

View File

@ -0,0 +1,248 @@
import getRouteConfig from './routeConfig';
const userProfile = {
isSuperUser: false,
isSystemAuditor: false,
isOrgAdmin: false,
isNotificationAdmin: false,
isExecEnvAdmin: false,
};
const filterPaths = sidebar => {
const visibleRoutes = [];
sidebar.forEach(({ routes }) => {
routes.forEach(route => {
visibleRoutes.push(route.path);
});
});
return visibleRoutes;
};
describe('getRouteConfig', () => {
test('routes for system admin', () => {
const sidebar = getRouteConfig({ ...userProfile, isSuperUser: true });
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/credential_types',
'/notification_templates',
'/management_jobs',
'/instance_groups',
'/applications',
'/execution_environments',
'/settings',
]);
});
test('routes for system auditor', () => {
const sidebar = getRouteConfig({ ...userProfile, isSystemAuditor: true });
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/credential_types',
'/notification_templates',
'/management_jobs',
'/instance_groups',
'/applications',
'/execution_environments',
'/settings',
]);
});
test('routes for org admin', () => {
const sidebar = getRouteConfig({ ...userProfile, isOrgAdmin: true });
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/notification_templates',
'/instance_groups',
'/applications',
'/execution_environments',
]);
});
test('routes for notifications admin', () => {
const sidebar = getRouteConfig({
...userProfile,
isNotificationAdmin: true,
});
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/notification_templates',
'/execution_environments',
]);
});
test('routes for execution environments admin', () => {
const sidebar = getRouteConfig({ ...userProfile, isExecEnvAdmin: true });
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/execution_environments',
]);
});
test('routes for regular users', () => {
const sidebar = getRouteConfig(userProfile);
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/execution_environments',
]);
});
test('routes for execution environment admins and notification admin', () => {
const sidebar = getRouteConfig({
...userProfile,
isExecEnvAdmin: true,
isNotificationAdmin: true,
});
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/notification_templates',
'/execution_environments',
]);
});
test('routes for execution environment admins and organization admins', () => {
const sidebar = getRouteConfig({
...userProfile,
isExecEnvAdmin: true,
isOrgAdmin: true,
});
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/notification_templates',
'/instance_groups',
'/applications',
'/execution_environments',
]);
});
test('routes for notification admins and organization admins', () => {
const sidebar = getRouteConfig({
...userProfile,
isNotificationAdmin: true,
isOrgAdmin: true,
});
const filteredPaths = filterPaths(sidebar);
expect(filteredPaths).toEqual([
'/home',
'/jobs',
'/schedules',
'/activity_stream',
'/workflow_approvals',
'/templates',
'/credentials',
'/projects',
'/inventories',
'/hosts',
'/organizations',
'/users',
'/teams',
'/notification_templates',
'/instance_groups',
'/applications',
'/execution_environments',
]);
});
});

View File

@ -1,9 +1,14 @@
import React, { Fragment, useEffect, useCallback } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Button, List, ListItem } from '@patternfly/react-core';
import {
Button,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
} from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import ContentError from '../../../components/ContentError';
@ -134,15 +139,7 @@ function CredentialDetail({ credential }) {
}
if (type === 'boolean') {
return (
<Detail
dataCy={`credential-${id}-detail`}
id={`credential-${id}-detail`}
key={id}
label={t`Options`}
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
/>
);
return null;
}
if (inputs[id] === '$encrypted$') {
@ -189,6 +186,10 @@ function CredentialDetail({ credential }) {
credential
);
const enabledBooleanFields = fields.filter(
({ id, type }) => type === 'boolean' && inputs[id]
);
if (hasContentLoading) {
return <ContentLoading />;
}
@ -255,6 +256,20 @@ function CredentialDetail({ credential }) {
date={modified}
user={modified_by}
/>
{enabledBooleanFields.length > 0 && (
<Detail
label={t`Enabled Options`}
value={
<TextList component={TextListVariants.ul}>
{enabledBooleanFields.map(({ id, label }) => (
<TextListItem key={id} component={TextListItemVariants.li}>
{label}
</TextListItem>
))}
</TextList>
}
/>
)}
</DetailList>
{Object.keys(inputSources).length > 0 && (
<PluginFieldText>

View File

@ -111,7 +111,7 @@ describe('<CredentialDetail />', () => {
'Privilege Escalation Password',
'Prompt on launch'
);
expect(wrapper.find(`Detail[label="Options"] ListItem`).text()).toEqual(
expect(wrapper.find(`Detail[label="Enabled Options"] li`).text()).toEqual(
'Authorize'
);
});

View File

@ -34,6 +34,7 @@ function HostEdit({ host }) {
handleSubmit={handleSubmit}
handleCancel={handleCancel}
submitError={formError}
disableInventoryLookup
/>
</CardBody>
);

View File

@ -141,8 +141,10 @@ function InstanceList() {
[instanceGroupId]
);
const readInstancesOptions = () =>
InstanceGroupsAPI.readInstanceOptions(instanceGroupId);
const readInstancesOptions = useCallback(
() => InstanceGroupsAPI.readInstanceOptions(instanceGroupId),
[instanceGroupId]
);
return (
<>

View File

@ -1,9 +1,13 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import { Button, List, ListItem } from '@patternfly/react-core';
import {
Button,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
} from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import { VariablesDetail } from '../../../components/CodeEditor';
@ -19,7 +23,6 @@ import {
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
import Popover from '../../../components/Popover';
import useRequest from '../../../util/useRequest';
import { InventorySourcesAPI } from '../../../api';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
@ -112,71 +115,28 @@ function InventorySourceDetail({ inventorySource }) {
update_on_project_update
) {
optionsList = (
<List>
<TextList component={TextListVariants.ul}>
{overwrite && (
<ListItem>
{t`Overwrite`}
<Popover
content={
<>
{t`If checked, any hosts and groups that were
previously present on the external source but are now removed
will be removed from the inventory. Hosts and groups
that were not managed by the inventory source will be promoted
to the next manually created group or if there is no manually
created group to promote them into, they will be left in the "all"
default group for the inventory.`}
<br />
<br />
{t`When not checked, local child
hosts and groups not found on the external source will remain
untouched by the inventory update process.`}
</>
}
/>
</ListItem>
<TextListItem component={TextListItemVariants.li}>
{t`Overwrite local groups and hosts from remote inventory source`}
</TextListItem>
)}
{overwrite_vars && (
<ListItem>
{t`Overwrite variables`}
<Popover
content={
<>
{t`If checked, all variables for child groups
and hosts will be removed and replaced by those found
on the external source.`}
<br />
<br />
{t`When not checked, a merge will be performed,
combining local variables with those found on the
external source.`}
</>
}
/>
</ListItem>
<TextListItem component={TextListItemVariants.li}>
{t`Overwrite local variables from remote inventory source`}
</TextListItem>
)}
{update_on_launch && (
<ListItem>
<TextListItem component={TextListItemVariants.li}>
{t`Update on launch`}
<Popover
content={t`Each time a job runs using this inventory,
refresh the inventory from the selected source before
executing job tasks.`}
/>
</ListItem>
</TextListItem>
)}
{update_on_project_update && (
<ListItem>
<TextListItem component={TextListItemVariants.li}>
{t`Update on project update`}
<Popover
content={t`After every project update where the SCM revision
changes, refresh the inventory from the selected source
before executing job tasks. This is intended for static content,
like the Ansible inventory .ini file format.`}
/>
</ListItem>
</TextListItem>
)}
</List>
</TextList>
);
}
@ -242,7 +202,7 @@ function InventorySourceDetail({ inventorySource }) {
/>
)}
{optionsList && (
<Detail fullWidth label={t`Options`} value={optionsList} />
<Detail fullWidth label={t`Enabled Options`} value={optionsList} />
)}
{source_vars && (
<VariablesDetail

View File

@ -85,10 +85,10 @@ describe('InventorySourceDetail', () => {
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
'---\nfoo: bar'
);
wrapper.find('Detail[label="Options"] li').forEach(option => {
wrapper.find('Detail[label="Enabled Options"] li').forEach(option => {
expect([
'Overwrite',
'Overwrite variables',
'Overwrite local groups and hosts from remote inventory source',
'Overwrite local variables from remote inventory source',
'Update on launch',
'Update on project update',
]).toContain(option.text());

View File

@ -28,25 +28,22 @@ function SmartInventory({ setBreadcrumb }) {
const match = useRouteMatch('/inventories/smart_inventory/:id');
const {
result: { inventory },
result: inventory,
error: contentError,
isLoading: hasContentLoading,
request: fetchInventory,
} = useRequest(
useCallback(async () => {
const { data } = await InventoriesAPI.readDetail(match.params.id);
return {
inventory: data,
};
return data;
}, [match.params.id]),
{
inventory: null,
}
null
);
useEffect(() => {
fetchInventory();
}, [fetchInventory]);
}, [fetchInventory, location.pathname]);
useEffect(() => {
if (inventory) {

View File

@ -79,7 +79,7 @@ function JobDetail({ job }) {
inventory_update: t`Inventory Sync`,
job: job.job_type === 'check' ? t`Playbook Check` : t`Playbook Run`,
ad_hoc_command: t`Command`,
management_job: t`Management Job`,
system_job: t`Management Job`,
workflow_job: t`Workflow Job`,
};
@ -220,10 +220,12 @@ function JobDetail({ job }) {
<Detail label={t`Playbook`} value={job.playbook} />
<Detail label={t`Limit`} value={job.limit} />
<Detail label={t`Verbosity`} value={VERBOSITY[job.verbosity]} />
<ExecutionEnvironmentDetail
executionEnvironment={executionEnvironment}
verifyMissingVirtualEnv={false}
/>
{job.type !== 'workflow_job' && !isJobRunning(job.status) && (
<ExecutionEnvironmentDetail
executionEnvironment={executionEnvironment}
verifyMissingVirtualEnv={false}
/>
)}
<Detail label={t`Execution Node`} value={job.execution_node} />
{instanceGroup && !instanceGroup?.is_container_group && (
<Detail

View File

@ -1,9 +1,14 @@
import React, { useContext } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import { shape } from 'prop-types';
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
import { CompassIcon, WrenchIcon } from '@patternfly/react-icons';
import {
CompassIcon,
WrenchIcon,
ProjectDiagramIcon,
} from '@patternfly/react-icons';
import styled from 'styled-components';
import StatusIcon from '../../../components/StatusIcon';
import {
@ -58,11 +63,15 @@ const StatusIconWithMargin = styled(StatusIcon)`
function WorkflowOutputToolbar({ job }) {
const dispatch = useContext(WorkflowDispatchContext);
const history = useHistory();
const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
const navToWorkflow = () => {
history.push(
`/templates/workflow_job_template/${job.unified_job_template}/visualizer`
);
};
return (
<Toolbar id="workflow-output-toolbar">
<ToolbarJob>
@ -70,6 +79,15 @@ function WorkflowOutputToolbar({ job }) {
<b>{job.name}</b>
</ToolbarJob>
<ToolbarActions>
<ActionButton
ouiaId="edit-workflow"
aria-label={t`Edit workflow`}
id="edit-workflow"
variant="plain"
onClick={navToWorkflow}
>
<ProjectDiagramIcon />
</ActionButton>
<div>{t`Total Nodes`}</div>
<Badge isRead>{totalNodes}</Badge>
<Tooltip content={t`Toggle Legend`} position="bottom">

View File

@ -18,6 +18,9 @@ const workflowContext = {
showTools: false,
};
function shouldFind(element) {
expect(wrapper.find(element)).toHaveLength(1);
}
describe('WorkflowOutputToolbar', () => {
beforeAll(() => {
const nodes = [
@ -45,6 +48,13 @@ describe('WorkflowOutputToolbar', () => {
wrapper.unmount();
});
test('should render correct toolbar item', () => {
shouldFind(`Button[ouiaId="edit-workflow"]`);
shouldFind('Button#workflow-output-toggle-legend');
shouldFind('Badge');
shouldFind('Button#workflow-output-toggle-tools');
});
test('Shows correct number of nodes', () => {
// The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
expect(wrapper.find('Badge').text()).toBe('1');

View File

@ -133,11 +133,13 @@ function OrganizationDetail({ organization }) {
value={
<ChipGroup numChips={5} totalChips={galaxy_credentials.length}>
{galaxy_credentials.map(credential => (
<CredentialChip
credential={credential}
key={credential.id}
isReadOnly
/>
<Link to={`/credentials/${credential.id}/details`}>
<CredentialChip
credential={credential}
key={credential.id}
isReadOnly
/>
</Link>
))}
</ChipGroup>
}

View File

@ -5,8 +5,10 @@ import styled from 'styled-components';
import {
Button,
ClipboardCopy,
List,
ListItem,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
Tooltip,
} from '@patternfly/react-core';
import { Project } from '../../../types';
@ -78,17 +80,33 @@ function ProjectDetail({ project }) {
allow_override
) {
optionsList = (
<List>
{scm_clean && <ListItem>{t`Clean`}</ListItem>}
{scm_delete_on_update && <ListItem>{t`Delete on Update`}</ListItem>}
<TextList component={TextListVariants.ul}>
{scm_clean && (
<TextListItem
component={TextListItemVariants.li}
>{t`Discard local changes before syncing`}</TextListItem>
)}
{scm_delete_on_update && (
<TextListItem
component={TextListItemVariants.li}
>{t`Delete the project before syncing`}</TextListItem>
)}
{scm_track_submodules && (
<ListItem>{t`Track submodules latest commit on branch`}</ListItem>
<TextListItem
component={TextListItemVariants.li}
>{t`Track submodules latest commit on branch`}</TextListItem>
)}
{scm_update_on_launch && (
<ListItem>{t`Update Revision on Launch`}</ListItem>
<TextListItem
component={TextListItemVariants.li}
>{t`Update revision on job launch`}</TextListItem>
)}
{allow_override && <ListItem>{t`Allow Branch Override`}</ListItem>}
</List>
{allow_override && (
<TextListItem
component={TextListItemVariants.li}
>{t`Allow branch override`}</TextListItem>
)}
</TextList>
);
}
@ -196,7 +214,6 @@ function ProjectDetail({ project }) {
}
/>
)}
{optionsList && <Detail label={t`Options`} value={optionsList} />}
<Detail
label={t`Cache Timeout`}
value={`${scm_update_cache_timeout} ${t`Seconds`}`}
@ -212,7 +229,6 @@ function ProjectDetail({ project }) {
)}
</Config>
<Detail label={t`Playbook Directory`} value={local_path} />
<UserDateDetail
label={t`Created`}
date={created}
@ -223,6 +239,9 @@ function ProjectDetail({ project }) {
date={modified}
user={summary_fields.modified_by}
/>
{optionsList && (
<Detail fullWidth label={t`Enabled Options`} value={optionsList} />
)}
</DetailList>
<CardActionsRow>
{summary_fields.user_capabilities?.edit && (

View File

@ -70,7 +70,7 @@ describe('<ProjectDetail />', () => {
scm_refspec: 'refs/remotes/*',
scm_clean: true,
scm_delete_on_update: true,
scm_track_submodules: false,
scm_track_submodules: true,
credential: 100,
status: 'successful',
organization: 10,
@ -127,12 +127,13 @@ describe('<ProjectDetail />', () => {
);
expect(
wrapper
.find('Detail[label="Options"]')
.find('Detail[label="Enabled Options"]')
.containsAllMatchingElements([
<li>Clean</li>,
<li>Delete on Update</li>,
<li>Update Revision on Launch</li>,
<li>Allow Branch Override</li>,
<li>Discard local changes before syncing</li>,
<li>Delete the project before syncing</li>,
<li>Track submodules latest commit on branch</li>,
<li>Update revision on job launch</li>,
<li>Allow branch override</li>,
])
).toEqual(true);
});
@ -151,7 +152,7 @@ describe('<ProjectDetail />', () => {
const wrapper = mountWithContexts(
<ProjectDetail project={{ ...mockProject, ...mockOptions }} />
);
expect(wrapper.find('Detail[label="Options"]').length).toBe(0);
expect(wrapper.find('Detail[label="Enabled Options"]').length).toBe(0);
});
test('should have proper number of delete detail requests', () => {

View File

@ -23,7 +23,7 @@ import {
} from '../../../../util/dates';
function SubscriptionDetail() {
const { license_info, version } = useConfig();
const { me = {}, license_info, version } = useConfig();
const baseURL = '/settings/subscription';
const tabsArray = [
{
@ -164,15 +164,17 @@ function SubscriptionDetail() {
contact us.
</Button>
</Trans>
<CardActionsRow>
<Button
aria-label={t`edit`}
component={Link}
to="/settings/subscription/edit"
>
<Trans>Edit</Trans>
</Button>
</CardActionsRow>
{me.is_superuser && (
<CardActionsRow>
<Button
aria-label={t`edit`}
component={Link}
to="/settings/subscription/edit"
>
<Trans>Edit</Trans>
</Button>
</CardActionsRow>
)}
</CardBody>
</>
);

View File

@ -71,6 +71,14 @@ describe('<SubscriptionDetail />', () => {
assertDetail('Hosts remaining', '1000');
assertDetail('Hosts automated', '12 since 3/2/2021, 7:43:48 PM');
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
});
test('should render edit button for system admin', () => {
wrapper = mountWithContexts(<SubscriptionDetail />, {
context: { ...config, me: { is_superuser: true } },
});
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(1);
});
});

View File

@ -115,28 +115,37 @@ function JobTemplateDetail({ template }) {
);
const generateCallBackUrl = `${window.location.origin + url}callback/`;
const renderOptionsField =
become_enabled || host_config_key || allow_simultaneous || use_fact_cache;
become_enabled ||
host_config_key ||
allow_simultaneous ||
use_fact_cache ||
webhook_service;
const renderOptions = (
<TextList component={TextListVariants.ul}>
{become_enabled && (
<TextListItem component={TextListItemVariants.li}>
{t`Enable Privilege Escalation`}
{t`Privilege Escalation`}
</TextListItem>
)}
{host_config_key && (
<TextListItem component={TextListItemVariants.li}>
{t`Allow Provisioning Callbacks`}
{t`Provisioning Callbacks`}
</TextListItem>
)}
{allow_simultaneous && (
<TextListItem component={TextListItemVariants.li}>
{t`Enable Concurrent Jobs`}
{t`Concurrent Jobs`}
</TextListItem>
)}
{use_fact_cache && (
<TextListItem component={TextListItemVariants.li}>
{t`Use Fact Storage`}
{t`Fact Storage`}
</TextListItem>
)}
{webhook_service && (
<TextListItem component={TextListItemVariants.li}>
{t`Webhooks`}
</TextListItem>
)}
</TextList>
@ -166,7 +175,6 @@ function JobTemplateDetail({ template }) {
if (isLoadingInstanceGroups || isDeleteLoading) {
return <ContentLoading />;
}
return (
<CardBody>
<DetailList gutter="sm">
@ -212,7 +220,10 @@ function JobTemplateDetail({ template }) {
)}
<ExecutionEnvironmentDetail
virtualEnvironment={custom_virtualenv}
executionEnvironment={summary_fields?.execution_environment}
executionEnvironment={summary_fields?.resolved_environment}
helpText={t`The execution environment that will be used when launching
this job template. The resolved execution environment can be overridden by
explicitly assigning a different one to this job template.`}
/>
<Detail label={t`Source Control Branch`} value={template.scm_branch} />
<Detail label={t`Playbook`} value={playbook} />
@ -256,9 +267,6 @@ function JobTemplateDetail({ template }) {
}
/>
)}
{renderOptionsField && (
<Detail label={t`Options`} value={renderOptions} />
)}
<UserDateDetail
label={t`Created`}
date={created}
@ -269,6 +277,9 @@ function JobTemplateDetail({ template }) {
date={modified}
user={summary_fields.modified_by}
/>
{renderOptionsField && (
<Detail fullWidth label={t`Enabled Options`} value={renderOptions} />
)}
{summary_fields.credentials && summary_fields.credentials.length > 0 && (
<Detail
fullWidth
@ -279,7 +290,9 @@ function JobTemplateDetail({ template }) {
totalChips={summary_fields.credentials.length}
>
{summary_fields.credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
<Link to={`/credentials/${c.id}/details`}>
<CredentialChip key={c.id} credential={c} isReadOnly />
</Link>
))}
</ChipGroup>
}

View File

@ -58,12 +58,12 @@ function WorkflowJobTemplateDetail({ template }) {
<TextList component={TextListVariants.ul}>
{template.allow_simultaneous && (
<TextListItem component={TextListItemVariants.li}>
{t`- Enable Concurrent Jobs`}
{t`Concurrent Jobs`}
</TextListItem>
)}
{template.webhook_service && (
<TextListItem component={TextListItemVariants.li}>
{t`- Enable Webhooks`}
{t`Webhooks`}
</TextListItem>
)}
</TextList>
@ -186,9 +186,6 @@ function WorkflowJobTemplateDetail({ template }) {
}
/>
)}
{renderOptionsField && (
<Detail label={t`Options`} value={renderOptions} />
)}
<UserDateDetail
label={t`Created`}
date={created}
@ -199,6 +196,9 @@ function WorkflowJobTemplateDetail({ template }) {
date={modified}
user={summary_fields.modified_by}
/>
{renderOptionsField && (
<Detail fullWidth label={t`Enabled Options`} value={renderOptions} />
)}
{summary_fields.labels?.results?.length > 0 && (
<Detail
fullWidth

View File

@ -139,6 +139,12 @@
"name": "Default EE",
"description": "",
"image": "quay.io/ansible/awx-ee"
},
"resolved_environment": {
"id": 1,
"name": "Default EE",
"description": "",
"image": "quay.io/ansible/awx-ee"
}
},
"created": "2019-09-30T16:18:34.564820Z",

View File

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { t } from '@lingui/macro';
import { Formik, useField, useFormikContext } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core';
import { useConfig } from '../../../contexts/Config';
import AnsibleSelect from '../../../components/AnsibleSelect';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import FormField, {
@ -16,7 +17,7 @@ import { FormColumnLayout } from '../../../components/FormLayout';
function UserFormFields({ user }) {
const { setFieldValue, setFieldTouched } = useFormikContext();
const { me = {} } = useConfig();
const ldapUser = user.ldap_dn;
const socialAuthUser = user.auth?.length > 0;
const externalAccount = user.external_account;
@ -119,22 +120,24 @@ function UserFormFields({ user }) {
validate={required(t`Select a value for this field`)}
/>
)}
<FormGroup
fieldId="user-type"
helperTextInvalid={userTypeMeta.error}
isRequired
validated={
!userTypeMeta.touched || !userTypeMeta.error ? 'default' : 'error'
}
label={t`User Type`}
>
<AnsibleSelect
isValid={!userTypeMeta.touched || !userTypeMeta.error}
id="user-type"
data={userTypeOptions}
{...userTypeField}
/>
</FormGroup>
{me.is_superuser && (
<FormGroup
fieldId="user-type"
helperTextInvalid={userTypeMeta.error}
isRequired
validated={
!userTypeMeta.touched || !userTypeMeta.error ? 'default' : 'error'
}
label={t`User Type`}
>
<AnsibleSelect
isValid={!userTypeMeta.touched || !userTypeMeta.error}
id="user-type"
data={userTypeOptions}
{...userTypeField}
/>
</FormGroup>
)}
</>
);
}

View File

@ -230,4 +230,25 @@ describe('<UserForm />', () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(handleCancel).toBeCalled();
});
test('should not show user type field', async () => {
const handleCancel = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<UserForm
user={mockData}
handleSubmit={jest.fn()}
handleCancel={handleCancel}
/>,
{
context: {
config: {
me: { is_superuser: false },
},
},
}
);
});
expect(wrapper.find('FormGroup[label="User Type"]')).toHaveLength(0);
});
});

View File

@ -47,10 +47,6 @@ options:
description:
- Variables which will be made available to jobs ran inside the workflow.
type: dict
execution_environment:
description:
- Execution Environment to use for the WFJT.
type: str
organization:
description:
- Organization the workflow job template exists in.
@ -666,7 +662,6 @@ def main():
description=dict(),
extra_vars=dict(type='dict'),
organization=dict(),
execution_environment=dict(),
survey_spec=dict(type='dict', aliases=['survey']),
survey_enabled=dict(type='bool'),
allow_simultaneous=dict(type='bool'),
@ -713,10 +708,6 @@ def main():
organization_id = module.resolve_name_to_id('organizations', organization)
search_fields['organization'] = new_fields['organization'] = organization_id
ee = module.params.get('execution_environment')
if ee:
new_fields['execution_environment'] = module.resolve_name_to_id('execution_environments', ee)
# Attempt to look up an existing item based on the provided data
existing_item = module.get_one('workflow_job_templates', name_or_id=name, **{'data': search_fields})

View File

@ -67,6 +67,14 @@ class HasStatus(object):
msg += '{0}-{1} has status of {2}, which is not in {3}.'.format(self.type.title(), self.id, self.status, status_list)
if getattr(self, 'job_explanation', ''):
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
if getattr(self, 'execution_environment', ''):
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
if getattr(self, 'related', False):
ee = self.related.execution_environment.get()
msg += f'\nee_image: {ee.image}'
msg += f'\nee_credential: {ee.credential}'
msg += f'\nee_pull_option: {ee.pull}'
msg += f'\nee_summary_fields: {ee.summary_fields}'
if getattr(self, 'result_traceback', ''):
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))

View File

@ -21,3 +21,5 @@ sdb
remote-pdb
gprof2dot
atomicwrites==1.4.0
flake8
yamllint