Merge pull request #5204 from AlexSCorey/5106-MissingOrDeletedFields

Adds `Deleted` text to missing resources in JT Details View

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-11-07 15:41:35 +00:00
committed by GitHub
8 changed files with 235 additions and 55 deletions

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'; import React from 'react';
import { node, bool } from 'prop-types'; import { node, bool } from 'prop-types';
import { TextListItem, TextListItemVariants } from '@patternfly/react-core'; import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
import styled from 'styled-components'; 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') { if (!value && typeof value !== 'number') {
return null; return null;
} }
return ( return (
<Fragment> <>
<DetailName component={TextListItemVariants.dt} fullWidth={fullWidth}> <DetailName
className={className}
component={TextListItemVariants.dt}
fullWidth={fullWidth}
>
{label} {label}
</DetailName> </DetailName>
<DetailValue component={TextListItemVariants.dd} fullWidth={fullWidth}> <DetailValue
className={className}
component={TextListItemVariants.dd}
fullWidth={fullWidth}
>
{value} {value}
</DetailValue> </DetailValue>
</Fragment> </>
); );
}; };
Detail.propTypes = { Detail.propTypes = {

View File

@@ -9,13 +9,20 @@ import mockJobData from '../shared/data.job.json';
jest.mock('@api'); jest.mock('@api');
describe('<JobDetail />', () => { describe('<JobDetail />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<JobDetail job={mockJobData} />); wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
expect(wrapper.length).toBe(1);
}); });
test('should display details', () => { test('should display details', () => {
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
function assertDetail(label, value) { function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
@@ -43,7 +50,6 @@ describe('<JobDetail />', () => {
}); });
test('should display credentials', () => { test('should display credentials', () => {
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
const credentialChip = wrapper.find('CredentialChip'); const credentialChip = wrapper.find('CredentialChip');
expect(credentialChip.prop('credential')).toEqual( expect(credentialChip.prop('credential')).toEqual(
@@ -52,21 +58,18 @@ describe('<JobDetail />', () => {
}); });
test('should display successful job status icon', () => { test('should display successful job status icon', () => {
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
const statusDetail = wrapper.find('Detail[label="Status"]'); const statusDetail = wrapper.find('Detail[label="Status"]');
expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1); expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1); expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
}); });
test('should display successful project status icon', () => { test('should display successful project status icon', () => {
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
const statusDetail = wrapper.find('Detail[label="Project"]'); const statusDetail = wrapper.find('Detail[label="Project"]');
expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1); expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1); expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
}); });
test('should properly delete job', async () => { test('should properly delete job', async () => {
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
wrapper.find('button[aria-label="Delete"]').simulate('click'); wrapper.find('button[aria-label="Delete"]').simulate('click');
await sleep(1); await sleep(1);
wrapper.update(); wrapper.update();
@@ -89,8 +92,6 @@ describe('<JobDetail />', () => {
}, },
}) })
); );
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
wrapper.find('button[aria-label="Delete"]').simulate('click'); wrapper.find('button[aria-label="Delete"]').simulate('click');
const modal = wrapper.find('Modal'); const modal = wrapper.find('Modal');
expect(modal.length).toBe(1); expect(modal.length).toBe(1);
@@ -102,4 +103,23 @@ describe('<JobDetail />', () => {
const errorModal = wrapper.find('ErrorDetail'); const errorModal = wrapper.find('ErrorDetail');
expect(errorModal.length).toBe(1); 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(
<JobDetail job={newMockJobData} />
).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');
});
}); });

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component, Fragment } from 'react';
import { Link, withRouter } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { import {
@@ -28,6 +28,12 @@ const ButtonGroup = styled.div`
margin-left: 20px; margin-left: 20px;
} }
`; `;
const MissingDetail = styled(Detail)`
dd& {
color: red;
}
`;
class JobTemplateDetail extends Component { class JobTemplateDetail extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -60,6 +66,7 @@ class JobTemplateDetail extends Component {
render() { render() {
const { const {
template: { template: {
ask_inventory_on_launch,
allow_simultaneous, allow_simultaneous,
become_enabled, become_enabled,
created, created,
@@ -156,6 +163,28 @@ class JobTemplateDetail extends Component {
</TextList> </TextList>
); );
const renderMissingDataDetail = value => (
<MissingDetail label={value} value={i18n._(t`Deleted`)} />
);
const inventoryValue = (kind, id) => {
const inventorykind =
kind === 'smart' ? (kind = 'smart_inventory') : (kind = 'inventory');
return ask_inventory_on_launch ? (
<Fragment>
<Link to={`/inventories/${inventorykind}/${id}/details`}>
{summary_fields.inventory.name}
</Link>
<span> {i18n._(t`(Prompt on Launch)`)}</span>
</Fragment>
) : (
<Link to={`/inventories/${inventorykind}/${id}/details`}>
{summary_fields.inventory.name}
</Link>
);
};
if (contentError) { if (contentError) {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
} }
@@ -171,31 +200,32 @@ class JobTemplateDetail extends Component {
<Detail label={i18n._(t`Name`)} value={name} /> <Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} /> <Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Job Type`)} value={job_type} /> <Detail label={i18n._(t`Job Type`)} value={job_type} />
{summary_fields.inventory && (
{summary_fields.inventory ? (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}
value={ value={inventoryValue(
<Link summary_fields.inventory.kind,
to={`/inventories/${ summary_fields.inventory.id
summary_fields.inventory.kind === 'smart' )}
? 'smart_inventory'
: 'inventory'
}/${summary_fields.inventory.id}/details`}
>
{summary_fields.inventory.name}
</Link>
}
/> />
) : (
!ask_inventory_on_launch &&
renderMissingDataDetail(i18n._(t`Inventory`))
)} )}
{summary_fields.project && ( {summary_fields.project ? (
<Detail <Detail
label={i18n._(t`Project`)} label={i18n._(t`Project`)}
value={ value={
<Link to={`/projects/${summary_fields.project.id}/details`}> <Link to={`/projects/${summary_fields.project.id}/details`}>
{summary_fields.project.name} {summary_fields.project
? summary_fields.project.name
: i18n._(t`Deleted`)}
</Link> </Link>
} }
/> />
) : (
renderMissingDataDetail(i18n._(t`Project`))
)} )}
<Detail label={i18n._(t`Playbook`)} value={playbook} /> <Detail label={i18n._(t`Playbook`)} value={playbook} />
<Detail label={i18n._(t`Forks`)} value={forks || '0'} /> <Detail label={i18n._(t`Forks`)} value={forks || '0'} />

View File

@@ -9,6 +9,8 @@ import { JobTemplate } from '@types';
import { getAddedAndRemoved } from '@util/lists'; import { getAddedAndRemoved } from '@util/lists';
import JobTemplateForm from '../shared/JobTemplateForm'; import JobTemplateForm from '../shared/JobTemplateForm';
const loadRelatedProjectPlaybooks = async project =>
ProjectsAPI.readPlaybooks(project);
class JobTemplateEdit extends Component { class JobTemplateEdit extends Component {
static propTypes = { static propTypes = {
template: JobTemplate.isRequired, template: JobTemplate.isRequired,
@@ -33,9 +35,6 @@ class JobTemplateEdit extends Component {
this.handleCancel = this.handleCancel.bind(this); this.handleCancel = this.handleCancel.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.loadRelatedCredentials = this.loadRelatedCredentials.bind(this); this.loadRelatedCredentials = this.loadRelatedCredentials.bind(this);
this.loadRelatedProjectPlaybooks = this.loadRelatedProjectPlaybooks.bind(
this
);
this.submitLabels = this.submitLabels.bind(this); this.submitLabels = this.submitLabels.bind(this);
} }
@@ -44,15 +43,20 @@ class JobTemplateEdit extends Component {
} }
async loadRelated() { async loadRelated() {
const {
template: { project },
} = this.props;
this.setState({ contentError: null, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const [relatedCredentials, relatedProjectPlaybooks] = await Promise.all([ if (project) {
this.loadRelatedCredentials(), const { data: playbook = [] } = await loadRelatedProjectPlaybooks(
this.loadRelatedProjectPlaybooks(), project
]); );
this.setState({ relatedProjectPlaybooks: playbook });
}
const [relatedCredentials] = await this.loadRelatedCredentials();
this.setState({ this.setState({
relatedCredentials, relatedCredentials,
relatedProjectPlaybooks,
}); });
} catch (contentError) { } catch (contentError) {
this.setState({ 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) { async handleSubmit(values) {
const { template, history } = this.props; const { template, history } = this.props;
const { const {

View File

@@ -44,6 +44,10 @@ const mockJobTemplate = {
{ id: 1, kind: 'cloud', name: 'Foo' }, { id: 1, kind: 'cloud', name: 'Foo' },
{ id: 2, kind: 'ssh', name: 'Bar' }, { id: 2, kind: 'ssh', name: 'Bar' },
], ],
project: {
id: 15,
name: 'Boo',
},
}, },
}; };
@@ -237,4 +241,50 @@ describe('<JobTemplateEdit />', () => {
'/templates/job_template/1/details' '/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(<JobTemplateEdit template={noProjectTemplate} />, {
context: { router: { history } },
})
);
expect(ProjectsAPI.readPlaybooks).not.toBeCalled();
});
}); });

