Merge pull request #4816 from marshmalien/add-missing-job-detail-fields

Add missing job detail fields

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-09-26 19:40:10 +00:00
committed by GitHub
8 changed files with 144 additions and 85 deletions

View File

@@ -11,7 +11,7 @@ const DetailList = ({ children, stacked, ...props }) => (
export default styled(DetailList)` export default styled(DetailList)`
display: grid; display: grid;
grid-gap: 20px; grid-gap: 20px;
align-items: baseline; align-items: flex-start;
${props => ${props =>
props.stacked props.stacked
? ` ? `

View File

@@ -75,11 +75,12 @@ const SkippedBottom = styled.div`
const StatusIcon = ({ status, ...props }) => { const StatusIcon = ({ status, ...props }) => {
return ( return (
<div {...props}> <div className="at-c-statusIcon" {...props}>
{status === 'running' && <RunningJob />} {status === 'running' && <RunningJob />}
{(status === 'new' || status === 'pending' || status === 'waiting') && ( {(status === 'new' ||
<WaitingJob /> status === 'pending' ||
)} status === 'waiting' ||
status === 'never updated') && <WaitingJob />}
{(status === 'failed' || status === 'error' || status === 'canceled') && ( {(status === 'failed' || status === 'error' || status === 'canceled') && (
<FinishedJob> <FinishedJob>
<FailedTop /> <FailedTop />

View File

@@ -10,6 +10,7 @@ import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip'; import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput'; import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
import ErrorDetail from '@components/ErrorDetail'; import ErrorDetail from '@components/ErrorDetail';
import { StatusIcon } from '@components/Sparkline';
import { toTitleCase } from '@util/strings'; import { toTitleCase } from '@util/strings';
import { Job } from '../../../types'; import { Job } from '../../../types';
import { import {
@@ -37,6 +38,14 @@ const VariablesInput = styled(_VariablesInput)`
} }
`; `;
const StatusDetailValue = styled.div`
align-items: center;
display: inline-flex;
.at-c-statusIcon {
margin-right: 10px;
}
`;
const VERBOSITY = { const VERBOSITY = {
0: '0 (Normal)', 0: '0 (Normal)',
1: '1 (Verbose)', 1: '1 (Verbose)',
@@ -45,18 +54,50 @@ const VERBOSITY = {
4: '4 (Connection Debug)', 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, history }) { function JobDetail({ job, i18n, history }) {
const { const {
job_template: jobTemplate,
project,
inventory,
instance_group: instanceGroup,
credentials, credentials,
instance_group: instanceGroup,
inventory,
job_template: jobTemplate,
labels, labels,
project,
} = job.summary_fields; } = job.summary_fields;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [errorMsg, setErrorMsg] = useState(); const [errorMsg, setErrorMsg] = useState();
const { value: launchedByValue, link: launchedByLink } =
getLaunchedByDetails(job) || {};
const deleteJob = async () => { const deleteJob = async () => {
try { try {
switch (job.type) { switch (job.type) {
@@ -84,11 +125,20 @@ function JobDetail({ job, i18n, history }) {
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
} }
}; };
return ( return (
<CardBody> <CardBody>
<DetailList> <DetailList>
{/* TODO: add status icon? */} {/* TODO: hookup status to websockets */}
<Detail label={i18n._(t`Status`)} value={toTitleCase(job.status)} /> <Detail
label={i18n._(t`Status`)}
value={
<StatusDetailValue>
{job.status && <StatusIcon status={job.status} />}
{toTitleCase(job.status)}
</StatusDetailValue>
}
/>
<Detail label={i18n._(t`Started`)} value={job.started} /> <Detail label={i18n._(t`Started`)} value={job.started} />
<Detail label={i18n._(t`Finished`)} value={job.finished} /> <Detail label={i18n._(t`Finished`)} value={job.finished} />
{jobTemplate && ( {jobTemplate && (
@@ -102,6 +152,16 @@ function JobDetail({ job, i18n, history }) {
/> />
)} )}
<Detail label={i18n._(t`Job Type`)} value={toTitleCase(job.job_type)} /> <Detail label={i18n._(t`Job Type`)} value={toTitleCase(job.job_type)} />
<Detail
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
{inventory && ( {inventory && (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}
@@ -110,19 +170,23 @@ function JobDetail({ job, i18n, history }) {
} }
/> />
)} )}
{/* TODO: show project status icon */}
{project && ( {project && (
<Detail <Detail
label={i18n._(t`Project`)} label={i18n._(t`Project`)}
value={<Link to={`/projects/${project.id}`}>{project.name}</Link>} value={
<StatusDetailValue>
{project.status && <StatusIcon status={project.status} />}
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</StatusDetailValue>
}
/> />
)} )}
<Detail label={i18n._(t`Revision`)} value={job.scm_revision} /> <Detail label={i18n._(t`Revision`)} value={job.scm_revision} />
<Detail label={i18n._(t`Playbook`)} value={null} /> <Detail label={i18n._(t`Playbook`)} value={job.playbook} />
<Detail label={i18n._(t`Limit`)} value={job.limit} /> <Detail label={i18n._(t`Limit`)} value={job.limit} />
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} /> <Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
<Detail label={i18n._(t`Environment`)} value={null} /> <Detail label={i18n._(t`Environment`)} value={job.custom_virtualenv} />
<Detail label={i18n._(t`Execution Node`)} value={job.exucution_node} /> <Detail label={i18n._(t`Execution Node`)} value={job.execution_node} />
{instanceGroup && ( {instanceGroup && (
<Detail <Detail
label={i18n._(t`Instance Group`)} label={i18n._(t`Instance Group`)}

View File

@@ -4,78 +4,77 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import JobDetail from './JobDetail'; import JobDetail from './JobDetail';
import { JobsAPI, ProjectUpdatesAPI } from '@api'; import { JobsAPI, ProjectUpdatesAPI } from '@api';
import mockJobData from '../shared/data.job.json';
jest.mock('@api'); jest.mock('@api');
describe('<JobDetail />', () => { describe('<JobDetail />', () => {
let job;
beforeEach(() => {
job = {
name: 'Foo',
summary_fields: {},
};
});
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<JobDetail job={job} />); mountWithContexts(<JobDetail job={mockJobData} />);
}); });
test('should display a Close button', () => { test('should display a Close button', () => {
const wrapper = mountWithContexts(<JobDetail job={job} />); const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1); expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
wrapper.unmount(); wrapper.unmount();
}); });
test('should display details', () => { test('should display details', () => {
job.status = 'Successful'; const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
job.started = '2019-07-02T17:35:22.753817Z';
job.finished = '2019-07-02T17:35:34.910800Z';
const wrapper = mountWithContexts(<JobDetail job={job} />); function assertDetail(label, value) {
const details = wrapper.find('Detail'); expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
function assertDetail(detail, label, value) {
expect(detail.prop('label')).toEqual(label);
expect(detail.prop('value')).toEqual(value);
} }
assertDetail(details.at(0), 'Status', 'Successful'); assertDetail('Status', 'Successful');
assertDetail(details.at(1), 'Started', job.started); assertDetail('Started', mockJobData.started);
assertDetail(details.at(2), 'Finished', job.finished); assertDetail('Finished', mockJobData.finished);
assertDetail('Template', mockJobData.summary_fields.job_template.name);
assertDetail('Job Type', 'Run');
assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
assertDetail('Inventory', mockJobData.summary_fields.inventory.name);
assertDetail('Project', mockJobData.summary_fields.project.name);
assertDetail('Revision', mockJobData.scm_revision);
assertDetail('Playbook', mockJobData.playbook);
assertDetail('Verbosity', '0 (Normal)');
assertDetail('Environment', mockJobData.custom_virtualenv);
assertDetail('Execution Node', mockJobData.execution_node);
assertDetail(
'Instance Group',
mockJobData.summary_fields.instance_group.name
);
assertDetail('Job Slice', '0/1');
assertDetail('Credentials', 'SSH: Demo Credential');
}); });
test('should display credentials', () => { test('should display credentials', () => {
job.summary_fields.credentials = [ const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
{
id: 1,
name: 'Foo',
cloud: false,
kind: 'ssh',
},
];
const wrapper = mountWithContexts(<JobDetail job={job} />);
const credentialChip = wrapper.find('CredentialChip'); const credentialChip = wrapper.find('CredentialChip');
expect(credentialChip.prop('credential')).toEqual( expect(credentialChip.prop('credential')).toEqual(
job.summary_fields.credentials[0] mockJobData.summary_fields.credentials[0]
); );
}); });
test('should display successful job status icon', () => {
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
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(<JobDetail job={mockJobData} />);
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 () => { test('should properly delete job', async () => {
job = { const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
name: 'Rage', wrapper.find('button[aria-label="Delete"]').simulate('click');
id: 1,
type: 'job',
summary_fields: {
job_template: { name: 'Spud' },
},
};
const wrapper = mountWithContexts(<JobDetail job={job} />);
wrapper
.find('button')
.at(0)
.simulate('click');
await sleep(1); await sleep(1);
wrapper.update(); wrapper.update();
const modal = wrapper.find('Modal'); const modal = wrapper.find('Modal');
@@ -83,17 +82,12 @@ describe('<JobDetail />', () => {
modal.find('button[aria-label="Delete"]').simulate('click'); modal.find('button[aria-label="Delete"]').simulate('click');
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
}); });
// The test below is skipped until react can be upgraded to at least 16.9.0. An upgrade to /*
// react - router will likely be necessary also. The test below is skipped until react can be upgraded to at least 16.9.0. An upgrade to
react - router will likely be necessary also.
See: https://github.com/ansible/awx/issues/4817
*/
test.skip('should display error modal when a job does not delete properly', async () => { test.skip('should display error modal when a job does not delete properly', async () => {
job = {
name: 'Angry',
id: 'a',
type: 'project_update',
summary_fields: {
job_template: { name: 'Peanut' },
},
};
ProjectUpdatesAPI.destroy.mockRejectedValue( ProjectUpdatesAPI.destroy.mockRejectedValue(
new Error({ new Error({
response: { response: {
@@ -106,7 +100,7 @@ describe('<JobDetail />', () => {
}, },
}) })
); );
const wrapper = mountWithContexts(<JobDetail job={job} />); const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
wrapper wrapper
.find('button') .find('button')

View File

@@ -32,7 +32,7 @@ const Modal = styled(PFModal)`
const HostNameDetailValue = styled.div` const HostNameDetailValue = styled.div`
align-items: center; align-items: center;
display: inline-flex; display: inline-flex;
> div { .at-c-statusIcon {
margin-right: 10px; margin-right: 10px;
} }
`; `;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobOutput from './JobOutput'; import JobOutput from './JobOutput';
import { JobsAPI } from '@api'; import { JobsAPI } from '@api';
import mockJobData from './data.job.json'; import mockJobData from '../shared/data.job.json';
import mockJobEventsData from './data.job_events.json'; import mockJobEventsData from './data.job_events.json';
jest.mock('@api'); jest.mock('@api');

View File

@@ -321,9 +321,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle { "componentStyle": ComponentStyle {
"componentId": "DetailList-sc-12g7m4-0", "componentId": "DetailList-sc-12g7m4-0",
"isStatic": false, "isStatic": false,
"lastClassName": "gmERnX", "lastClassName": "eYaZBv",
"rules": Array [ "rules": Array [
"display:grid;grid-gap:20px;align-items:baseline;", "display:grid;grid-gap:20px;align-items:flex-start;",
[Function], [Function],
], ],
}, },
@@ -341,15 +341,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
stacked={true} stacked={true}
> >
<DetailList <DetailList
className="DetailList-sc-12g7m4-0 gmERnX" className="DetailList-sc-12g7m4-0 eYaZBv"
stacked={true} stacked={true}
> >
<TextList <TextList
className="DetailList-sc-12g7m4-0 gmERnX" className="DetailList-sc-12g7m4-0 eYaZBv"
component="dl" component="dl"
> >
<dl <dl
className="DetailList-sc-12g7m4-0 gmERnX" className="DetailList-sc-12g7m4-0 eYaZBv"
data-pf-content={true} data-pf-content={true}
> >
<Detail <Detail
@@ -484,9 +484,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle { "componentStyle": ComponentStyle {
"componentId": "DetailList-sc-12g7m4-0", "componentId": "DetailList-sc-12g7m4-0",
"isStatic": false, "isStatic": false,
"lastClassName": "gmERnX", "lastClassName": "eYaZBv",
"rules": Array [ "rules": Array [
"display:grid;grid-gap:20px;align-items:baseline;", "display:grid;grid-gap:20px;align-items:flex-start;",
[Function], [Function],
], ],
}, },
@@ -504,15 +504,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
stacked={true} stacked={true}
> >
<DetailList <DetailList
className="DetailList-sc-12g7m4-0 gmERnX" className="DetailList-sc-12g7m4-0 eYaZBv"
stacked={true} stacked={true}
> >
<TextList <TextList
className="DetailList-sc-12g7m4-0 gmERnX" className="DetailList-sc-12g7m4-0 eYaZBv"
component="dl" component="dl"
> >
<dl <dl
className="DetailList-sc-12g7m4-0 gmERnX" className="DetailList-sc-12g7m4-0 eYaZBv"
data-pf-content={true} data-pf-content={true}
> >
<Detail <Detail