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 || [],