View File

@@ -8,7 +8,11 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react'; 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 ActionButtonCell from '@components/ActionButtonCell';
import DataListCell from '@components/DataListCell'; import DataListCell from '@components/DataListCell';
@@ -58,7 +62,10 @@ class TemplateListItem extends Component {
render() { render() {
const { i18n, template, isSelected, onSelect } = this.props; const { i18n, template, isSelected, onSelect } = this.props;
const canLaunch = template.summary_fields.user_capabilities.start; const canLaunch = template.summary_fields.user_capabilities.start;
const missingResourceIcon =
(!template.summary_fields.inventory &&
!template.ask_inventory_on_launch) ||
!template.summary_fields.project;
return ( return (
<DataListItem <DataListItem
aria-labelledby={`check-action-${template.id}`} aria-labelledby={`check-action-${template.id}`}
@@ -80,6 +87,16 @@ class TemplateListItem extends Component {
<b>{template.name}</b> <b>{template.name}</b>
</Link> </Link>
</span> </span>
{missingResourceIcon && (
<Tooltip
content={i18n._(
t`Resources are missing from this template.`
)}
position="right"
>
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
</Tooltip>
)}
</LeftDataListCell>, </LeftDataListCell>,
<RightDataListCell <RightDataListCell
css="padding-left: 40px;" css="padding-left: 40px;"

View File

@@ -81,4 +81,65 @@ describe('<TemplatesListItem />', () => {
); );
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
}); });
test('missing resource icon is shown.', () => {
const wrapper = mountWithContexts(
<TemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
);
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(
<TemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
inventory: { name: 'Bar', id: 2 },
},
}}
/>
);
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(
<TemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
ask_inventory_on_launch: true,
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
},
}}
/>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
}); });

View File

@@ -572,7 +572,9 @@ const FormikApp = withFormik({
inventory: { organization: null }, inventory: { organization: null },
}, },
} = template; } = template;
const hasInventory = summary_fields.inventory
? summary_fields.inventory.organization_id
: null;
return { return {
name: template.name || '', name: template.name || '',
description: template.description || '', description: template.description || '',
@@ -594,7 +596,7 @@ const FormikApp = withFormik({
allow_simultaneous: template.allow_simultaneous || false, allow_simultaneous: template.allow_simultaneous || false,
use_fact_cache: template.use_fact_cache || false, use_fact_cache: template.use_fact_cache || false,
host_config_key: template.host_config_key || '', host_config_key: template.host_config_key || '',
organizationId: summary_fields.inventory.organization_id || null, organizationId: hasInventory,
initialInstanceGroups: [], initialInstanceGroups: [],
instanceGroups: [], instanceGroups: [],
credentials: summary_fields.credentials || [], credentials: summary_fields.credentials || [],