diff --git a/src/api/models/JobTemplates.js b/src/api/models/JobTemplates.js
index 3158d48c41..dd1e5269fa 100644
--- a/src/api/models/JobTemplates.js
+++ b/src/api/models/JobTemplates.js
@@ -1,6 +1,7 @@
import Base from '../Base';
+import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
-class JobTemplates extends Base {
+class JobTemplates extends InstanceGroupsMixin(Base) {
constructor (http) {
super(http);
this.baseUrl = '/api/v2/job_templates/';
diff --git a/src/components/DetailList/Detail.jsx b/src/components/DetailList/Detail.jsx
index 1c6e4b4579..9f09fd4ed2 100644
--- a/src/components/DetailList/Detail.jsx
+++ b/src/components/DetailList/Detail.jsx
@@ -7,7 +7,6 @@ const DetailName = styled(({ fullWidth, ...props }) => (
))`
font-weight: var(--pf-global--FontWeight--bold);
- text-align: right;
${props => props.fullWidth && `
grid-column: 1;
`}
diff --git a/src/screens/Organization/Organization.jsx b/src/screens/Organization/Organization.jsx
index 14d8a44c8f..920e66126d 100644
--- a/src/screens/Organization/Organization.jsx
+++ b/src/screens/Organization/Organization.jsx
@@ -4,17 +4,15 @@ import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect } from 'react-router-dom';
import { Card, CardHeader as PFCardHeader, PageSection } from '@patternfly/react-core';
import styled from 'styled-components';
-
-import { OrganizationsAPI } from '@api';
import CardCloseButton from '@components/CardCloseButton';
-import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
-
+import ContentError from '@components/ContentError';
import { OrganizationAccess } from './OrganizationAccess';
import OrganizationDetail from './OrganizationDetail';
import OrganizationEdit from './OrganizationEdit';
import OrganizationNotifications from './OrganizationNotifications';
import OrganizationTeams from './OrganizationTeams';
+import { OrganizationsAPI } from '@api';
class Organization extends Component {
constructor (props) {
@@ -141,10 +139,11 @@ class Organization extends Component {
`;
let cardHeader = (
-
+
diff --git a/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap b/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap
index 996cbf29fe..5d523bfb30 100644
--- a/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap
+++ b/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap
@@ -426,9 +426,9 @@ exports[` initially renders succesfully 1`] = `
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailName-sc-16ypsyv-0",
"isStatic": false,
- "lastClassName": "gRioSK",
+ "lastClassName": "erdIBg",
"rules": Array [
- "font-weight:var(--pf-global--FontWeight--bold);text-align:right;",
+ "font-weight:var(--pf-global--FontWeight--bold);",
[Function],
],
},
@@ -446,16 +446,16 @@ exports[` initially renders succesfully 1`] = `
fullWidth={false}
>
Name
@@ -603,9 +603,9 @@ exports[` initially renders succesfully 1`] = `
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailName-sc-16ypsyv-0",
"isStatic": false,
- "lastClassName": "gRioSK",
+ "lastClassName": "erdIBg",
"rules": Array [
- "font-weight:var(--pf-global--FontWeight--bold);text-align:right;",
+ "font-weight:var(--pf-global--FontWeight--bold);",
[Function],
],
},
@@ -623,16 +623,16 @@ exports[` initially renders succesfully 1`] = `
fullWidth={false}
>
Team Roles
diff --git a/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
new file mode 100644
index 0000000000..b7e1764f18
--- /dev/null
+++ b/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
@@ -0,0 +1,340 @@
+import React, { Component } from 'react';
+import { Link, withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { CardBody, Button, TextList, TextListItem, TextListItemVariants, TextListVariants } from '@patternfly/react-core';
+import styled from 'styled-components';
+import { t } from '@lingui/macro';
+
+import ContentError from '../../../components/ContentError';
+import ContentLoading from '../../../components/ContentLoading';
+import { ChipGroup, Chip } from '../../../components/Chip';
+import { DetailList, Detail } from '../../../components/DetailList';
+import { JobTemplatesAPI } from '../../../api';
+import { toTitleCase } from '../../../util/strings';
+
+const ButtonGroup = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+ & > :not(:first-child){
+ margin-left: 20px;
+ }
+`;
+class JobTemplateDetail extends Component {
+ constructor (props) {
+ super(props);
+ this.state = {
+ contentError: false,
+ hasContentLoading: true,
+ instanceGroups: []
+ };
+ this.readInstanceGroups = this.readInstanceGroups.bind(this);
+ }
+
+ componentDidMount () {
+ this.readInstanceGroups();
+ }
+
+ async readInstanceGroups () {
+ const { match } = this.props;
+ try {
+ const { data } = await JobTemplatesAPI.readInstanceGroups(match.params.id);
+ this.setState({ instanceGroups: [...data.results] });
+ } catch {
+ this.setState({ contentError: true });
+ } finally {
+ this.setState({ hasContentLoading: false });
+ }
+ }
+
+ render () {
+ const {
+ template: {
+ allow_simultaneous,
+ become_enabled,
+ created,
+ description,
+ diff_mode,
+ forks,
+ host_config_key,
+ job_slice_count,
+ job_tags,
+ job_type,
+ inventory,
+ name,
+ limit,
+ modified,
+ playbook,
+ project,
+ skip_tags,
+ timeout,
+ summary_fields,
+ use_fact_cache,
+ url,
+ verbosity
+ },
+ hasTemplateLoading,
+ i18n,
+ } = this.props;
+ const { instanceGroups, hasContentLoading, contentError } = this.state;
+ const verbosityOptions = [
+ { verbosity: 0, details: i18n._(t`0 (Normal)`) },
+ { verbosity: 1, details: i18n._(t`1 (Verbose)`) },
+ { verbosity: 2, details: i18n._(t`2 (More Verbose)`) },
+ { verbosity: 3, details: i18n._(t`3 (Debug)`) },
+ { verbosity: 4, details: i18n._(t`4 (Connection Debug)`) },
+ { verbosity: 5, details: i18n._(t`5 (WinRM Debug)`) },
+ ];
+ const verbosityDetails = verbosityOptions.filter(
+ option => option.verbosity === verbosity
+ );
+ const generateCallBackUrl = `${window.location.origin + url}callback/`;
+ const isInitialized = !hasTemplateLoading && !hasContentLoading;
+
+ const credentialType = (c) => (c === 'aws' || c === 'ssh' ? c.toUpperCase() : toTitleCase(c));
+
+ const renderOptionsField = become_enabled || host_config_key || allow_simultaneous
+ || use_fact_cache;
+
+ const renderOptions = (
+
+ {become_enabled && (
+
+ {i18n._(t`Enable Privilege Escalation`)}
+
+ )}
+ {host_config_key && (
+
+ {i18n._(t`Allow Provisioning Callbacks`)}
+
+ )}
+ {allow_simultaneous && (
+
+ {i18n._(t`Enable Concurrent Jobs`)}
+
+ )}
+ {use_fact_cache && (
+
+ {i18n._(t`Use Fact Cache`)}
+
+ )}
+
+ );
+
+ if (contentError) {
+ return ();
+ }
+
+ if (hasContentLoading) {
+ return ();
+ }
+
+ return (
+ isInitialized && (
+
+
+
+
+
+ {inventory && (
+
+ )}
+ {project && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {host_config_key && (
+
+
+
+
+ )}
+ {renderOptionsField && (
+
+ )}
+ {(summary_fields.credentials && summary_fields.credentials.length > 0) && (
+
+ {summary_fields.credentials.map(c => (
+
+
+ {c.kind ? credentialType(c.kind) : i18n._(t`Cloud`)}
+:
+
+ {` ${c.name}`}
+
+ ))
+ }
+
+ )}
+ />
+ )}
+ {(summary_fields.labels && summary_fields.labels.results.length > 0) && (
+
+ {summary_fields.labels.results.map(l => (
+
+ {l.name}
+
+ ))
+ }
+
+ )}
+ />
+ )}
+ {(instanceGroups.length > 0) && (
+
+ {instanceGroups.map(ig => (
+
+ {ig.name}
+
+ ))}
+
+ )}
+ />
+ )}
+ {(job_tags && job_tags.length > 0) && (
+
+ {job_tags.split(',').map(jobTag => (
+
+ {jobTag}
+
+ ))
+ }
+
+ )}
+ />
+ )}
+ {(skip_tags && skip_tags.length > 0) && (
+
+ {skip_tags.split(',').map(skipTag => (
+
+ {skipTag}
+
+ ))
+ }
+
+ )}
+ />
+ )}
+
+
+ {summary_fields.user_capabilities.edit && (
+
+ )}
+
+
+
+
+ )
+ );
+ }
+}
+export { JobTemplateDetail as _JobTemplateDetail };
+export default withI18n()(withRouter(JobTemplateDetail));
diff --git a/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx b/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
new file mode 100644
index 0000000000..59f8f9e5a2
--- /dev/null
+++ b/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import { mountWithContexts, waitForElement } from '../../../../testUtils/enzymeHelpers';
+import JobTemplateDetail, { _JobTemplateDetail } from './JobTemplateDetail';
+
+describe('', () => {
+ const template = {
+ forks: 1,
+ host_config_key: 'ssh',
+ name: 'Temp 1',
+ job_type: 'run',
+ inventory: 1,
+ limit: '1',
+ project: 7,
+ playbook: '',
+ id: 1,
+ verbosity: 1,
+ summary_fields: {
+ user_capabilities: { edit: true },
+ created_by: { username: 'Joe' },
+ modified_by: { username: 'Joe' },
+ credentials: [
+ { id: 1, kind: 'ssh', name: 'Credential 1' },
+ { id: 2, kind: 'awx', name: 'Credential 2' }
+ ],
+ inventory: { name: 'Inventory' },
+ project: { name: 'Project' }
+ }
+ };
+
+ const mockInstanceGroups = {
+ count: 5,
+ data: {
+ results: [
+ { id: 1, name: 'IG1' },
+ { id: 2, name: 'IG2' }]
+ }
+ };
+
+ const readInstanceGroups = jest.spyOn(_JobTemplateDetail.prototype, 'readInstanceGroups');
+
+ test('initially renders succesfully', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+ test('When component mounts API is called to get instance groups', async (done) => {
+ const wrapper = mountWithContexts(
+
+ );
+ await waitForElement(wrapper, 'JobTemplateDetail', (el) => el.state('hasContentLoading') === true);
+ expect(readInstanceGroups).toHaveBeenCalled();
+ await waitForElement(wrapper, 'JobTemplateDetail', (el) => el.state('hasContentLoading') === false);
+ done();
+ });
+ test('Edit button is absent when user does not have edit privilege', async (done) => {
+ const regularUser = {
+ forks: 1,
+ host_config_key: 'ssh',
+ name: 'Temp 1',
+ job_tags: 'cookies,pizza',
+ job_type: 'run',
+ inventory: 1,
+ limit: '1',
+ project: 7,
+ playbook: '',
+ id: 1,
+ verbosity: 0,
+ created_by: 'Alex',
+ skip_tags: 'coffe,tea',
+ summary_fields: {
+ user_capabilities: { edit: false },
+ created_by: { username: 'Joe' },
+ modified_by: { username: 'Joe' },
+ inventory: { name: 'Inventory' },
+ project: { name: 'Project' },
+ labels: { count: 1, results: [{ name: 'Label', id: 1 }] }
+ }
+ };
+ const wrapper = mountWithContexts(
+
+ );
+ const jobTemplateDetail = wrapper.find('JobTemplateDetail');
+ const editButton = jobTemplateDetail.find('button[aria-label="Edit"]');
+
+ jobTemplateDetail.setState({
+ instanceGroups: mockInstanceGroups, hasContentLoading: false, contentError: false
+ });
+ expect(editButton.length).toBe(0);
+ done();
+ });
+
+ test('Credential type is Cloud if credential.kind is null', async (done) => {
+ template.summary_fields.credentials = [{ id: 1, name: 'cred', kind: null, }];
+ const wrapper = mountWithContexts(
+
+ );
+ const jobTemplateDetail = wrapper.find('JobTemplateDetail');
+ jobTemplateDetail.setState({
+ instanceGroups: mockInstanceGroups.data.results, hasContentLoading: false, contentError: false
+ });
+ const cred = wrapper.find('strong.credential');
+ expect(cred.text()).toContain('Cloud:');
+ done();
+ });
+});
diff --git a/src/screens/Template/JobTemplateDetail/__snapshots__/JobTemplateDetail.test.jsx.snap b/src/screens/Template/JobTemplateDetail/__snapshots__/JobTemplateDetail.test.jsx.snap
new file mode 100644
index 0000000000..2bc21e093c
--- /dev/null
+++ b/src/screens/Template/JobTemplateDetail/__snapshots__/JobTemplateDetail.test.jsx.snap
@@ -0,0 +1,199 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` initially renders succesfully 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/screens/Template/JobTemplateDetail/index.js b/src/screens/Template/JobTemplateDetail/index.js
new file mode 100644
index 0000000000..fedf6e28d5
--- /dev/null
+++ b/src/screens/Template/JobTemplateDetail/index.js
@@ -0,0 +1,4 @@
+import JobTemplateDetail from './JobTemplateDetail';
+
+export { JobTemplateDetail as _JobTemplateDetail };
+export default JobTemplateDetail;
diff --git a/src/screens/Template/Template.jsx b/src/screens/Template/Template.jsx
index 432ad89b35..5e7717bf5d 100644
--- a/src/screens/Template/Template.jsx
+++ b/src/screens/Template/Template.jsx
@@ -7,6 +7,7 @@ import { Switch, Route, Redirect, withRouter } from 'react-router-dom';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
+import JobTemplateDetail from './JobTemplateDetail';
import { JobTemplatesAPI } from '@api';
class Template extends Component {
@@ -85,7 +86,11 @@ class Template extends Component {
(
- Coming soon!
+
)}
/>
)}
diff --git a/src/screens/Template/Template.test.jsx b/src/screens/Template/Template.test.jsx
new file mode 100644
index 0000000000..c0b7574433
--- /dev/null
+++ b/src/screens/Template/Template.test.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import Template, { _Template } from './Template';
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts();
+ });
+ test('When component mounts API is called and the response is put in state', async (done) => {
+ const readTemplate = jest.spyOn(_Template.prototype, 'readTemplate');
+ const wrapper = mountWithContexts();
+ await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
+ expect(readTemplate).toHaveBeenCalled();
+ await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
+ done();
+ });
+});
diff --git a/src/util/strings.js b/src/util/strings.js
index 6d6f9a05e1..12ddb7503b 100644
--- a/src/util/strings.js
+++ b/src/util/strings.js
@@ -15,7 +15,7 @@ export function ucFirst (str) {
return `${str[0].toUpperCase()}${str.substr(1)}`;
}
-export const toTitleCase = (type) => type
+export const toTitleCase = (string) => string
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))