diff --git a/awx/ui_next/src/components/DetailList/Detail.jsx b/awx/ui_next/src/components/DetailList/Detail.jsx
index ef13ac0aa6..9f8f3647b3 100644
--- a/awx/ui_next/src/components/DetailList/Detail.jsx
+++ b/awx/ui_next/src/components/DetailList/Detail.jsx
@@ -1,4 +1,4 @@
-import React, { Fragment } from 'react';
+import React from 'react';
import { node, bool } from 'prop-types';
import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
import styled from 'styled-components';
@@ -25,19 +25,28 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
`}
`;
-const Detail = ({ label, value, fullWidth }) => {
+const Detail = ({ label, value, fullWidth, className }) => {
if (!value && typeof value !== 'number') {
return null;
}
+
return (
-
-
+ <>
+
{label}
-
+
{value}
-
+ >
);
};
Detail.propTypes = {
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx
index 3c211eb265..239ea21a28 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx
@@ -9,13 +9,20 @@ import mockJobData from '../shared/data.job.json';
jest.mock('@api');
describe('', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = mountWithContexts();
+ });
+ afterEach(() => {
+ wrapper.unmount();
+ });
test('initially renders succesfully', () => {
- mountWithContexts();
+ wrapper = mountWithContexts();
+
+ expect(wrapper.length).toBe(1);
});
test('should display details', () => {
- const wrapper = mountWithContexts();
-
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
@@ -43,7 +50,6 @@ describe('', () => {
});
test('should display credentials', () => {
- const wrapper = mountWithContexts();
const credentialChip = wrapper.find('CredentialChip');
expect(credentialChip.prop('credential')).toEqual(
@@ -52,21 +58,18 @@ describe('', () => {
});
test('should display successful job status icon', () => {
- const wrapper = mountWithContexts();
const statusDetail = wrapper.find('Detail[label="Status"]');
expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
});
test('should display successful project status icon', () => {
- const wrapper = mountWithContexts();
const statusDetail = wrapper.find('Detail[label="Project"]');
expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
});
test('should properly delete job', async () => {
- const wrapper = mountWithContexts();
wrapper.find('button[aria-label="Delete"]').simulate('click');
await sleep(1);
wrapper.update();
@@ -89,8 +92,6 @@ describe('', () => {
},
})
);
- const wrapper = mountWithContexts();
-
wrapper.find('button[aria-label="Delete"]').simulate('click');
const modal = wrapper.find('Modal');
expect(modal.length).toBe(1);
@@ -102,4 +103,23 @@ describe('', () => {
const errorModal = wrapper.find('ErrorDetail');
expect(errorModal.length).toBe(1);
});
+
+ test('DELETED is shown for required Job resources that have been deleted', () => {
+ const newMockJobData = { ...mockJobData };
+ newMockJobData.summary_fields.inventory = null;
+ newMockJobData.summary_fields.project = null;
+ const newWrapper = mountWithContexts(
+
+ ).find('JobDetail');
+ async function assertMissingDetail(label) {
+ expect(newWrapper.length).toBe(1);
+ await sleep(0);
+ expect(newWrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
+ expect(newWrapper.find(`Detail[label="${label}"] dd`).text()).toBe(
+ 'DELETED'
+ );
+ }
+ assertMissingDetail('Project');
+ assertMissingDetail('Inventory');
+ });
});
diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
index eac6ea5905..0b48128e9a 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { Component, Fragment } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import {
@@ -28,6 +28,12 @@ const ButtonGroup = styled.div`
margin-left: 20px;
}
`;
+
+const MissingDetail = styled(Detail)`
+ dd& {
+ color: red;
+ }
+`;
class JobTemplateDetail extends Component {
constructor(props) {
super(props);
@@ -60,6 +66,7 @@ class JobTemplateDetail extends Component {
render() {
const {
template: {
+ ask_inventory_on_launch,
allow_simultaneous,
become_enabled,
created,
@@ -156,6 +163,28 @@ class JobTemplateDetail extends Component {
);
+ const renderMissingDataDetail = value => (
+
+ );
+
+ const inventoryValue = (kind, id) => {
+ const inventorykind =
+ kind === 'smart' ? (kind = 'smart_inventory') : (kind = 'inventory');
+
+ return ask_inventory_on_launch ? (
+
+
+ {summary_fields.inventory.name}
+
+ {i18n._(t`(Prompt on Launch)`)}
+
+ ) : (
+
+ {summary_fields.inventory.name}
+
+ );
+ };
+
if (contentError) {
return ;
}
@@ -171,31 +200,32 @@ class JobTemplateDetail extends Component {
- {summary_fields.inventory && (
+
+ {summary_fields.inventory ? (
- {summary_fields.inventory.name}
-
- }
+ value={inventoryValue(
+ summary_fields.inventory.kind,
+ summary_fields.inventory.id
+ )}
/>
+ ) : (
+ !ask_inventory_on_launch &&
+ renderMissingDataDetail(i18n._(t`Inventory`))
)}
- {summary_fields.project && (
+ {summary_fields.project ? (
- {summary_fields.project.name}
+ {summary_fields.project
+ ? summary_fields.project.name
+ : i18n._(t`Deleted`)}
}
/>
+ ) : (
+ renderMissingDataDetail(i18n._(t`Project`))
)}
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
index 1db7b084a6..8514cb0edc 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
@@ -9,6 +9,8 @@ import { JobTemplate } from '@types';
import { getAddedAndRemoved } from '@util/lists';
import JobTemplateForm from '../shared/JobTemplateForm';
+const loadRelatedProjectPlaybooks = async project =>
+ ProjectsAPI.readPlaybooks(project);
class JobTemplateEdit extends Component {
static propTypes = {
template: JobTemplate.isRequired,
@@ -33,9 +35,6 @@ class JobTemplateEdit extends Component {
this.handleCancel = this.handleCancel.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.loadRelatedCredentials = this.loadRelatedCredentials.bind(this);
- this.loadRelatedProjectPlaybooks = this.loadRelatedProjectPlaybooks.bind(
- this
- );
this.submitLabels = this.submitLabels.bind(this);
}
@@ -44,15 +43,20 @@ class JobTemplateEdit extends Component {
}
async loadRelated() {
+ const {
+ template: { project },
+ } = this.props;
this.setState({ contentError: null, hasContentLoading: true });
try {
- const [relatedCredentials, relatedProjectPlaybooks] = await Promise.all([
- this.loadRelatedCredentials(),
- this.loadRelatedProjectPlaybooks(),
- ]);
+ if (project) {
+ const { data: playbook = [] } = await loadRelatedProjectPlaybooks(
+ project
+ );
+ this.setState({ relatedProjectPlaybooks: playbook });
+ }
+ const [relatedCredentials] = await this.loadRelatedCredentials();
this.setState({
relatedCredentials,
- relatedProjectPlaybooks,
});
} catch (contentError) {
this.setState({ contentError });
@@ -89,19 +93,6 @@ class JobTemplateEdit extends Component {
}
}
- async loadRelatedProjectPlaybooks() {
- const {
- template: { project },
- } = this.props;
- try {
- const { data: playbooks = [] } = await ProjectsAPI.readPlaybooks(project);
- this.setState({ relatedProjectPlaybooks: playbooks });
- return playbooks;
- } catch (err) {
- throw err;
- }
- }
-
async handleSubmit(values) {
const { template, history } = this.props;
const {
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
index dc5c22f4ee..eb9a049e41 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
@@ -44,6 +44,10 @@ const mockJobTemplate = {
{ id: 1, kind: 'cloud', name: 'Foo' },
{ id: 2, kind: 'ssh', name: 'Bar' },
],
+ project: {
+ id: 15,
+ name: 'Boo',
+ },
},
};
@@ -237,4 +241,50 @@ describe('', () => {
'/templates/job_template/1/details'
);
});
+ test('should not call ProjectsAPI.readPlaybooks if there is no project', async () => {
+ const history = createMemoryHistory({});
+ const noProjectTemplate = {
+ id: 1,
+ name: 'Foo',
+ description: 'Bar',
+ job_type: 'run',
+ inventory: 2,
+ playbook: 'Baz',
+ type: 'job_template',
+ forks: 0,
+ limit: '',
+ verbosity: '0',
+ job_slice_count: 1,
+ timeout: 0,
+ job_tags: '',
+ skip_tags: '',
+ diff_mode: false,
+ allow_callbacks: false,
+ allow_simultaneous: false,
+ use_fact_cache: false,
+ host_config_key: '',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ },
+ labels: {
+ results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
+ },
+ inventory: {
+ id: 2,
+ organization_id: 1,
+ },
+ credentials: [
+ { id: 1, kind: 'cloud', name: 'Foo' },
+ { id: 2, kind: 'ssh', name: 'Bar' },
+ ],
+ },
+ };
+ await act(async () =>
+ mountWithContexts(, {
+ context: { router: { history } },
+ })
+ );
+ expect(ProjectsAPI.readPlaybooks).not.toBeCalled();
+ });
});
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
index ac6e2807cd..09f000dd04 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
@@ -8,7 +8,11 @@ import {
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
-import { PencilAltIcon, RocketIcon } from '@patternfly/react-icons';
+import {
+ ExclamationTriangleIcon,
+ PencilAltIcon,
+ RocketIcon,
+} from '@patternfly/react-icons';
import ActionButtonCell from '@components/ActionButtonCell';
import DataListCell from '@components/DataListCell';
@@ -58,7 +62,10 @@ class TemplateListItem extends Component {
render() {
const { i18n, template, isSelected, onSelect } = this.props;
const canLaunch = template.summary_fields.user_capabilities.start;
-
+ const missingResourceIcon =
+ (!template.summary_fields.inventory &&
+ !template.ask_inventory_on_launch) ||
+ !template.summary_fields.project;
return (
{template.name}
+ {missingResourceIcon && (
+
+
+
+ )}
,
', () => {
);
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);
+ });
});
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
index 6eff4dcbe1..846d7f4fb8 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
@@ -572,7 +572,9 @@ const FormikApp = withFormik({
inventory: { organization: null },
},
} = template;
-
+ const hasInventory = summary_fields.inventory
+ ? summary_fields.inventory.organization_id
+ : null;
return {
name: template.name || '',
description: template.description || '',
@@ -594,7 +596,7 @@ const FormikApp = withFormik({
allow_simultaneous: template.allow_simultaneous || false,
use_fact_cache: template.use_fact_cache || false,
host_config_key: template.host_config_key || '',
- organizationId: summary_fields.inventory.organization_id || null,
+ organizationId: hasInventory,
initialInstanceGroups: [],
instanceGroups: [],
credentials: summary_fields.credentials || [],