diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx
index c9594e4e17..e152e7cde8 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx
@@ -195,9 +195,6 @@ function InventoryList({ i18n }) {
{i18n._(t`Status`)}
{i18n._(t`Type`)}
{i18n._(t`Organization`)}
- {i18n._(t`Groups`)}
- {i18n._(t`Hosts`)}
- {i18n._(t`Sources`)}
{i18n._(t`Actions`)}
}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
index d10dbf2a0a..91f7681f98 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
@@ -89,11 +89,6 @@ function InventoryListItem({
{inventory?.summary_fields?.organization?.name}
- {inventory.total_groups}
- {inventory.total_hosts}
-
- {inventory.total_inventory_sources}
-
{inventory.pending_deletion ? (
{i18n._(t`Pending delete`)}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx
index 67f543c9a7..731e1f8e59 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx
@@ -56,7 +56,7 @@ function InventoryRelatedGroupListItem({
]}
/>
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx
index a85ea121d1..94c21eef73 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx
@@ -88,7 +88,7 @@ function InventorySourceListItem({
{source.summary_fields.user_capabilities.start && (
diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx
index 3b23b1a2b7..8aee3165e5 100644
--- a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx
+++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx
@@ -152,7 +152,7 @@ function SmartInventoryDetail({ inventory, i18n }) {
{user_capabilities?.edit && (
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx
index b371cc31b5..07eda93f84 100644
--- a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx
+++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx
@@ -105,7 +105,7 @@ const InventoryGroupsDeleteModal = ({
setIsModalOpen(false)}
- variant="secondary"
+ variant="link"
key="cancel"
>
{i18n._(t`Cancel`)}
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
index f239081f48..9ee8e2a94e 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
@@ -11,6 +11,7 @@ import {
DetailList,
Detail,
UserDateDetail,
+ LaunchedByDetail,
} from '../../../components/DetailList';
import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup';
@@ -53,35 +54,6 @@ const VERBOSITY = {
4: '4 (Connection Debug)',
};
-const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
- const {
- created_by: createdBy,
- job_template: jobTemplate,
- schedule,
- } = summary_fields;
- const { schedule: relatedSchedule } = related;
-
- if (!createdBy && !schedule) {
- return null;
- }
-
- let link;
- let value;
-
- if (createdBy) {
- link = `/users/${createdBy.id}`;
- value = createdBy.username;
- } else if (relatedSchedule && jobTemplate) {
- link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
- value = schedule.name;
- } else {
- link = null;
- value = schedule.name;
- }
-
- return { link, value };
-};
-
function JobDetail({ job, i18n }) {
const {
created_by,
@@ -107,9 +79,6 @@ function JobDetail({ job, i18n }) {
workflow_job: i18n._(t`Workflow Job`),
};
- const { value: launchedByValue, link: launchedByLink } =
- getLaunchedByDetails(job) || {};
-
const deleteJob = async () => {
try {
switch (job.type) {
@@ -137,7 +106,7 @@ function JobDetail({ job, i18n }) {
}
};
- const isIsolatedInstanceGroup = item => {
+ const buildInstanceGroupLink = item => {
if (item.is_isolated) {
return (
<>
@@ -153,16 +122,26 @@ function JobDetail({ job, i18n }) {
return {item.name};
};
+ const buildContainerGroupLink = item => {
+ return (
+
+ {item.name}
+
+ );
+ };
+
return (
- {/* TODO: hookup status to websockets */}
{job.status && }
- {toTitleCase(job.status)}
+ {job.job_explanation
+ ? job.job_explanation
+ : toTitleCase(job.status)}
}
/>
@@ -207,16 +186,7 @@ function JobDetail({ job, i18n }) {
/>
)}
- {launchedByValue}
- ) : (
- launchedByValue
- )
- }
- />
+
{inventory && (
- {instanceGroup && (
+ {instanceGroup && !instanceGroup?.is_containerized && (
+ )}
+ {instanceGroup && instanceGroup?.is_containerized && (
+
)}
{typeof job.job_slice_number === 'number' &&
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
index 88e32be49f..97c6220d0c 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
@@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
-import { I18n } from '@lingui/react';
+import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
@@ -10,6 +10,7 @@ import {
InfiniteLoader,
List,
} from 'react-virtualized';
+import { Button } from '@patternfly/react-core';
import Ansi from 'ansi-to-html';
import hasAnsi from 'has-ansi';
import { AllHtmlEntities } from 'html-entities';
@@ -225,6 +226,7 @@ class JobOutput extends Component {
this.state = {
contentError: null,
deletionError: null,
+ cancelError: null,
hasContentLoading: true,
results: {},
currentlyLoading: [],
@@ -232,6 +234,9 @@ class JobOutput extends Component {
isHostModalOpen: false,
hostEvent: {},
cssMap: {},
+ jobStatus: props.job.status ?? 'waiting',
+ showCancelPrompt: false,
+ cancelInProgress: false,
};
this.cache = new CellMeasurerCache({
@@ -242,6 +247,9 @@ class JobOutput extends Component {
this._isMounted = false;
this.loadJobEvents = this.loadJobEvents.bind(this);
this.handleDeleteJob = this.handleDeleteJob.bind(this);
+ this.handleCancelOpen = this.handleCancelOpen.bind(this);
+ this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
+ this.handleCancelClose = this.handleCancelClose.bind(this);
this.rowRenderer = this.rowRenderer.bind(this);
this.handleHostEventClick = this.handleHostEventClick.bind(this);
this.handleHostModalClose = this.handleHostModalClose.bind(this);
@@ -261,11 +269,21 @@ class JobOutput extends Component {
this._isMounted = true;
this.loadJobEvents();
+ if (job.result_traceback) return;
+
connectJobSocket(job, data => {
- if (data.counter && data.counter > this.jobSocketCounter) {
- this.jobSocketCounter = data.counter;
- } else if (data.final_counter && data.unified_job_id === job.id) {
- this.jobSocketCounter = data.final_counter;
+ if (data.group_name === 'job_events') {
+ if (data.counter && data.counter > this.jobSocketCounter) {
+ this.jobSocketCounter = data.counter;
+ }
+ }
+ if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
+ if (data.final_counter) {
+ this.jobSocketCounter = data.final_counter;
+ }
+ if (data.status) {
+ this.setState({ jobStatus: data.status });
+ }
}
});
this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000);
@@ -326,10 +344,32 @@ class JobOutput extends Component {
});
this._isMounted &&
this.setState(({ results }) => {
+ let countOffset = 1;
+ if (job?.result_traceback) {
+ const tracebackEvent = {
+ counter: 1,
+ created: null,
+ event: null,
+ type: null,
+ stdout: job?.result_traceback,
+ start_line: 0,
+ };
+ const firstIndex = newResults.findIndex(
+ jobEvent => jobEvent.counter === 1
+ );
+ if (firstIndex && newResults[firstIndex]?.stdout) {
+ const stdoutLines = newResults[firstIndex].stdout.split('\r\n');
+ stdoutLines[0] = tracebackEvent.stdout;
+ newResults[firstIndex].stdout = stdoutLines.join('\r\n');
+ } else {
+ countOffset += 1;
+ newResults.unshift(tracebackEvent);
+ }
+ }
newResults.forEach(jobEvent => {
results[jobEvent.counter] = jobEvent;
});
- return { results, remoteRowCount: count + 1 };
+ return { results, remoteRowCount: count + countOffset };
});
} catch (err) {
this.setState({ contentError: err });
@@ -344,6 +384,26 @@ class JobOutput extends Component {
}
}
+ handleCancelOpen() {
+ this.setState({ showCancelPrompt: true });
+ }
+
+ handleCancelClose() {
+ this.setState({ showCancelPrompt: false });
+ }
+
+ async handleCancelConfirm() {
+ const { job, type } = this.props;
+ this.setState({ cancelInProgress: true });
+ try {
+ await JobsAPI.cancel(job.id, type);
+ } catch (cancelError) {
+ this.setState({ cancelError });
+ } finally {
+ this.setState({ showCancelPrompt: false, cancelInProgress: false });
+ }
+ }
+
async handleDeleteJob() {
const { job, history } = this.props;
try {
@@ -518,7 +578,7 @@ class JobOutput extends Component {
}
render() {
- const { job } = this.props;
+ const { job, i18n } = this.props;
const {
contentError,
@@ -528,6 +588,10 @@ class JobOutput extends Component {
isHostModalOpen,
remoteRowCount,
cssMap,
+ jobStatus,
+ showCancelPrompt,
+ cancelError,
+ cancelInProgress,
} = this.state;
if (hasContentLoading) {
@@ -553,7 +617,12 @@ class JobOutput extends Component {
{job.name}
-
+
+ {showCancelPrompt &&
+ ['pending', 'waiting', 'running'].includes(jobStatus) && (
+
+ {i18n._(t`Cancel job`)}
+ ,
+
+ {i18n._(t`Return`)}
+ ,
+ ]}
+ >
+ {i18n._(
+ t`Are you sure you want to submit the request to cancel this job?`
+ )}
+
+ )}
+ {cancelError && (
+ <>
+ this.setState({ cancelError: null })}
+ title={i18n._(t`Job Cancel Error`)}
+ label={i18n._(t`Job Cancel Error`)}
+ >
+
+
+ >
+ )}
{deletionError && (
<>
-
- {({ i18n }) => (
- this.setState({ deletionError: null })}
- title={i18n._(t`Job Delete Error`)}
- label={i18n._(t`Job Delete Error`)}
- >
-
-
- )}
-
+ this.setState({ deletionError: null })}
+ title={i18n._(t`Job Delete Error`)}
+ label={i18n._(t`Job Delete Error`)}
+ >
+
+
>
)}
@@ -618,4 +731,4 @@ class JobOutput extends Component {
}
export { JobOutput as _JobOutput };
-export default withRouter(JobOutput);
+export default withI18n()(withRouter(JobOutput));
diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
index d98d2f72b2..868b6a1570 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
@@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { shape, func } from 'prop-types';
import {
+ MinusCircleIcon,
DownloadIcon,
RocketIcon,
TrashAltIcon,
@@ -58,7 +59,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
'inventory_update',
];
-const OutputToolbar = ({ i18n, job, onDelete }) => {
+const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count;
@@ -148,19 +149,34 @@ const OutputToolbar = ({ i18n, job, onDelete }) => {
)}
+ {job.summary_fields.user_capabilities.start &&
+ ['pending', 'waiting', 'running'].includes(jobStatus) && (
+
+
+
+
+
+ )}
- {job.summary_fields.user_capabilities.delete && (
-
-
-
-
-
- )}
+ {job.summary_fields.user_capabilities.delete &&
+ ['new', 'successful', 'failed', 'error', 'canceled'].includes(
+ jobStatus
+ ) && (
+
+
+
+
+
+ )}
);
};
diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx
index d5a246b82a..2fa8ca29ae 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx
@@ -16,6 +16,7 @@ describe(' ', () => {
failures: 2,
},
}}
+ jobStatus="successful"
onDelete={() => {}}
/>
);
@@ -33,6 +34,7 @@ describe(' ', () => {
wrapper = mountWithContexts(
{}}
/>
);
@@ -54,6 +56,7 @@ describe(' ', () => {
host_status_counts: {},
playbook_counts: {},
}}
+ jobStatus="successful"
onDelete={() => {}}
/>
);
@@ -74,6 +77,7 @@ describe(' ', () => {
...mockJobData,
elapsed: 274265,
}}
+ jobStatus="successful"
onDelete={() => {}}
/>
);
@@ -95,6 +99,7 @@ describe(' ', () => {
},
},
}}
+ jobStatus="successful"
onDelete={() => {}}
/>
);
@@ -113,6 +118,7 @@ describe(' ', () => {
},
},
}}
+ jobStatus="successful"
onDelete={() => {}}
/>
);
diff --git a/awx/ui_next/src/screens/Login/Login.jsx b/awx/ui_next/src/screens/Login/Login.jsx
index cad87f1dfa..8887d7351f 100644
--- a/awx/ui_next/src/screens/Login/Login.jsx
+++ b/awx/ui_next/src/screens/Login/Login.jsx
@@ -165,6 +165,41 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
);
}
+ if (authKey === 'github-enterprise') {
+ return (
+
+
+
+
+
+ );
+ }
+ if (authKey === 'github-enterprise-org') {
+ return (
+
+
+
+
+
+ );
+ }
+ if (authKey === 'github-enterprise-team') {
+ return (
+
+
+
+
+
+ );
+ }
if (authKey === 'google-oauth2') {
return (
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx
index e243f2c321..eb19b562c8 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx
@@ -127,7 +127,10 @@ function NotificationTemplateListItem({
,
]}
/>
-
+
- Use custom messages to change the content of notifications sent
- when a job starts, succeeds, or fails. Use curly braces to access
- information about the job:{' '}
+
+ Use custom messages to change the content of notifications sent
+ when a job starts, succeeds, or fails. Use curly braces to
+ access information about the job:{' '}
+
{'{{'} job_friendly_name {'}}'}
@@ -79,12 +81,15 @@ function CustomMessagesSubForm({ defaultMessages, type, i18n }) {
{'{{'} url {'}}'}
- , or attributes of the job such as{' '}
+ , or attributes of the job such as {' '}
{'{{'} job.status {'}}'}
- . You may apply a number of possible variables in the message.
- Refer to the{' '}
+ .{' '}
+
+ You may apply a number of possible variables in the message.
+ Refer to the{' '}
+ {' '}
Ansible Tower documentation
{' '}
- for more details.
+ for more details.
diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx
index 1be109e533..03b062a83d 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx
+++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx
@@ -172,7 +172,7 @@ function OrganizationsList({ i18n }) {
key="delete"
onDelete={handleOrgDelete}
itemsToDelete={selected}
- pluralizedItemName="Organizations"
+ pluralizedItemName={i18n._(t`Organizations`)}
/>,
]}
/>
diff --git a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx
index b352e3dda9..7ffec8c2c0 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx
+++ b/awx/ui_next/src/screens/Organization/OrganizationTeams/OrganizationTeamListItem.jsx
@@ -32,7 +32,7 @@ function OrganizationTeamListItem({ i18n, team, detailUrl }) {
]}
/>
diff --git a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx
index dafaf9675a..df2fe6630f 100644
--- a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx
@@ -86,7 +86,7 @@ function ProjectJobTemplateListItem({
]}
/>
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
index a1f6d36f41..acb473a34d 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
@@ -9,10 +9,14 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
-import PaginatedDataList, {
+import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
+import PaginatedTable, {
+ HeaderRow,
+ HeaderCell,
+} from '../../../components/PaginatedTable';
import useWsProjects from './useWsProjects';
import { getQSConfig, parseQueryString } from '../../../util/qs';
@@ -116,7 +120,7 @@ function ProjectList({ i18n }) {
-
+ {i18n._(t`Name`)}
+ {i18n._(t`Status`)}
+ {i18n._(t`Type`)}
+ {i18n._(t`Revision`)}
+ {i18n._(t`Actions`)}
+
+ }
renderToolbar={props => (
)}
- renderItem={o => (
+ renderRow={(project, index) => (
row.id === o.id)}
- onSelect={() => handleSelect(o)}
+ key={project.id}
+ project={project}
+ detailUrl={`${match.url}/${project.id}`}
+ isSelected={selected.some(row => row.id === project.id)}
+ onSelect={() => handleSelect(project)}
+ rowIndex={index}
/>
)}
emptyStateControls={
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
index dba55552d4..88c72e7f89 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
@@ -2,37 +2,22 @@ import 'styled-components/macro';
import React, { Fragment, useState, useCallback } from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
-import {
- Button,
- DataListAction as _DataListAction,
- DataListCheck,
- DataListItem,
- DataListItemRow,
- DataListItemCells,
- Tooltip,
-} from '@patternfly/react-core';
-
+import { Button, Tooltip } from '@patternfly/react-core';
+import { Tr, Td } from '@patternfly/react-table';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
+import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import { formatDateString, timeOfDay } from '../../../util/dates';
import { ProjectsAPI } from '../../../api';
import ClipboardCopyButton from '../../../components/ClipboardCopyButton';
-import StatusIcon from '../../../components/StatusIcon';
-import DataListCell from '../../../components/DataListCell';
+import StatusLabel from '../../../components/StatusLabel';
import { toTitleCase } from '../../../util/strings';
import CopyButton from '../../../components/CopyButton';
import ProjectSyncButton from '../shared/ProjectSyncButton';
import { Project } from '../../../types';
-const DataListAction = styled(_DataListAction)`
- align-items: center;
- display: grid;
- grid-gap: 16px;
- grid-template-columns: repeat(3, 40px);
-`;
-
const Label = styled.span`
color: var(--pf-global--disabled-color--100);
`;
@@ -42,8 +27,9 @@ function ProjectListItem({
isSelected,
onSelect,
detailUrl,
- i18n,
fetchProjects,
+ rowIndex,
+ i18n,
}) {
const [isDisabled, setIsDisabled] = useState(false);
ProjectListItem.propTypes = {
@@ -88,106 +74,89 @@ function ProjectListItem({
}, []);
const labelId = `check-action-${project.id}`;
+
return (
-
-
-
+
+
+
+ {project.name}
+
+
+
+ {project.summary_fields.last_job && (
+
+
+
+
+
+ )}
+
+
+ {project.scm_type === ''
+ ? i18n._(t`Manual`)
+ : toTitleCase(project.scm_type)}
+
+
+ {project.scm_revision.substring(0, 7)}
+ {!project.scm_revision && (
+
+ {i18n._(t`Sync for revision`)}
+
+ )}
+
-
- {project.summary_fields.last_job && (
-
-
-
-
-
- )}
- ,
-
-
- {project.name}
-
- ,
-
- {project.scm_type === ''
- ? i18n._(t`Manual`)
- : toTitleCase(project.scm_type)}
- ,
-
- {project.scm_revision.substring(0, 7)}
- {!project.scm_revision && (
-
- {i18n._(t`Sync for revision`)}
-
- )}
-
- ,
- ]}
- />
-
+
+
- {project.summary_fields.user_capabilities.start && (
-
-
-
- )}
- {project.summary_fields.user_capabilities.edit ? (
-
-
-
-
-
- ) : (
- ''
- )}
- {project.summary_fields.user_capabilities.copy && (
-
- )}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
);
}
export default withI18n()(ProjectListItem);
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx
index 7866015703..21f96efc4d 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx
@@ -10,112 +10,128 @@ jest.mock('../../../api/models/Projects');
describe(' ', () => {
test('launch button shown to users with start capabilities', () => {
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- start: true,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ start: true,
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('ProjectSyncButton').exists()).toBeTruthy();
});
test('launch button hidden from users without start capabilities', () => {
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- start: false,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ start: false,
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('ProjectSyncButton').exists()).toBeFalsy();
});
test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- edit: true,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ edit: true,
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button hidden from users without edit capabilities', () => {
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- edit: false,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ edit: false,
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
@@ -123,29 +139,33 @@ describe(' ', () => {
test('should call api to copy project', async () => {
ProjectsAPI.copy.mockResolvedValue();
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- edit: false,
- copy: true,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ edit: false,
+ copy: true,
+ },
+ },
+ }}
+ />
+
+
);
await act(async () =>
@@ -159,29 +179,33 @@ describe(' ', () => {
ProjectsAPI.copy.mockRejectedValue(new Error());
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- edit: false,
- copy: true,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ edit: false,
+ copy: true,
+ },
+ },
+ }}
+ />
+
+
);
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
@@ -192,56 +216,64 @@ describe(' ', () => {
});
test('should not render copy button', async () => {
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- edit: false,
- copy: false,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ edit: false,
+ copy: false,
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('CopyButton').length).toBe(0);
});
test('should render disabled copy to clipboard button', () => {
const wrapper = mountWithContexts(
- {}}
- project={{
- id: 1,
- name: 'Project 1',
- url: '/api/v2/projects/1',
- type: 'project',
- scm_type: 'git',
- scm_revision: '',
- summary_fields: {
- last_job: {
- id: 9000,
- status: 'successful',
- },
- user_capabilities: {
- edit: true,
- },
- },
- }}
- />
+
+
+ {}}
+ project={{
+ id: 1,
+ name: 'Project 1',
+ url: '/api/v2/projects/1',
+ type: 'project',
+ scm_type: 'git',
+ scm_revision: '',
+ summary_fields: {
+ last_job: {
+ id: 9000,
+ status: 'successful',
+ },
+ user_capabilities: {
+ edit: true,
+ },
+ },
+ }}
+ />
+
+
);
expect(
wrapper.find('span[aria-label="copy to clipboard disabled"]').text()
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ArchiveSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ArchiveSubForm.jsx
index ba65b0b6ff..d06d9ea84b 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ArchiveSubForm.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/ArchiveSubForm.jsx
@@ -21,8 +21,16 @@ const ArchiveSubForm = ({
{i18n._(t`Example URLs for Remote Archive Source Control include:`)}
- https://github.com/username/project/archive/v0.0.1.tar.gz
- https://github.com/username/project/archive/v0.0.2.zip
+
+
+ https://github.com/username/project/archive/v0.0.1.tar.gz
+
+
+
+
+ https://github.com/username/project/archive/v0.0.2.zip
+
+
}
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx
index a1721468dc..32d2d85565 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx
@@ -23,9 +23,15 @@ const GitSubForm = ({
{i18n._(t`Example URLs for GIT Source Control include:`)}
- https://github.com/ansible/ansible.git
- git@github.com:ansible/ansible.git
- git://servername.example.com/ansible.git
+
+ https://github.com/ansible/ansible.git
+
+
+ git@github.com:ansible/ansible.git
+
+
+ git://servername.example.com/ansible.git
+
{i18n._(t`Note: When using SSH protocol for GitHub or
Bitbucket, enter an SSH key only, do not enter a username
@@ -58,8 +64,12 @@ const GitSubForm = ({
{i18n._(t`Examples include:`)}
- refs/*:refs/remotes/origin/*
- refs/pull/62/head:refs/remotes/origin/pull/62/head
+
+ refs/*:refs/remotes/origin/*
+
+
+ refs/pull/62/head:refs/remotes/origin/pull/62/head
+
{i18n._(t`The first fetches all references. The second
fetches the Github pull request number 62, in this example
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx
index f9da184a17..f14d0ad649 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx
@@ -22,9 +22,15 @@ const SvnSubForm = ({
{i18n._(t`Example URLs for Subversion Source Control include:`)}
- https://github.com/ansible/ansible
- svn://servername.example.com/path
- svn+ssh://servername.example.com/path
+
+ https://github.com/ansible/ansible
+
+
+ svn://servername.example.com/path
+
+
+ svn+ssh://servername.example.com/path
+
}
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
index 03d92e5899..aa23ae58ce 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
@@ -8,6 +8,9 @@ import GitHubDetail from './GitHubDetail';
import GitHubEdit from './GitHubEdit';
import GitHubOrgEdit from './GitHubOrgEdit';
import GitHubTeamEdit from './GitHubTeamEdit';
+import GitHubEnterpriseEdit from './GitHubEnterpriseEdit';
+import GitHubEnterpriseOrgEdit from './GitHubEnterpriseOrgEdit';
+import GitHubEnterpriseTeamEdit from './GitHubEnterpriseTeamEdit';
function GitHub({ i18n }) {
const baseURL = '/settings/github';
@@ -40,6 +43,15 @@ function GitHub({ i18n }) {
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
index 68572d6c35..12a2a95261 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
@@ -48,6 +48,44 @@ describe(' ', () => {
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
},
});
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_URL: 'https://localhost/url',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL: 'https://localhost/apiurl',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY: 'ent_key',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET: '$encrypted',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: {},
+ },
+ });
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise-org/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL: 'https://localhost/url',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL: 'https://localhost/apiurl',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY: 'ent_org_key',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME: 'ent_org_name',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: {},
+ },
+ });
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise-team/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL: 'https://localhost/url',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL: 'https://localhost/apiurl',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY: 'ent_team_key',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID: 'ent_team_id',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: {},
+ },
+ });
});
afterEach(() => {
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx
index 5dc76348fb..816ae229b8 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx
@@ -31,21 +31,33 @@ function GitHubDetail({ i18n }) {
{ data: gitHubDefault },
{ data: gitHubOrganization },
{ data: gitHubTeam },
+ { data: gitHubEnterprise },
+ { data: gitHubEnterpriseOrganization },
+ { data: gitHubEnterpriseTeam },
] = await Promise.all([
SettingsAPI.readCategory('github'),
SettingsAPI.readCategory('github-org'),
SettingsAPI.readCategory('github-team'),
+ SettingsAPI.readCategory('github-enterprise'),
+ SettingsAPI.readCategory('github-enterprise-org'),
+ SettingsAPI.readCategory('github-enterprise-team'),
]);
return {
default: gitHubDefault,
organization: gitHubOrganization,
team: gitHubTeam,
+ enterprise: gitHubEnterprise,
+ enterprise_organization: gitHubEnterpriseOrganization,
+ enterprise_team: gitHubEnterpriseTeam,
};
}, []),
{
default: null,
organization: null,
team: null,
+ enterprise: null,
+ enterprise_organization: null,
+ enterprise_team: null,
}
);
@@ -79,6 +91,21 @@ function GitHubDetail({ i18n }) {
link: `${baseURL}/team/details`,
id: 2,
},
+ {
+ name: i18n._(t`GitHub Enterprise`),
+ link: `${baseURL}/enterprise/details`,
+ id: 3,
+ },
+ {
+ name: i18n._(t`GitHub Enterprise Organization`),
+ link: `${baseURL}/enterprise_organization/details`,
+ id: 4,
+ },
+ {
+ name: i18n._(t`GitHub Enterprise Team`),
+ link: `${baseURL}/enterprise_team/details`,
+ id: 5,
+ },
];
if (!Object.keys(gitHubDetails).includes(category)) {
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx
index 11db4b57f0..3d2551e3a3 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.jsx
@@ -51,6 +51,44 @@ const mockTeam = {
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
},
};
+const mockEnterprise = {
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_URL: 'https://localhost/enterpriseurl',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL: 'https://localhost/enterpriseapi',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY: 'foobar',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: null,
+ },
+};
+const mockEnterpriseOrg = {
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise-org/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL: 'https://localhost/orgurl',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL: 'https://localhost/orgapi',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY: 'foobar',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME: 'foo',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: null,
+ },
+};
+const mockEnterpriseTeam = {
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise-team/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL: 'https://localhost/teamurl',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL: 'https://localhost/teamapi',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY: 'foobar',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID: 'foo',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: null,
+ },
+};
describe(' ', () => {
describe('Default', () => {
@@ -60,6 +98,9 @@ describe(' ', () => {
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterprise);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseTeam);
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/default/details',
path: '/settings/github/:category/details',
@@ -90,6 +131,9 @@ describe(' ', () => {
'GitHub Default',
'GitHub Organization',
'GitHub Team',
+ 'GitHub Enterprise',
+ 'GitHub Enterprise Organization',
+ 'GitHub Enterprise Team',
];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
@@ -149,6 +193,9 @@ describe(' ', () => {
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterprise);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseTeam);
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/organization/details',
path: '/settings/github/:category/details',
@@ -198,6 +245,9 @@ describe(' ', () => {
SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterprise);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseTeam);
useRouteMatch.mockImplementation(() => ({
url: '/settings/github/team/details',
path: '/settings/github/:category/details',
@@ -236,6 +286,199 @@ describe(' ', () => {
});
});
+ describe('Enterprise', () => {
+ let wrapper;
+
+ beforeAll(async () => {
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterprise);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseTeam);
+ useRouteMatch.mockImplementation(() => ({
+ url: '/settings/github/enterprise/details',
+ path: '/settings/github/:category/details',
+ params: { category: 'enterprise' },
+ }));
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('should render expected details', () => {
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise OAuth2 Callback URL',
+ 'https://towerhost/sso/complete/github-enterprise/'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise URL',
+ 'https://localhost/enterpriseurl'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise API URL',
+ 'https://localhost/enterpriseapi'
+ );
+ assertDetail(wrapper, 'GitHub Enterprise OAuth2 Key', 'foobar');
+ assertDetail(wrapper, 'GitHub Enterprise OAuth2 Secret', 'Encrypted');
+ assertVariableDetail(
+ wrapper,
+ 'GitHub Enterprise OAuth2 Organization Map',
+ '{}'
+ );
+ assertVariableDetail(wrapper, 'GitHub Enterprise OAuth2 Team Map', '{}');
+ });
+ });
+
+ describe('Enterprise Org', () => {
+ let wrapper;
+
+ beforeAll(async () => {
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterprise);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseTeam);
+ useRouteMatch.mockImplementation(() => ({
+ url: '/settings/github/enterprise_organization/details',
+ path: '/settings/github/:category/details',
+ params: { category: 'enterprise_organization' },
+ }));
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('should render expected details', () => {
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Organization OAuth2 Callback URL',
+ 'https://towerhost/sso/complete/github-enterprise-org/'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Organization URL',
+ 'https://localhost/orgurl'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Organization API URL',
+ 'https://localhost/orgapi'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Organization OAuth2 Key',
+ 'foobar'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Organization OAuth2 Secret',
+ 'Encrypted'
+ );
+ assertDetail(wrapper, 'GitHub Enterprise Organization Name', 'foo');
+ assertVariableDetail(
+ wrapper,
+ 'GitHub Enterprise Organization OAuth2 Organization Map',
+ '{}'
+ );
+ assertVariableDetail(
+ wrapper,
+ 'GitHub Enterprise Organization OAuth2 Team Map',
+ '{}'
+ );
+ });
+ });
+
+ describe('Enterprise Team', () => {
+ let wrapper;
+
+ beforeAll(async () => {
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockDefault);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockTeam);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterprise);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseOrg);
+ SettingsAPI.readCategory.mockResolvedValueOnce(mockEnterpriseTeam);
+ useRouteMatch.mockImplementation(() => ({
+ url: '/settings/github/enterprise_team/details',
+ path: '/settings/github/:category/details',
+ params: { category: 'enterprise_team' },
+ }));
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('should render expected details', () => {
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Team OAuth2 Callback URL',
+ 'https://towerhost/sso/complete/github-enterprise-team/'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Team URL',
+ 'https://localhost/teamurl'
+ );
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Team API URL',
+ 'https://localhost/teamapi'
+ );
+ assertDetail(wrapper, 'GitHub Enterprise Team OAuth2 Key', 'foobar');
+ assertDetail(
+ wrapper,
+ 'GitHub Enterprise Team OAuth2 Secret',
+ 'Encrypted'
+ );
+ assertDetail(wrapper, 'GitHub Enterprise Team ID', 'foo');
+ assertVariableDetail(
+ wrapper,
+ 'GitHub Enterprise Team OAuth2 Organization Map',
+ '{}'
+ );
+ assertVariableDetail(
+ wrapper,
+ 'GitHub Enterprise Team OAuth2 Team Map',
+ '{}'
+ );
+ });
+ });
+
describe('Redirect', () => {
test('should render redirect when user navigates to erroneous category', async () => {
let wrapper;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.jsx
new file mode 100644
index 0000000000..3d6ee60c3e
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.jsx
@@ -0,0 +1,151 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubEnterpriseEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-enterprise');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/enterprise/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/enterprise/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && github && (
+
+ {formik => (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default GitHubEnterpriseEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.jsx
new file mode 100644
index 0000000000..f0bb46ab23
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.jsx
@@ -0,0 +1,196 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubEnterpriseEdit from './GitHubEnterpriseEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: null,
+ },
+});
+
+describe(' ', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/enterprise/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubEnterpriseEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise URL"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise API URL"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise OAuth2 Key"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise OAuth2 Secret"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Enterprise OAuth2 Organization Map"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise OAuth2 Team Map"]')
+ .length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper
+ .find('input#SOCIAL_AUTH_GITHUB_ENTERPRISE_URL')
+ .simulate('change', {
+ target: {
+ value: 'https://localhost',
+ name: 'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL',
+ },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_URL: 'https://localhost',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github enterprise detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/enterprise/details'
+ );
+ });
+
+ test('should navigate to github enterprise detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/enterprise/details'
+ );
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/index.js
new file mode 100644
index 0000000000..5b5377e423
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubEnterpriseEdit';
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.jsx
new file mode 100644
index 0000000000..272b1866ff
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.jsx
@@ -0,0 +1,157 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubEnterpriseOrgEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-enterprise-org');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/enterprise_organization/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/enterprise_organization/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && github && (
+
+ {formik => (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default GitHubEnterpriseOrgEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.jsx
new file mode 100644
index 0000000000..278dd5d37e
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.jsx
@@ -0,0 +1,212 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubEnterpriseOrgEdit from './GitHubEnterpriseOrgEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise-org/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: null,
+ },
+});
+
+describe(' ', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/enterprise_organization/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubEnterpriseOrgEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Organization URL"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Organization API URL"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Enterprise Organization OAuth2 Key"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Enterprise Organization OAuth2 Secret"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Organization Name"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Enterprise Organization OAuth2 Organization Map"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Enterprise Organization OAuth2 Team Map"]'
+ ).length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper
+ .find('input#SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL')
+ .simulate('change', {
+ target: {
+ value: 'https://localhost',
+ name: 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL',
+ },
+ });
+ wrapper
+ .find(
+ 'CodeMirrorInput#SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP'
+ )
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL: 'https://localhost',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github enterprise org detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/enterprise_organization/details'
+ );
+ });
+
+ test('should navigate to github enterprise org detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/enterprise_organization/details'
+ );
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/index.js
new file mode 100644
index 0000000000..e86b1f78f0
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubEnterpriseOrgEdit';
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.jsx
new file mode 100644
index 0000000000..d9b725dc13
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.jsx
@@ -0,0 +1,157 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubEnterpriseTeamEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-enterprise-team');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/enterprise_team/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/enterprise_team/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && github && (
+
+ {formik => (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default GitHubEnterpriseTeamEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.jsx
new file mode 100644
index 0000000000..764c9fa377
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.jsx
@@ -0,0 +1,206 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubEnterpriseTeamEdit from './GitHubEnterpriseTeamEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-enterprise-team/',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: null,
+ },
+});
+
+describe(' ', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/enterprise_team/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubEnterpriseTeamEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Team URL"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Team API URL"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Team OAuth2 Key"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Team OAuth2 Secret"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Team ID"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Enterprise Team OAuth2 Organization Map"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Enterprise Team OAuth2 Team Map"]')
+ .length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper
+ .find('input#SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL')
+ .simulate('change', {
+ target: {
+ value: 'https://localhost',
+ name: 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL',
+ },
+ });
+ wrapper
+ .find(
+ 'CodeMirrorInput#SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP'
+ )
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL: 'https://localhost',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID: '',
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github enterprise team detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/enterprise_team/details'
+ );
+ });
+
+ test('should navigate to github enterprise team detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/enterprise_team/details'
+ );
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/index.js
new file mode 100644
index 0000000000..002e319910
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubEnterpriseTeamEdit';
diff --git a/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx
index d0529ab483..2bc3ca1940 100644
--- a/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx
+++ b/awx/ui_next/src/screens/Setting/Jobs/Jobs.test.jsx
@@ -2,13 +2,13 @@ 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 mockJobSettings from '../shared/data.jobSettings.json';
import { SettingsAPI } from '../../../api';
+import Jobs from './Jobs';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
- data: {},
+ data: mockJobSettings,
});
describe(' ', () => {
diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx
index 7ae08c9276..143c805a01 100644
--- a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx
+++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.jsx
@@ -1,25 +1,242 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Button } from '@patternfly/react-core';
-import { CardBody, CardActionsRow } from '../../../../components/Card';
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import {
+ BooleanField,
+ InputField,
+ ObjectField,
+ RevertAllAlert,
+ RevertFormActionGroup,
+} from '../../shared';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { formatJson } from '../../shared/settingUtils';
+import { SettingsAPI } from '../../../../api';
+
+function JobsEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchJobs, 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,
+ EVENT_STDOUT_MAX_BYTES_DISPLAY,
+ STDOUT_MAX_BYTES_DISPLAY,
+ ...jobsData
+ } = data;
+ const mergedData = {};
+ Object.keys(jobsData).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = jobsData[key];
+ });
+
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchJobs();
+ }, [fetchJobs]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/jobs/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ AD_HOC_COMMANDS: formatJson(form.AD_HOC_COMMANDS),
+ AWX_PROOT_SHOW_PATHS: formatJson(form.AWX_PROOT_SHOW_PATHS),
+ AWX_PROOT_HIDE_PATHS: formatJson(form.AWX_PROOT_HIDE_PATHS),
+ AWX_ANSIBLE_CALLBACK_PLUGINS: formatJson(
+ form.AWX_ANSIBLE_CALLBACK_PLUGINS
+ ),
+ AWX_TASK_ENV: formatJson(form.AWX_TASK_ENV),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = {};
+ Object.entries(jobs).forEach(([key, value]) => {
+ defaultValues[key] = value.default;
+ });
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/jobs/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
-function JobsEdit({ i18n }) {
return (
- {i18n._(t`Edit form coming soon :)`)}
-
-
- {i18n._(t`Cancel`)}
-
-
+ {isLoading && }
+ {!isLoading && error && }
+ {!isLoading && jobs && (
+
+ {formik => {
+ return (
+
+ );
+ }}
+
+ )}
);
}
-export default withI18n()(JobsEdit);
+export default JobsEdit;
diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx
index 06f4fb2f12..14b2fb11ad 100644
--- a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx
+++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/JobsEdit.test.jsx
@@ -1,16 +1,127 @@
import React from 'react';
-import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import mockJobSettings from '../../shared/data.jobSettings.json';
+import mockDefaultJobSettings from './data.defaultJobSettings.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
import JobsEdit from './JobsEdit';
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: mockJobSettings,
+});
+
describe(' ', () => {
let wrapper;
- beforeEach(() => {
- wrapper = mountWithContexts( );
- });
+ let history;
+
afterEach(() => {
wrapper.unmount();
+ jest.clearAllMocks();
});
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/jobs/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
test('initially renders without crashing', () => {
expect(wrapper.find('JobsEdit').length).toBe(1);
});
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith(mockDefaultJobSettings);
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ const {
+ ALLOW_JINJA_IN_EXTRA_VARS,
+ AWX_ISOLATED_KEY_GENERATION,
+ AWX_ISOLATED_PRIVATE_KEY,
+ AWX_ISOLATED_PUBLIC_KEY,
+ EVENT_STDOUT_MAX_BYTES_DISPLAY,
+ STDOUT_MAX_BYTES_DISPLAY,
+ ...jobRequest
+ } = mockJobSettings;
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith(jobRequest);
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should navigate to job settings detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/jobs/details');
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/data.defaultJobSettings.json b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/data.defaultJobSettings.json
new file mode 100644
index 0000000000..70c73869d7
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/Jobs/JobsEdit/data.defaultJobSettings.json
@@ -0,0 +1,48 @@
+{
+ "AD_HOC_COMMANDS": [
+ "command",
+ "shell",
+ "yum",
+ "apt",
+ "apt_key",
+ "apt_repository",
+ "apt_rpm",
+ "service",
+ "group",
+ "user",
+ "mount",
+ "ping",
+ "selinux",
+ "setup",
+ "win_ping",
+ "win_service",
+ "win_updates",
+ "win_group",
+ "win_user"
+ ],
+ "ANSIBLE_FACT_CACHE_TIMEOUT": 0,
+ "AWX_ANSIBLE_CALLBACK_PLUGINS": [],
+ "AWX_COLLECTIONS_ENABLED": true,
+ "AWX_ISOLATED_CHECK_INTERVAL": 1,
+ "AWX_ISOLATED_CONNECTION_TIMEOUT": 10,
+ "AWX_ISOLATED_HOST_KEY_CHECKING": false,
+ "AWX_ISOLATED_LAUNCH_TIMEOUT": 600,
+ "AWX_PROOT_BASE_PATH": "/tmp",
+ "AWX_PROOT_ENABLED": true,
+ "AWX_PROOT_HIDE_PATHS": [],
+ "AWX_PROOT_SHOW_PATHS": [],
+ "AWX_RESOURCE_PROFILING_CPU_POLL_INTERVAL": 0.25,
+ "AWX_RESOURCE_PROFILING_ENABLED": false,
+ "AWX_RESOURCE_PROFILING_MEMORY_POLL_INTERVAL": 0.25,
+ "AWX_RESOURCE_PROFILING_PID_POLL_INTERVAL": 0.25,
+ "AWX_ROLES_ENABLED": true,
+ "AWX_SHOW_PLAYBOOK_LINKS": false,
+ "AWX_TASK_ENV": {},
+ "DEFAULT_INVENTORY_UPDATE_TIMEOUT": 0,
+ "DEFAULT_JOB_TIMEOUT": 0,
+ "DEFAULT_PROJECT_UPDATE_TIMEOUT": 0,
+ "GALAXY_IGNORE_CERTS": false,
+ "MAX_FORKS": 200,
+ "PROJECT_UPDATE_VVV": false,
+ "SCHEDULE_MAX_JOBS": 10
+}
\ No newline at end of file
diff --git a/awx/ui_next/src/screens/Setting/Settings.jsx b/awx/ui_next/src/screens/Setting/Settings.jsx
index a535384afa..8b9c2db334 100644
--- a/awx/ui_next/src/screens/Setting/Settings.jsx
+++ b/awx/ui_next/src/screens/Setting/Settings.jsx
@@ -57,6 +57,17 @@ function Settings({ i18n }) {
'/settings/github/team': i18n._(t`GitHub Team`),
'/settings/github/team/details': i18n._(t`Details`),
'/settings/github/team/edit': i18n._(t`Edit Details`),
+ '/settings/github/enterprise': i18n._(t`GitHub Enterprise`),
+ '/settings/github/enterprise/details': i18n._(t`Details`),
+ '/settings/github/enterprise/edit': i18n._(t`Edit Details`),
+ '/settings/github/enterprise_organization': i18n._(
+ t`GitHub Enterprise Organization`
+ ),
+ '/settings/github/enterprise_organization/details': i18n._(t`Details`),
+ '/settings/github/enterprise_organization/edit': i18n._(t`Edit Details`),
+ '/settings/github/enterprise_team': i18n._(t`GitHub Enterprise Team`),
+ '/settings/github/enterprise_team/details': i18n._(t`Details`),
+ '/settings/github/enterprise_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`),
diff --git a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
index 46ed00e8d6..96983f02e0 100644
--- a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
@@ -24,7 +24,7 @@ function RevertAllAlert({ i18n, onClose, onRevertAll }) {
,
{
expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo');
});
+ test('InputField should revert to expected default value', async () => {
+ const wrapper = mountWithContexts(
+
+ {() => (
+
+ )}
+
+ );
+ expect(wrapper.find('TextInputBase')).toHaveLength(1);
+ expect(wrapper.find('TextInputBase').prop('value')).toEqual(5);
+ await act(async () => {
+ wrapper.find('button[aria-label="Revert"]').invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('TextInputBase').prop('value')).toEqual(0);
+ });
+
test('TextAreaField renders the expected content', async () => {
const wrapper = mountWithContexts(
/.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP": {
+ "type": "nested object",
+ "label": "GitHub Enterprise Organization OAuth2 Organization Map",
+ "help_text": "Mapping to organization admins/users from social auth accounts. This setting\ncontrols which users are placed into which Tower organizations based on their\nusername and email address. Configuration details are available in the Ansible\nTower documentation.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "defined_in_file": false,
+ "child": {
+ "type": "nested object",
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP": {
+ "type": "nested object",
+ "label": "GitHub Enterprise Organization OAuth2 Team Map",
+ "help_text": "Mapping of team members (users) from social auth accounts. Configuration\ndetails are available in Tower documentation.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "defined_in_file": false,
+ "child": {
+ "type": "nested object",
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL": {
+ "type": "string",
+ "label": "GitHub Enterprise Team OAuth2 Callback URL",
+ "help_text": "Create an organization-owned application at https://github.com/organizations//settings/applications and obtain an OAuth2 key (Client ID) and secret (Client Secret). Provide this URL as the callback URL for your application.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL": {
+ "type": "string",
+ "label": "GitHub Enterprise Team URL",
+ "help_text": "The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL": {
+ "type": "string",
+ "label": "GitHub Enterprise Team API URL",
+ "help_text": "The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY": {
+ "type": "string",
+ "label": "GitHub Enterprise Team OAuth2 Key",
+ "help_text": "The OAuth2 key (Client ID) from your GitHub Enterprise organization application.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET": {
+ "type": "string",
+ "label": "GitHub Enterprise Team OAuth2 Secret",
+ "help_text": "The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID": {
+ "type": "string",
+ "label": "GitHub Enterprise Team ID",
+ "help_text": "Find the numeric team ID using the Github Enterprise API: http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP": {
+ "type": "nested object",
+ "label": "GitHub Enterprise Team OAuth2 Organization Map",
+ "help_text": "Mapping to organization admins/users from social auth accounts. This setting\ncontrols which users are placed into which Tower organizations based on their\nusername and email address. Configuration details are available in the Ansible\nTower documentation.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false,
+ "child": {
+ "type": "nested object",
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP": {
+ "type": "nested object",
+ "label": "GitHub Enterprise Team OAuth2 Team Map",
+ "help_text": "Mapping of team members (users) from social auth accounts. Configuration\ndetails are available in Tower documentation.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "defined_in_file": false,
+ "child": {
+ "type": "nested object",
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
"SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL": {
"type": "string",
"label": "Azure AD OAuth2 Callback URL",
@@ -2745,29 +2993,6 @@
"category_slug": "system",
"default": true
},
- "PENDO_TRACKING_STATE": {
- "default": "off",
- "type": "choice",
- "required": true,
- "label": "User Analytics Tracking State",
- "help_text": "Enable or Disable User Analytics Tracking.",
- "category": "UI",
- "category_slug": "ui",
- "choices": [
- [
- "off",
- "Off"
- ],
- [
- "anonymous",
- "Anonymous"
- ],
- [
- "detailed",
- "Detailed"
- ]
- ]
- },
"MANAGE_ORGANIZATION_AUTH": {
"type": "boolean",
"required": true,
@@ -2821,7 +3046,7 @@
"type": "string",
"required": false,
"label": "Red Hat customer username",
- "help_text": "This username is used to retrieve license information and to send Automation Analytics",
+ "help_text": "This username is used to send data to Automation Analytics",
"category": "System",
"category_slug": "system",
"default": ""
@@ -2830,7 +3055,25 @@
"type": "string",
"required": false,
"label": "Red Hat customer password",
- "help_text": "This password is used to retrieve license information and to send Automation Analytics",
+ "help_text": "This password is used to send data to Automation Analytics",
+ "category": "System",
+ "category_slug": "system",
+ "default": ""
+ },
+ "SUBSCRIPTIONS_USERNAME": {
+ "type": "string",
+ "required": false,
+ "label": "Red Hat or Satellite username",
+ "help_text": "This username is used to retrieve subscription and content information",
+ "category": "System",
+ "category_slug": "system",
+ "default": ""
+ },
+ "SUBSCRIPTIONS_PASSWORD": {
+ "type": "string",
+ "required": false,
+ "label": "Red Hat or Satellite password",
+ "help_text": "This password is used to retrieve subscription and content information",
"category": "System",
"category_slug": "system",
"default": ""
@@ -3513,6 +3756,29 @@
"category_slug": "authentication",
"default": ""
},
+ "PENDO_TRACKING_STATE": {
+ "default": "off",
+ "type": "choice",
+ "required": true,
+ "label": "User Analytics Tracking State",
+ "help_text": "Enable or Disable User Analytics Tracking.",
+ "category": "UI",
+ "category_slug": "ui",
+ "choices": [
+ [
+ "off",
+ "Off"
+ ],
+ [
+ "anonymous",
+ "Anonymous"
+ ],
+ [
+ "detailed",
+ "Detailed"
+ ]
+ ]
+ },
"CUSTOM_LOGIN_INFO": {
"type": "string",
"required": false,
@@ -6067,6 +6333,357 @@
}
}
},
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_URL": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise URL",
+ "help_text": "The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise API URL",
+ "help_text": "The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise OAuth2 Key",
+ "help_text": "The OAuth2 key (Client ID) from your GitHub Enterprise developer application.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise OAuth2 Secret",
+ "help_text": "The OAuth2 secret (Client Secret) from your GitHub Enterprise developer application.",
+ "category": "GitHub OAuth2",
+ "category_slug": "github-enterprise",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP": {
+ "type": "nested object",
+ "required": false,
+ "label": "GitHub Enterprise OAuth2 Organization Map",
+ "help_text": "Mapping to organization admins/users from social auth accounts. This setting\ncontrols which users are placed into which Tower organizations based on their\nusername and email address. Configuration details are available in the Ansible\nTower documentation.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise",
+ "placeholder": {
+ "Default": {
+ "users": true
+ },
+ "Test Org": {
+ "admins": [
+ "admin@example.com"
+ ],
+ "auditors": [
+ "auditor@example.com"
+ ],
+ "users": true
+ },
+ "Test Org 2": {
+ "admins": [
+ "admin@example.com",
+ "/^tower-[^@]+*?@.*$/"
+ ],
+ "remove_admins": true,
+ "users": "/^[^@].*?@example\\.com$/i",
+ "remove_users": true
+ }
+ },
+ "default": null,
+ "child": {
+ "type": "nested object",
+ "required": true,
+ "read_only": false,
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP": {
+ "type": "nested object",
+ "required": false,
+ "label": "GitHub Enterprise OAuth2 Team Map",
+ "help_text": "Mapping of team members (users) from social auth accounts. Configuration\ndetails are available in Tower documentation.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise",
+ "placeholder": {
+ "My Team": {
+ "organization": "Test Org",
+ "users": [
+ "/^[^@]+?@test\\.example\\.com$/"
+ ],
+ "remove": true
+ },
+ "Other Team": {
+ "organization": "Test Org 2",
+ "users": "/^[^@]+?@test2\\.example\\.com$/i",
+ "remove": false
+ }
+ },
+ "default": null,
+ "child": {
+ "type": "nested object",
+ "required": true,
+ "read_only": false,
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Organization URL",
+ "help_text": "The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise-org",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Organization API URL",
+ "help_text": "The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise-org",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Organization OAuth2 Key",
+ "help_text": "The OAuth2 key (Client ID) from your GitHub Enterprise organization application.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Organization OAuth2 Secret",
+ "help_text": "The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Organization Name",
+ "help_text": "The name of your GitHub Enterprise organization, as used in your organization's URL: https://github.com//.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP": {
+ "type": "nested object",
+ "required": false,
+ "label": "GitHub Enterprise Organization OAuth2 Organization Map",
+ "help_text": "Mapping to organization admins/users from social auth accounts. This setting\ncontrols which users are placed into which Tower organizations based on their\nusername and email address. Configuration details are available in the Ansible\nTower documentation.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "placeholder": {
+ "Default": {
+ "users": true
+ },
+ "Test Org": {
+ "admins": [
+ "admin@example.com"
+ ],
+ "auditors": [
+ "auditor@example.com"
+ ],
+ "users": true
+ },
+ "Test Org 2": {
+ "admins": [
+ "admin@example.com",
+ "/^tower-[^@]+*?@.*$/"
+ ],
+ "remove_admins": true,
+ "users": "/^[^@].*?@example\\.com$/i",
+ "remove_users": true
+ }
+ },
+ "default": null,
+ "child": {
+ "type": "nested object",
+ "required": true,
+ "read_only": false,
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP": {
+ "type": "nested object",
+ "required": false,
+ "label": "GitHub Enterprise Organization OAuth2 Team Map",
+ "help_text": "Mapping of team members (users) from social auth accounts. Configuration\ndetails are available in Tower documentation.",
+ "category": "GitHub Enterprise Organization OAuth2",
+ "category_slug": "github-enterprise-org",
+ "placeholder": {
+ "My Team": {
+ "organization": "Test Org",
+ "users": [
+ "/^[^@]+?@test\\.example\\.com$/"
+ ],
+ "remove": true
+ },
+ "Other Team": {
+ "organization": "Test Org 2",
+ "users": "/^[^@]+?@test2\\.example\\.com$/i",
+ "remove": false
+ }
+ },
+ "default": null,
+ "child": {
+ "type": "nested object",
+ "required": true,
+ "read_only": false,
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Team URL",
+ "help_text": "The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise-team",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Team API URL",
+ "help_text": "The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.",
+ "category": "GitHub Enterprise OAuth2",
+ "category_slug": "github-enterprise-team",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Team OAuth2 Key",
+ "help_text": "The OAuth2 key (Client ID) from your GitHub Enterprise organization application.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Team OAuth2 Secret",
+ "help_text": "The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID": {
+ "type": "string",
+ "required": false,
+ "label": "GitHub Enterprise Team ID",
+ "help_text": "Find the numeric team ID using the Github Enterprise API: http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "default": ""
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP": {
+ "type": "nested object",
+ "required": false,
+ "label": "GitHub Enterprise Team OAuth2 Organization Map",
+ "help_text": "Mapping to organization admins/users from social auth accounts. This setting\ncontrols which users are placed into which Tower organizations based on their\nusername and email address. Configuration details are available in the Ansible\nTower documentation.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "placeholder": {
+ "Default": {
+ "users": true
+ },
+ "Test Org": {
+ "admins": [
+ "admin@example.com"
+ ],
+ "auditors": [
+ "auditor@example.com"
+ ],
+ "users": true
+ },
+ "Test Org 2": {
+ "admins": [
+ "admin@example.com",
+ "/^tower-[^@]+*?@.*$/"
+ ],
+ "remove_admins": true,
+ "users": "/^[^@].*?@example\\.com$/i",
+ "remove_users": true
+ }
+ },
+ "default": null,
+ "child": {
+ "type": "nested object",
+ "required": true,
+ "read_only": false,
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
+ "SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP": {
+ "type": "nested object",
+ "required": false,
+ "label": "GitHub Enterprise Team OAuth2 Team Map",
+ "help_text": "Mapping of team members (users) from social auth accounts. Configuration\ndetails are available in Tower documentation.",
+ "category": "GitHub Enterprise Team OAuth2",
+ "category_slug": "github-enterprise-team",
+ "placeholder": {
+ "My Team": {
+ "organization": "Test Org",
+ "users": [
+ "/^[^@]+?@test\\.example\\.com$/"
+ ],
+ "remove": true
+ },
+ "Other Team": {
+ "organization": "Test Org 2",
+ "users": "/^[^@]+?@test2\\.example\\.com$/i",
+ "remove": false
+ }
+ },
+ "default": null,
+ "child": {
+ "type": "nested object",
+ "required": true,
+ "read_only": false,
+ "child": {
+ "type": "field",
+ "required": true,
+ "read_only": false
+ }
+ }
+ },
"SOCIAL_AUTH_AZUREAD_OAUTH2_KEY": {
"type": "string",
"required": false,
@@ -6520,4 +7137,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx
index e128e9890a..3e91b5a226 100644
--- a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx
+++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx
@@ -9,7 +9,11 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
-import PaginatedDataList, {
+import PaginatedTable, {
+ HeaderRow,
+ HeaderCell,
+} from '../../../components/PaginatedTable';
+import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
@@ -112,7 +116,7 @@ function TeamList({ i18n }) {
-
+ {i18n._(t`Name`)}
+ {i18n._(t`Organization`)}
+ {i18n._(t`Actions`)}
+
+ }
renderToolbar={props => (
)}
- renderItem={o => (
+ renderRow={(team, index) => (
row.id === o.id)}
- onSelect={() => handleSelect(o)}
+ key={team.id}
+ team={team}
+ detailUrl={`${match.url}/${team.id}`}
+ isSelected={selected.some(row => row.id === team.id)}
+ onSelect={() => handleSelect(team)}
+ rowIndex={index}
/>
)}
emptyStateControls={
diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx
index 6d3691da71..a6f56eee3c 100644
--- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx
+++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx
@@ -1,33 +1,23 @@
import 'styled-components/macro';
-import React, { Fragment } from 'react';
+import React from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import {
- Button,
- DataListAction as _DataListAction,
- DataListCheck,
- DataListItem,
- DataListItemCells,
- DataListItemRow,
- Tooltip,
-} from '@patternfly/react-core';
-
-import styled from 'styled-components';
+import { Button } from '@patternfly/react-core';
+import { Tr, Td } from '@patternfly/react-table';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
-import DataListCell from '../../../components/DataListCell';
-
+import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import { Team } from '../../../types';
-const DataListAction = styled(_DataListAction)`
- align-items: center;
- display: grid;
- grid-gap: 16px;
- grid-template-columns: 40px;
-`;
-
-function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) {
+function TeamListItem({
+ team,
+ isSelected,
+ onSelect,
+ detailUrl,
+ rowIndex,
+ i18n,
+}) {
TeamListItem.propTypes = {
team: Team.isRequired,
detailUrl: string.isRequired,
@@ -38,57 +28,45 @@ function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) {
const labelId = `check-action-${team.id}`;
return (
-
-
-
-
-
- {team.name}
-
- ,
-
- {team.summary_fields.organization && (
-
- {i18n._(t`Organization`)} {' '}
-
- {team.summary_fields.organization.name}
-
-
- )}
- ,
- ]}
- />
-
+
+
+
+ {team.name}
+
+
+
+ {team.summary_fields.organization && (
+
+ {team.summary_fields.organization.name}
+
+ )}
+
+
+
- {team.summary_fields.user_capabilities.edit ? (
-
-
-
-
-
- ) : (
- ''
- )}
-
-
-
+
+
+
+
+
+
);
}
export default withI18n()(TeamListItem);
diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx
index ffc2af1f35..377d37184e 100644
--- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx
+++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx
@@ -11,20 +11,24 @@ describe(' ', () => {
mountWithContexts(
- {}}
- />
+
);
@@ -33,20 +37,24 @@ describe(' ', () => {
const wrapper = mountWithContexts(
- {}}
- />
+
);
@@ -56,20 +64,24 @@ describe(' ', () => {
const wrapper = mountWithContexts(
- {}}
- />
+
);
diff --git a/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx
index a61e8c6f3d..1e65d52f54 100644
--- a/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx
+++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx
@@ -205,7 +205,7 @@ function TeamRolesList({ i18n, me, team }) {
,
setRoleToDisassociate(null)}
>
diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx
index 399efe9922..45ff07dd79 100644
--- a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx
+++ b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx
@@ -107,7 +107,7 @@ function SurveyList({
,
{
setIsDeleteModalOpen(false);
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
deleted file mode 100644
index 242f757a54..0000000000
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import 'styled-components/macro';
-import React, { useState, useCallback } from 'react';
-import { Link } from 'react-router-dom';
-import {
- Button,
- DataListAction as _DataListAction,
- DataListCheck,
- DataListItem,
- DataListItemRow,
- DataListItemCells,
- Tooltip,
-} from '@patternfly/react-core';
-import { t } from '@lingui/macro';
-import { withI18n } from '@lingui/react';
-import {
- ExclamationTriangleIcon,
- PencilAltIcon,
- ProjectDiagramIcon,
- RocketIcon,
-} from '@patternfly/react-icons';
-import styled from 'styled-components';
-import DataListCell from '../../../components/DataListCell';
-
-import { timeOfDay } from '../../../util/dates';
-
-import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
-import LaunchButton from '../../../components/LaunchButton';
-import Sparkline from '../../../components/Sparkline';
-import { toTitleCase } from '../../../util/strings';
-import CopyButton from '../../../components/CopyButton';
-
-const DataListAction = styled(_DataListAction)`
- align-items: center;
- display: grid;
- grid-gap: 16px;
- grid-template-columns: repeat(4, 40px);
-`;
-
-function TemplateListItem({
- i18n,
- template,
- isSelected,
- onSelect,
- detailUrl,
- fetchTemplates,
-}) {
- const [isDisabled, setIsDisabled] = useState(false);
- const labelId = `check-action-${template.id}`;
-
- const copyTemplate = useCallback(async () => {
- if (template.type === 'job_template') {
- await JobTemplatesAPI.copy(template.id, {
- name: `${template.name} @ ${timeOfDay()}`,
- });
- } else {
- await WorkflowJobTemplatesAPI.copy(template.id, {
- name: `${template.name} @ ${timeOfDay()}`,
- });
- }
- await fetchTemplates();
- }, [fetchTemplates, template.id, template.name, template.type]);
-
- const handleCopyStart = useCallback(() => {
- setIsDisabled(true);
- }, []);
-
- const handleCopyFinish = useCallback(() => {
- setIsDisabled(false);
- }, []);
-
- const missingResourceIcon =
- template.type === 'job_template' &&
- (!template.summary_fields.project ||
- (!template.summary_fields.inventory &&
- !template.ask_inventory_on_launch));
- return (
-
-
-
-
-
-
- {template.name}
-
-
- {missingResourceIcon && (
-
-
-
-
-
- )}
- ,
-
- {toTitleCase(template.type)}
- ,
-
-
- ,
- ]}
- />
-
- {template.type === 'workflow_job_template' && (
-
-
-
-
-
- )}
- {template.summary_fields.user_capabilities.start && (
-
-
- {({ handleLaunch }) => (
-
-
-
- )}
-
-
- )}
- {template.summary_fields.user_capabilities.edit && (
-
-
-
-
-
- )}
- {template.summary_fields.user_capabilities.copy && (
-
- )}
-
-
-
- );
-}
-
-export { TemplateListItem as _TemplateListItem };
-export default withI18n()(TemplateListItem);
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.test.jsx
deleted file mode 100644
index 3c02c82672..0000000000
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.test.jsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import React from 'react';
-
-import { createMemoryHistory } from 'history';
-import { act } from 'react-dom/test-utils';
-import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
-import { JobTemplatesAPI } from '../../../api';
-import mockJobTemplateData from '../shared/data.job_template.json';
-import TemplateListItem from './TemplateListItem';
-
-jest.mock('../../../api');
-
-describe(' ', () => {
- test('launch button shown to users with start capabilities', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
- });
- test('launch button hidden from users without start capabilities', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
- });
- test('edit button shown to users with edit capabilities', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
- });
- test('edit button hidden from users without edit capabilities', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
- });
- test('missing resource icon is shown.', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
- });
- test('missing resource icon is not shown when there is a project and an inventory.', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
- });
- test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
- });
- test('missing resource icon is not shown type is workflow_job_template', () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
- });
- test('clicking on template from templates list navigates properly', () => {
- const history = createMemoryHistory({
- initialEntries: ['/templates'],
- });
- const wrapper = mountWithContexts(
- ,
- { context: { router: { history } } }
- );
- wrapper.find('Link').simulate('click', { button: 0 });
- expect(history.location.pathname).toEqual(
- '/templates/job_template/1/details'
- );
- });
- test('should call api to copy template', async () => {
- JobTemplatesAPI.copy.mockResolvedValue();
-
- const wrapper = mountWithContexts(
-
- );
- await act(async () =>
- wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
- );
- expect(JobTemplatesAPI.copy).toHaveBeenCalled();
- jest.clearAllMocks();
- });
-
- test('should render proper alert modal on copy error', async () => {
- JobTemplatesAPI.copy.mockRejectedValue(new Error());
-
- const wrapper = mountWithContexts(
-
- );
- await act(async () =>
- wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
- );
- wrapper.update();
- expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
- jest.clearAllMocks();
- });
-
- test('should not render copy button', async () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('CopyButton').length).toBe(0);
- });
-
- test('should render visualizer button for workflow', async () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
- });
-
- test('should not render visualizer button for job template', async () => {
- const wrapper = mountWithContexts(
-
- );
- expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
- });
-});
diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx
index f3905608cc..c5c5e1335f 100644
--- a/awx/ui_next/src/screens/Template/Templates.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.jsx
@@ -5,7 +5,7 @@ import { Route, withRouter, Switch } from 'react-router-dom';
import { PageSection } from '@patternfly/react-core';
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
-import { TemplateList } from './TemplateList';
+import TemplateList from '../../components/TemplateList';
import Template from './Template';
import WorkflowJobTemplate from './WorkflowJobTemplate';
import JobTemplateAdd from './JobTemplateAdd';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx
index 57d177b75c..bae18f2011 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx
@@ -22,7 +22,7 @@ function DeleteAllNodesModal({ i18n }) {
dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
>
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx
index 6c2c49ef04..e0f9252cbb 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx
@@ -14,7 +14,7 @@ function LinkDeleteModal({ i18n }) {
return (
dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
actions={[
@@ -32,7 +32,7 @@ function LinkDeleteModal({ i18n }) {
aria-label={i18n._(t`Cancel link removal`)}
key="cancel"
onClick={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
- variant="secondary"
+ variant="link"
>
{i18n._(t`Cancel`)}
,
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx
index ed11bf59c2..b3469e93dd 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx
@@ -36,7 +36,7 @@ function LinkModal({ header, i18n, onConfirm }) {
dispatch({ type: 'CANCEL_LINK_MODAL' })}
>
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx
index c7fcbde268..2f150c3472 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx
@@ -31,7 +31,7 @@ function NodeDeleteModal({ i18n }) {
dispatch({ type: 'SET_NODE_TO_DELETE', value: null })}
>
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
index e1bf669a60..55b485c438 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
@@ -293,10 +293,14 @@ describe(' ', () => {
).toBe(true);
expect(
- wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('readOnly')
+ wrapper
+ .find('input[aria-label="workflow job template webhook key"]')
+ .prop('readOnly')
).toBe(true);
expect(
- wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('value')
+ wrapper
+ .find('input[aria-label="workflow job template webhook key"]')
+ .prop('value')
).toBe('webhook key');
await act(() =>
wrapper.find('Button[aria-label="Update webhook key"]').prop('onClick')()
diff --git a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx
index b5d5595baf..cf8a51d448 100644
--- a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx
@@ -196,7 +196,7 @@ function WebhookSubForm({ i18n, templateType }) {
', () => {
wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
).toContain('/api/v2/job_templates/51/github/');
expect(
- wrapper.find('TextInputBase[aria-label="wfjt-webhook-key"]').prop('value')
+ wrapper
+ .find('TextInputBase[aria-label="workflow job template webhook key"]')
+ .prop('value')
).toBe('webhook key');
expect(
wrapper
@@ -89,7 +91,9 @@ describe(' ', () => {
wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
).toContain('/api/v2/job_templates/51/gitlab/');
expect(
- wrapper.find('TextInputBase[aria-label="wfjt-webhook-key"]').prop('value')
+ wrapper
+ .find('TextInputBase[aria-label="workflow job template webhook key"]')
+ .prop('value')
).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.');
});
test('should have disabled button to update webhook key', async () => {
diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
index c9bef838b8..7b796b37fc 100644
--- a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
+++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
@@ -221,10 +221,14 @@ describe(' ', () => {
wrapper.find('Checkbox[aria-label="Enable Webhook"]').prop('isChecked')
).toBe(true);
expect(
- wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('readOnly')
+ wrapper
+ .find('input[aria-label="workflow job template webhook key"]')
+ .prop('readOnly')
).toBe(true);
expect(
- wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('value')
+ wrapper
+ .find('input[aria-label="workflow job template webhook key"]')
+ .prop('value')
).toBe('sdfghjklmnbvcdsew435678iokjhgfd');
await act(() =>
wrapper.find('Button[aria-label="Update webhook key"]').prop('onClick')()
diff --git a/awx/ui_next/src/screens/User/UserList/UserList.jsx b/awx/ui_next/src/screens/User/UserList/UserList.jsx
index 635073acc6..9a48938744 100644
--- a/awx/ui_next/src/screens/User/UserList/UserList.jsx
+++ b/awx/ui_next/src/screens/User/UserList/UserList.jsx
@@ -7,7 +7,11 @@ import { UsersAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
-import PaginatedDataList, {
+import PaginatedTable, {
+ HeaderRow,
+ HeaderCell,
+} from '../../../components/PaginatedTable';
+import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
@@ -101,7 +105,7 @@ function UserList({ i18n }) {
<>
- (
@@ -162,18 +152,34 @@ function UserList({ i18n }) {
key="delete"
onDelete={handleUserDelete}
itemsToDelete={selected}
- pluralizedItemName="Users"
+ pluralizedItemName={i18n._(t`Users`)}
/>,
]}
/>
)}
- renderItem={o => (
+ headerRow={
+
+
+ {i18n._(t`Username`)}
+
+
+ {i18n._(t`First Name`)}
+
+
+ {i18n._(t`Last Name`)}
+
+ {i18n._(t`Role`)}
+ {i18n._(t`Actions`)}
+
+ }
+ renderRow={(user, index) => (
row.id === o.id)}
- onSelect={() => handleSelect(o)}
+ key={user.id}
+ user={user}
+ detailUrl={`${match.url}/${user.id}/details`}
+ isSelected={selected.some(row => row.id === user.id)}
+ onSelect={() => handleSelect(user)}
+ rowIndex={index}
/>
)}
emptyStateControls={
diff --git a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx
index 46cf02c128..21efc99d9c 100644
--- a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx
+++ b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx
@@ -129,51 +129,66 @@ describe('UsersList with full permissions', () => {
test('should check and uncheck the row item', async () => {
expect(
- wrapper.find('DataListCheck[id="select-user-1"]').props().checked
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .props().checked
).toBe(false);
await act(async () => {
- wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')(
- true
- );
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .invoke('onChange')(true);
});
wrapper.update();
expect(
- wrapper.find('DataListCheck[id="select-user-1"]').props().checked
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .props().checked
).toBe(true);
await act(async () => {
- wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')(
- false
- );
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .invoke('onChange')(false);
});
wrapper.update();
expect(
- wrapper.find('DataListCheck[id="select-user-1"]').props().checked
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
- wrapper.find('DataListCheck').forEach(el => {
+ expect(wrapper.find('.pf-c-table__check input')).toHaveLength(2);
+ wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
- wrapper.find('DataListCheck').forEach(el => {
+ wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
- wrapper.find('DataListCheck').forEach(el => {
+ wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should call api delete users for each selected user', async () => {
await act(async () => {
- wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')();
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .invoke('onChange')();
});
wrapper.update();
await act(async () => {
@@ -185,10 +200,12 @@ describe('UsersList with full permissions', () => {
test('should show error modal when user is not successfully deleted from api', async () => {
UsersAPI.destroy.mockImplementationOnce(() => Promise.reject(new Error()));
- // expect(wrapper.debug()).toBe(false);
expect(wrapper.find('Modal').length).toBe(0);
await act(async () => {
- wrapper.find('DataListCheck[id="select-user-1"]').invoke('onChange')();
+ wrapper
+ .find('.pf-c-table__check input')
+ .first()
+ .invoke('onChange')();
});
wrapper.update();
await act(async () => {
diff --git a/awx/ui_next/src/screens/User/UserList/UserListItem.jsx b/awx/ui_next/src/screens/User/UserList/UserListItem.jsx
index 9015b1675d..ba4cc016fe 100644
--- a/awx/ui_next/src/screens/User/UserList/UserListItem.jsx
+++ b/awx/ui_next/src/screens/User/UserList/UserListItem.jsx
@@ -3,24 +3,22 @@ import React, { Fragment } from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import {
- Button,
- DataListAction,
- DataListCheck,
- DataListItem,
- DataListItemCells,
- DataListItemRow,
- Label,
- Tooltip,
-} from '@patternfly/react-core';
-
+import { Button, Label } from '@patternfly/react-core';
+import { Tr, Td } from '@patternfly/react-table';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
-import DataListCell from '../../../components/DataListCell';
+import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import { User } from '../../../types';
-function UserListItem({ user, isSelected, onSelect, detailUrl, i18n }) {
+function UserListItem({
+ user,
+ isSelected,
+ onSelect,
+ detailUrl,
+ rowIndex,
+ i18n,
+}) {
const labelId = `check-action-${user.id}`;
let user_type;
@@ -36,84 +34,64 @@ function UserListItem({ user, isSelected, onSelect, detailUrl, i18n }) {
const socialAuthUser = user.auth.length > 0;
return (
-
-
-
-
-
-
- {user.username}
-
-
- {ldapUser && (
-
-
- {i18n._(t`LDAP`)}
-
-
- )}
- {socialAuthUser && (
-
-
- {i18n._(t`SOCIAL`)}
-
-
- )}
- ,
-
- {user.first_name && (
-
- {i18n._(t`First Name`)}
- {user.first_name}
-
- )}
- ,
-
- {user.last_name && (
-
- {i18n._(t`Last Name`)}
- {user.last_name}
-
- )}
- ,
-
- {user_type}
- ,
- ]}
- />
-
+
+
+
+ {user.username}
+
+ {ldapUser && (
+
+ {i18n._(t`LDAP`)}
+
+ )}
+ {socialAuthUser && (
+
+
+ {i18n._(t`SOCIAL`)}
+
+
+ )}
+
+
+ {user.first_name && (
+
+ {i18n._(t`First Name`)}
+ {user.first_name}
+
+ )}
+
+
+ {user.last_name && (
+
+ {i18n._(t`Last Name`)}
+ {user.last_name}
+
+ )}
+
+ {user_type}
+
+
- {user.summary_fields.user_capabilities.edit && (
-
-
-
-
-
- )}
-
-
-
+
+
+
+
+
+
);
}
diff --git a/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx b/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx
index fd14bdc09b..062c8b6c0f 100644
--- a/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx
+++ b/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx
@@ -18,12 +18,16 @@ describe('UserListItem with full permissions', () => {
wrapper = mountWithContexts(
- {}}
- />
+
);
@@ -36,9 +40,9 @@ describe('UserListItem with full permissions', () => {
});
test('should display user data', () => {
- expect(
- wrapper.find('DataListCell[aria-label="user type"]').prop('children')
- ).toEqual('System Administrator');
+ expect(wrapper.find('td[data-label="Role"]').prop('children')).toEqual(
+ 'System Administrator'
+ );
expect(
wrapper.find('Label[aria-label="social login"]').prop('children')
).toEqual('SOCIAL');
@@ -50,19 +54,23 @@ describe('UserListItem without full permissions', () => {
wrapper = mountWithContexts(
- {}}
- />
+
);
diff --git a/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx b/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx
index 9e6c8e9757..6ce20a7dd1 100644
--- a/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx
+++ b/awx/ui_next/src/screens/User/UserRoles/UserRolesList.jsx
@@ -205,7 +205,7 @@ function UserRolesList({ i18n, user }) {
,
setRoleToDisassociate(null)}
>
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx
index bf78d30a8d..f042f301ea 100644
--- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx
@@ -4,9 +4,11 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { WorkflowApprovalsAPI } from '../../../api';
-import PaginatedDataList, {
- ToolbarDeleteButton,
-} from '../../../components/PaginatedDataList';
+import PaginatedTable, {
+ HeaderRow,
+ HeaderCell,
+} from '../../../components/PaginatedTable';
+import { ToolbarDeleteButton } from '../../../components/PaginatedDataList';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import DataListToolbar from '../../../components/DataListToolbar';
@@ -155,7 +157,7 @@ function WorkflowApprovalsList({ i18n }) {
<>
- (
)}
- renderItem={workflowApproval => (
+ headerRow={
+
+ {i18n._(t`Name`)}
+ {i18n._(t`Job`)}
+ {i18n._(t`Started`)}
+ {i18n._(t`Status`)}
+
+ }
+ renderRow={(workflowApproval, index) => (
handleSelect(workflowApproval)}
onSuccessfulAction={fetchWorkflowApprovals}
+ rowIndex={index}
/>
)}
/>
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
index 085cc33ba2..95aab631ca 100644
--- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
@@ -2,27 +2,14 @@ import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { string, bool, func } from 'prop-types';
-import {
- DataListCheck,
- DataListItem,
- DataListItemCells,
- DataListItemRow,
- Label,
-} from '@patternfly/react-core';
+import { Label } from '@patternfly/react-core';
+import { Tr, Td } from '@patternfly/react-table';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
-import DataListCell from '../../../components/DataListCell';
import { WorkflowApproval } from '../../../types';
import { formatDateString } from '../../../util/dates';
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
-const StatusCell = styled(DataListCell)`
- @media screen and (min-width: 768px) {
- display: flex;
- justify-content: flex-end;
- }
-`;
-
const JobLabel = styled.b`
margin-right: 24px;
`;
@@ -32,6 +19,7 @@ function WorkflowApprovalListItem({
isSelected,
onSelect,
detailUrl,
+ rowIndex,
i18n,
}) {
const labelId = `check-action-${workflowApproval.id}`;
@@ -62,44 +50,39 @@ function WorkflowApprovalListItem({
};
return (
-
-
-
-
-
- {workflowApproval.name}
-
- ,
-
- <>
- {i18n._(t`Job`)}
- {workflowJob && workflowJob?.id ? (
-
- {`${workflowJob?.id} - ${workflowJob?.name}`}
-
- ) : (
- i18n._(t`Deleted`)
- )}
- >
- ,
-
- {getStatus()}
- ,
- ]}
- />
-
-
+
+
+
+
+ {workflowApproval.name}
+
+
+
+ <>
+ {i18n._(t`Job`)}
+ {workflowJob && workflowJob?.id ? (
+
+ {`${workflowJob?.id} - ${workflowJob?.name}`}
+
+ ) : (
+ i18n._(t`Deleted`)
+ )}
+ >
+
+
+ {formatDateString(workflowApproval.started)}
+
+
+ {getStatus()}
+
+
);
}
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx
index 2709ab2fa9..e64db9f3cc 100644
--- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx
@@ -12,91 +12,112 @@ describe(' ', () => {
afterEach(() => {
wrapper.unmount();
});
+
test('should display never expires status', () => {
wrapper = mountWithContexts(
- {}}
- workflowApproval={workflowApproval}
- />
+
+
+ {}}
+ workflowApproval={workflowApproval}
+ />
+
+
);
expect(wrapper.find('Label[children="Never expires"]').length).toBe(1);
});
test('should display timed out status', () => {
wrapper = mountWithContexts(
- {}}
- workflowApproval={{
- ...workflowApproval,
- status: 'failed',
- timed_out: true,
- }}
- />
+
+
+ {}}
+ workflowApproval={{
+ ...workflowApproval,
+ status: 'failed',
+ timed_out: true,
+ }}
+ />
+
+
);
expect(wrapper.find('Label[children="Timed out"]').length).toBe(1);
});
test('should display canceled status', () => {
wrapper = mountWithContexts(
- {}}
- workflowApproval={{
- ...workflowApproval,
- canceled_on: '2020-10-09T19:59:26.974046Z',
- status: 'canceled',
- }}
- />
+
+
+ {}}
+ workflowApproval={{
+ ...workflowApproval,
+ canceled_on: '2020-10-09T19:59:26.974046Z',
+ status: 'canceled',
+ }}
+ />
+
+
);
expect(wrapper.find('Label[children="Canceled"]').length).toBe(1);
});
test('should display approved status', () => {
wrapper = mountWithContexts(
- {}}
- workflowApproval={{
- ...workflowApproval,
- status: 'successful',
- summary_fields: {
- ...workflowApproval.summary_fields,
- approved_or_denied_by: {
- id: 1,
- username: 'admin',
- first_name: '',
- last_name: '',
- },
- },
- }}
- />
+
+
+ {}}
+ workflowApproval={{
+ ...workflowApproval,
+ status: 'successful',
+ summary_fields: {
+ ...workflowApproval.summary_fields,
+ approved_or_denied_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('Label[children="Approved"]').length).toBe(1);
});
test('should display denied status', () => {
wrapper = mountWithContexts(
- {}}
- workflowApproval={{
- ...workflowApproval,
- failed: true,
- status: 'failed',
- summary_fields: {
- ...workflowApproval.summary_fields,
- approved_or_denied_by: {
- id: 1,
- username: 'admin',
- first_name: '',
- last_name: '',
- },
- },
- }}
- />
+
+
+ {}}
+ workflowApproval={{
+ ...workflowApproval,
+ failed: true,
+ status: 'failed',
+ summary_fields: {
+ ...workflowApproval.summary_fields,
+ approved_or_denied_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ },
+ }}
+ />
+
+
);
expect(wrapper.find('Label[children="Denied"]').length).toBe(1);
});
diff --git a/awx/ui_next/urls.py b/awx/ui_next/urls.py
index 6612cee0eb..58ec7ab251 100644
--- a/awx/ui_next/urls.py
+++ b/awx/ui_next/urls.py
@@ -1,6 +1,9 @@
from django.conf.urls import url
+from django.utils.translation import ugettext_lazy as _
from django.views.generic.base import TemplateView
+from awx.main.utils.common import get_licenser
+
class IndexView(TemplateView):
@@ -10,6 +13,16 @@ class IndexView(TemplateView):
class MigrationsNotran(TemplateView):
template_name = 'installing.html'
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ product_name = get_licenser().validate()['product_name']
+ context['title'] = _('%s Upgrading' % product_name)
+ context['image_alt'] = _('Logo')
+ context['aria_spinner'] = _('Loading')
+ context['message_upgrade'] = _('%s is currently upgrading.' % product_name)
+ context['message_refresh'] = _('This page will refresh when complete.')
+ return context
app_name = 'ui_next'
diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py
index 9b118afcec..94828c8b77 100644
--- a/awx_collection/plugins/modules/tower_job_launch.py
+++ b/awx_collection/plugins/modules/tower_job_launch.py
@@ -37,6 +37,12 @@ options:
description:
- Inventory to use for the job, only used if prompt for inventory is set.
type: str
+ organization:
+ description:
+ - Organization the job template exists in.
+ - Used to help lookup the object, cannot be modified using this module.
+ - If not provided, will lookup by name only, which does not work with duplicates.
+ type: str
credentials:
description:
- Credential to use for job, only used if prompt for credential is set.
@@ -149,6 +155,7 @@ def main():
name=dict(required=True, aliases=['job_template']),
job_type=dict(choices=['run', 'check']),
inventory=dict(default=None),
+ organization=dict(),
# Credentials will be a str instead of a list for backwards compatability
credentials=dict(type='list', default=None, aliases=['credential'], elements='str'),
limit=dict(),
@@ -172,6 +179,7 @@ def main():
name = module.params.get('name')
optional_args['job_type'] = module.params.get('job_type')
inventory = module.params.get('inventory')
+ organization = module.params.get('organization')
credentials = module.params.get('credentials')
optional_args['limit'] = module.params.get('limit')
optional_args['tags'] = module.params.get('tags')
@@ -201,7 +209,10 @@ def main():
post_data['credentials'].append(module.resolve_name_to_id('credentials', credential))
# Attempt to look up job_template based on the provided name
- job_template = module.get_one('job_templates', name_or_id=name)
+ lookup_data = {}
+ if organization:
+ lookup_data['organization'] = module.resolve_name_to_id('organizations', organization)
+ job_template = module.get_one('job_templates', name_or_id=name, data=lookup_data)
if job_template is None:
module.fail_json(msg="Unable to find job template by name {0}".format(name))
@@ -211,7 +222,6 @@ def main():
check_vars_to_prompts = {
'scm_branch': 'ask_scm_branch_on_launch',
'diff_mode': 'ask_diff_mode_on_launch',
- 'extra_vars': 'ask_variables_on_launch',
'limit': 'ask_limit_on_launch',
'tags': 'ask_tags_on_launch',
'skip_tags': 'ask_skip_tags_on_launch',
@@ -225,6 +235,9 @@ def main():
for variable_name in check_vars_to_prompts:
if module.params.get(variable_name) and not job_template[check_vars_to_prompts[variable_name]]:
param_errors.append("The field {0} was specified but the job template does not allow for it to be overridden".format(variable_name))
+ # Check if Either ask_variables_on_launch, or survey_enabled is enabled for use of extra vars.
+ if module.params.get('extra_vars') and not (job_template['ask_variables_on_launch'] or job_template['survey_enabled']):
+ param_errors.append("The field extra_vars was specified but the job template does not allow for it to be overridden")
if len(param_errors) > 0:
module.fail_json(msg="Parameters specified which can not be passed into job template, see errors for details", **{'errors': param_errors})
diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py
index ca848e7d69..015badc02b 100644
--- a/awx_collection/plugins/modules/tower_project.py
+++ b/awx_collection/plugins/modules/tower_project.py
@@ -91,6 +91,8 @@ options:
timeout:
description:
- The amount of time (in seconds) to run before the SCM Update is canceled. A value of 0 means no timeout.
+ - If waiting for the project to update this will abort after this
+ amount of seconds
default: 0
type: int
aliases:
@@ -133,6 +135,19 @@ options:
- list of notifications to send on error
type: list
elements: str
+ update_project:
+ description:
+ - Force project to update after changes.
+ - Used in conjunction with wait, interval, and timeout.
+ default: False
+ type: bool
+ interval:
+ description:
+ - The interval to request an update from Tower.
+ - Requires wait.
+ required: False
+ default: 1
+ type: float
extends_documentation_fragment: awx.awx.auth
'''
@@ -164,7 +179,13 @@ from ..module_utils.tower_api import TowerAPIModule
def wait_for_project_update(module, last_request):
- # The current running job for the udpate is in last_request['summary_fields']['current_update']['id']
+ # The current running job for the update is in last_request['summary_fields']['current_update']['id']
+
+ # Get parameters that were not passed in
+ update_project = module.params.get('update_project')
+ wait = module.params.get('wait')
+ timeout = module.params.get('timeout')
+ interval = module.params.get('interval')
if 'current_update' in last_request['summary_fields']:
running = True
@@ -177,6 +198,25 @@ def wait_for_project_update(module, last_request):
if result['status'] != 'successful':
module.fail_json(msg="Project update failed")
+ elif update_project:
+ result = module.post_endpoint(last_request['related']['update'])
+
+ if result['status_code'] != 202:
+ module.fail_json(msg="Failed to update project, see response for details", response=result)
+
+ if not wait:
+ module.exit_json(**module.json_output)
+
+ # Grab our start time to compare against for the timeout
+ start = time.time()
+
+ # Invoke wait function
+ module.wait_on_url(
+ url=result['json']['url'],
+ object_name=module.get_item_name(last_request),
+ object_type='Project Update',
+ timeout=timeout, interval=interval
+ )
module.exit_json(**module.json_output)
@@ -205,6 +245,8 @@ def main():
notification_templates_error=dict(type="list", elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
wait=dict(type='bool', default=True),
+ update_project=dict(default=False, type='bool'),
+ interval=dict(default=1.0, type='float'),
)
# Create a module for ourselves
@@ -231,6 +273,8 @@ def main():
organization = module.params.get('organization')
state = module.params.get('state')
wait = module.params.get('wait')
+ update_project = module.params.get('update_project')
+ interval = module.params.get('interval')
# Attempt to look up the related items the user specified (these will fail the module if not found)
lookup_data = {}
@@ -300,7 +344,7 @@ def main():
# If we are doing a not manual project, register our on_change method
# An on_change function, if registered, will fire after an post_endpoint or update_if_needed completes successfully
on_change = None
- if wait and scm_type != '':
+ if wait and scm_type != '' or update_project and scm_type != '':
on_change = wait_for_project_update
# If the state was present and we can let the module build or update the existing project, this will return on its own
diff --git a/awx_collection/plugins/modules/tower_project_update.py b/awx_collection/plugins/modules/tower_project_update.py
index c2ebf2e422..80493b94ff 100644
--- a/awx_collection/plugins/modules/tower_project_update.py
+++ b/awx_collection/plugins/modules/tower_project_update.py
@@ -126,9 +126,6 @@ def main():
# Grab our start time to compare against for the timeout
start = time.time()
- if not wait:
- module.exit_json(**module.json_output)
-
# Invoke wait function
module.wait_on_url(
url=result['json']['url'],
diff --git a/awx_collection/plugins/modules/tower_workflow_launch.py b/awx_collection/plugins/modules/tower_workflow_launch.py
index a1de4a46bc..aebf890d56 100644
--- a/awx_collection/plugins/modules/tower_workflow_launch.py
+++ b/awx_collection/plugins/modules/tower_workflow_launch.py
@@ -152,15 +152,17 @@ def main():
'inventory': 'ask_inventory_on_launch',
'limit': 'ask_limit_on_launch',
'scm_branch': 'ask_scm_branch_on_launch',
- 'extra_vars': 'ask_variables_on_launch',
}
param_errors = []
for variable_name in check_vars_to_prompts:
if variable_name in post_data and not workflow_job_template[check_vars_to_prompts[variable_name]]:
param_errors.append("The field {0} was specified but the workflow job template does not allow for it to be overridden".format(variable_name))
+ # Check if Either ask_variables_on_launch, or survey_enabled is enabled for use of extra vars.
+ if module.params.get('extra_vars') and not (workflow_job_template['ask_variables_on_launch'] or workflow_job_template['survey_enabled']):
+ param_errors.append("The field extra_vars was specified but the workflow job template does not allow for it to be overridden")
if len(param_errors) > 0:
- module.fail_json(msg="Parameters specified which can not be passed into wotkflow job template, see errors for details", errors=param_errors)
+ module.fail_json(msg="Parameters specified which can not be passed into workflow job template, see errors for details", errors=param_errors)
# Launch the job
result = module.post_endpoint(workflow_job_template['related']['launch'], data=post_data)
diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py
index 922cc785bc..467ee9357b 100644
--- a/awx_collection/test/awx/test_completeness.py
+++ b/awx_collection/test/awx/test_completeness.py
@@ -37,7 +37,7 @@ ignore_parameters = [
# Add the module name as the key with the value being the list of params to ignore
no_api_parameter_ok = {
# The wait is for whether or not to wait for a project update on change
- 'tower_project': ['wait'],
+ 'tower_project': ['wait', 'interval', 'update_project'],
# Existing_token and id are for working with an existing tokens
'tower_token': ['existing_token', 'existing_token_id'],
# /survey spec is now how we handle associations
diff --git a/awx_collection/test/awx/test_job_template.py b/awx_collection/test/awx/test_job_template.py
index 5d4100dac1..8ec3d67bfc 100644
--- a/awx_collection/test/awx/test_job_template.py
+++ b/awx_collection/test/awx/test_job_template.py
@@ -81,10 +81,11 @@ def test_resets_job_template_values(run_module, admin_user, project, inventory):
@pytest.mark.django_db
-def test_job_launch_with_prompting(run_module, admin_user, project, inventory, machine_credential):
+def test_job_launch_with_prompting(run_module, admin_user, project, organization, inventory, machine_credential):
JobTemplate.objects.create(
name='foo',
project=project,
+ organization=organization,
playbook='helloworld.yml',
ask_variables_on_launch=True,
ask_inventory_on_launch=True,
diff --git a/awx_collection/test/awx/test_notification_template.py b/awx_collection/test/awx/test_notification_template.py
index 96fbd5e56c..cb1ffdca6b 100644
--- a/awx_collection/test/awx/test_notification_template.py
+++ b/awx_collection/test/awx/test_notification_template.py
@@ -136,5 +136,5 @@ def test_build_notification_message_undefined(run_module, admin_user, organizati
), admin_user)
nt = NotificationTemplate.objects.get(id=result['id'])
- _, body = job.build_notification_message(nt, 'running')
- assert '{"started_by": "My Placeholder"}' in body
+ body = job.build_notification_message(nt, 'running')
+ assert '{"started_by": "My Placeholder"}' in body[1]
diff --git a/awx_collection/tests/integration/targets/tower_job_launch/tasks/main.yml b/awx_collection/tests/integration/targets/tower_job_launch/tasks/main.yml
index c74f6a8bd5..14d7f7c236 100644
--- a/awx_collection/tests/integration/targets/tower_job_launch/tasks/main.yml
+++ b/awx_collection/tests/integration/targets/tower_job_launch/tasks/main.yml
@@ -70,12 +70,84 @@
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
+- name: Create the job template with survey
+ tower_job_template:
+ name: "{{ jt_name2 }}"
+ project: "{{ proj_name }}"
+ playbook: debug.yml
+ job_type: run
+ state: present
+ inventory: "Demo Inventory"
+ survey_enabled: true
+ ask_variables_on_launch: false
+ survey_spec:
+ name: ''
+ description: ''
+ spec:
+ - question_name: Basic Name
+ question_description: Name
+ required: true
+ type: text
+ variable: basic_name
+ min: 0
+ max: 1024
+ default: ''
+ choices: ''
+ new_question: true
+ - question_name: Choose yes or no?
+ question_description: Choosing yes or no.
+ required: false
+ type: multiplechoice
+ variable: option_true_false
+ min:
+ max:
+ default: 'yes'
+ choices: |-
+ yes
+ no
+ new_question: true
+
+- name: Kick off a job template with survey
+ tower_job_launch:
+ job_template: "{{ jt_name2 }}"
+ extra_vars:
+ basic_name: My First Variable
+ option_true_false: 'no'
+ ignore_errors: true
+ register: result
+
+- assert:
+ that:
+ - result is not failed
+
+- name: Prompt the job templates extra_vars on launch
+ tower_job_template:
+ name: "{{ jt_name2 }}"
+ state: present
+ ask_variables_on_launch: true
+
+
+- name: Kick off a job template with extra_vars
+ tower_job_launch:
+ job_template: "{{ jt_name2 }}"
+ extra_vars:
+ basic_name: My First Variable
+ var1: My First Variable
+ var2: My Second Variable
+ ignore_errors: true
+ register: result
+
+- assert:
+ that:
+ - result is not failed
+
- name: Create a Job Template for testing extra_vars
tower_job_template:
name: "{{ jt_name2 }}"
project: "{{ proj_name }}"
playbook: debug.yml
job_type: run
+ survey_enabled: false
state: present
inventory: "Demo Inventory"
extra_vars:
@@ -85,6 +157,7 @@
- name: Launch job template with inventory and credential for prompt on launch
tower_job_launch:
job_template: "{{ jt_name2 }}"
+ organization: Default
register: result
- assert:
diff --git a/awx_collection/tests/integration/targets/tower_project/tasks/main.yml b/awx_collection/tests/integration/targets/tower_project/tasks/main.yml
index b46e1cab43..eb1c54e5e0 100644
--- a/awx_collection/tests/integration/targets/tower_project/tasks/main.yml
+++ b/awx_collection/tests/integration/targets/tower_project/tasks/main.yml
@@ -138,6 +138,29 @@
that:
- result is changed
+- name: Update a git project, update the project and wait.
+ tower_project:
+ name: "{{ project_name3 }}"
+ organization: Default
+ scm_type: git
+ scm_branch: empty_branch
+ scm_url: https://github.com/ansible/test-playbooks
+ allow_override: true
+ wait: true
+ update_project: true
+ register: result
+
+- name: Update a git project, update the project without waiting.
+ tower_project:
+ name: "{{ project_name3 }}"
+ organization: Default
+ scm_type: git
+ scm_branch: empty_branch
+ scm_url: https://github.com/ansible/test-playbooks
+ wait: false
+ update_project: true
+ register: result
+
- name: Create a job template that overrides the project scm_branch
tower_job_template:
name: "{{ jt1 }}"
diff --git a/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml
index 680b629473..5cd4e06d1e 100644
--- a/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml
+++ b/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml
@@ -66,6 +66,66 @@
- result is not failed
- "'id' in result['job_info']"
+- name: Kick off a workflow with extra_vars but not enabled
+ tower_workflow_launch:
+ workflow_template: "{{ wfjt_name1 }}"
+ extra_vars:
+ var1: My First Variable
+ var2: My Second Variable
+ ignore_errors: true
+ register: result
+
+- assert:
+ that:
+ - result is failed
+ - "'The field extra_vars was specified but the workflow job template does not allow for it to be overridden' in result.errors"
+
+- name: Prompt the workflow's with survey
+ tower_workflow_job_template:
+ name: "{{ wfjt_name1 }}"
+ state: present
+ survey_enabled: true
+ ask_variables_on_launch: false
+ survey:
+ name: ''
+ description: ''
+ spec:
+ - question_name: Basic Name
+ question_description: Name
+ required: true
+ type: text
+ variable: basic_name
+ min: 0
+ max: 1024
+ default: ''
+ choices: ''
+ new_question: true
+ - question_name: Choose yes or no?
+ question_description: Choosing yes or no.
+ required: false
+ type: multiplechoice
+ variable: option_true_false
+ min:
+ max:
+ default: 'yes'
+ choices: |-
+ yes
+ no
+ new_question: true
+
+- name: Kick off a workflow with survey
+ tower_workflow_launch:
+ workflow_template: "{{ wfjt_name1 }}"
+ extra_vars:
+ basic_name: My First Variable
+ option_true_false: 'no'
+ ignore_errors: true
+ register: result
+
+- assert:
+ that:
+ - result is not failed
+
- name: Prompt the workflow's extra_vars on launch
tower_workflow_job_template:
name: "{{ wfjt_name1 }}"
@@ -76,6 +136,7 @@
tower_workflow_launch:
workflow_template: "{{ wfjt_name1 }}"
extra_vars:
+ basic_name: My First Variable
var1: My First Variable
var2: My Second Variable
ignore_errors: true
diff --git a/docs/auth/README.md b/docs/auth/README.md
index 50578947aa..38e9b52bd1 100644
--- a/docs/auth/README.md
+++ b/docs/auth/README.md
@@ -5,6 +5,9 @@ When a user wants to log into Tower, she can explicitly choose some of the suppo
* Github OAuth2
* Github Organization OAuth2
* Github Team OAuth2
+* Github Enterprise OAuth2
+* Github Enterprise Organization OAuth2
+* Github Enterprise Team OAuth2
* Microsoft Azure Active Directory (AD) OAuth2
On the other hand, the other authentication methods use the same types of login info as Tower (username and password), but authenticate using external auth systems rather than Tower's own database. If some of these methods are enabled, Tower will try authenticating using the enabled methods *before Tower's own authentication method*. The order of precedence is:
diff --git a/docs/custom_virtualenvs.md b/docs/custom_virtualenvs.md
index 78bde90758..1f5b6e6a2d 100644
--- a/docs/custom_virtualenvs.md
+++ b/docs/custom_virtualenvs.md
@@ -21,6 +21,9 @@ using `/opt/my-envs/` as the directory to hold custom venvs. But you can use any
other directory and replace `/opt/my-envs/` with that. Let's create the directory
first if absent:
+ NOTE: For docker installations, this directory needs to exist on awx_web AND
+ awx_task container
+
$ sudo mkdir /opt/my-envs
Now, we need to tell Tower to look into this directory for custom venvs. For that,
diff --git a/docs/licenses/JSON-log-formatter.txt b/docs/licenses/JSON-log-formatter.txt
new file mode 100644
index 0000000000..01be757bb4
--- /dev/null
+++ b/docs/licenses/JSON-log-formatter.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Marsel Mavletkulov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/installer/roles/dockerfile/templates/Dockerfile.j2 b/installer/roles/dockerfile/templates/Dockerfile.j2
index 0364a5a591..edcfbefede 100644
--- a/installer/roles/dockerfile/templates/Dockerfile.j2
+++ b/installer/roles/dockerfile/templates/Dockerfile.j2
@@ -75,7 +75,6 @@ COPY . /tmp/src/
WORKDIR /tmp/src/
RUN make sdist && \
/var/lib/awx/venv/awx/bin/pip install dist/awx-$(cat VERSION).tar.gz
-RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage
{% endif %}
# Final container(s)
@@ -173,6 +172,8 @@ RUN dnf --enablerepo=debuginfo -y install python3-debuginfo || :
# Copy app from builder
COPY --from=builder /var/lib/awx /var/lib/awx
+RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage
+
{%if build_dev|bool %}
RUN openssl req -nodes -newkey rsa:2048 -keyout /etc/nginx/nginx.key -out /etc/nginx/nginx.csr \
-subj "/C=US/ST=North Carolina/L=Durham/O=Ansible/OU=AWX Development/CN=awx.localhost" && \
diff --git a/requirements/requirements.in b/requirements/requirements.in
index f13ccb21b3..75d1b7afd0 100644
--- a/requirements/requirements.in
+++ b/requirements/requirements.in
@@ -28,6 +28,7 @@ djangorestframework-yaml
GitPython>=3.1.1 # minimum to fix https://github.com/ansible/awx/issues/6119
irc
jinja2>=2.11.0 # required for ChainableUndefined
+JSON-log-formatter
jsonschema
Markdown # used for formatting API help
openshift>=0.11.0 # minimum version to pull in new pyyaml for CVE-2017-18342
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 1c968ea264..9668f2d0fb 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -61,6 +61,7 @@ jaraco.logging==3.0.0 # via irc
jaraco.stream==3.0.0 # via irc
jaraco.text==3.2.0 # via irc, jaraco.collections
jinja2==2.11.2 # via -r /awx_devel/requirements/requirements.in, openshift
+json-log-formatter==0.3.0 # via -r /awx_devel/requirements/requirements.in
jsonschema==3.2.0 # via -r /awx_devel/requirements/requirements.in
kubernetes==11.0.0 # via openshift
lockfile==0.12.2 # via python-daemon