mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #4316 from keithjgrant/222-job-results-details
222 job results details Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
commit
8ccccfecf1
@ -1,10 +1,23 @@
|
||||
import Base from '../Base';
|
||||
|
||||
const BASE_URLS = {
|
||||
playbook: '/jobs/',
|
||||
project: '/project_updates/',
|
||||
system: '/system_jobs/',
|
||||
inventory: '/inventory_updates/',
|
||||
command: '/ad_hoc_commands/',
|
||||
workflow: '/workflow_jobs/',
|
||||
};
|
||||
|
||||
class Jobs extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/jobs/';
|
||||
}
|
||||
|
||||
readDetail(id, type) {
|
||||
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default Jobs;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import Chip from './Chip';
|
||||
|
||||
describe('Chip', () => {
|
||||
|
||||
32
awx/ui_next/src/components/Chip/CredentialChip.jsx
Normal file
32
awx/ui_next/src/components/Chip/CredentialChip.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { shape } from 'prop-types';
|
||||
import { toTitleCase } from '@util/strings';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import Chip from './Chip';
|
||||
import { Credential } from '../../types';
|
||||
|
||||
function CredentialChip({ credential, i18n, ...props }) {
|
||||
let type;
|
||||
if (credential.cloud) {
|
||||
type = i18n._(t`Cloud`);
|
||||
} else if (credential.kind === 'aws' || credential.kind === 'ssh') {
|
||||
type = credential.kind.toUpperCase();
|
||||
} else {
|
||||
type = toTitleCase(credential.kind);
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip {...props}>
|
||||
<strong>{type}: </strong>
|
||||
{credential.name}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
CredentialChip.propTypes = {
|
||||
credential: Credential.isRequired,
|
||||
i18n: shape({}).isRequired,
|
||||
};
|
||||
|
||||
export { CredentialChip as _CredentialChip };
|
||||
export default withI18n()(CredentialChip);
|
||||
58
awx/ui_next/src/components/Chip/CredentialChip.test.jsx
Normal file
58
awx/ui_next/src/components/Chip/CredentialChip.test.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import CredentialChip from './CredentialChip';
|
||||
|
||||
describe('CredentialChip', () => {
|
||||
test('should render SSH kind', () => {
|
||||
const credential = {
|
||||
id: 1,
|
||||
kind: 'ssh',
|
||||
name: 'foo',
|
||||
};
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<CredentialChip credential={credential} />
|
||||
);
|
||||
expect(wrapper.find('CredentialChip').text()).toEqual('SSH: foo');
|
||||
});
|
||||
|
||||
test('should render AWS kind', () => {
|
||||
const credential = {
|
||||
id: 1,
|
||||
kind: 'aws',
|
||||
name: 'foo',
|
||||
};
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<CredentialChip credential={credential} />
|
||||
);
|
||||
expect(wrapper.find('CredentialChip').text()).toEqual('AWS: foo');
|
||||
});
|
||||
|
||||
test('should render with "Cloud"', () => {
|
||||
const credential = {
|
||||
id: 1,
|
||||
cloud: true,
|
||||
kind: 'other',
|
||||
name: 'foo',
|
||||
};
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<CredentialChip credential={credential} />
|
||||
);
|
||||
expect(wrapper.find('CredentialChip').text()).toEqual('Cloud: foo');
|
||||
});
|
||||
|
||||
test('should render with other kind', () => {
|
||||
const credential = {
|
||||
id: 1,
|
||||
kind: 'other',
|
||||
name: 'foo',
|
||||
};
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<CredentialChip credential={credential} />
|
||||
);
|
||||
expect(wrapper.find('CredentialChip').text()).toEqual('Other: foo');
|
||||
});
|
||||
});
|
||||
@ -1,2 +1,3 @@
|
||||
export { default as ChipGroup } from './ChipGroup';
|
||||
export { default as Chip } from './Chip';
|
||||
export { default as CredentialChip } from './CredentialChip';
|
||||
|
||||
@ -32,12 +32,42 @@ const CodeMirror = styled(ReactCodeMirror)`
|
||||
background: var(--pf-c-form-control--invalid--Background);
|
||||
border-bottom-width: var(--pf-c-form-control--invalid--BorderBottomWidth);
|
||||
}`}
|
||||
|
||||
${props =>
|
||||
props.options &&
|
||||
props.options.readOnly &&
|
||||
`
|
||||
&,
|
||||
&:hover {
|
||||
--pf-c-form-control--BorderBottomColor: var(--pf-global--BorderColor--300);
|
||||
}
|
||||
|
||||
.CodeMirror-cursors {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
background-color: var(--pf-c-form-control--disabled--BackgroundColor);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
function CodeMirrorInput({ value, onChange, mode, readOnly, hasErrors, rows }) {
|
||||
function CodeMirrorInput({
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
readOnly,
|
||||
hasErrors,
|
||||
rows,
|
||||
className,
|
||||
}) {
|
||||
return (
|
||||
<CodeMirror
|
||||
className="pf-c-form-control"
|
||||
className={`pf-c-form-control ${className}`}
|
||||
value={value}
|
||||
onBeforeChange={(editor, data, val) => onChange(val)}
|
||||
mode={mode}
|
||||
|
||||
124
awx/ui_next/src/components/CodeMirrorInput/VariablesInput.jsx
Normal file
124
awx/ui_next/src/components/CodeMirrorInput/VariablesInput.jsx
Normal file
@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { string, func, bool, number } from 'prop-types';
|
||||
import { Button, Split, SplitItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import ButtonGroup from '@components/ButtonGroup';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
|
||||
const YAML_MODE = 'yaml';
|
||||
const JSON_MODE = 'javascript';
|
||||
|
||||
function formatJson(jsonString) {
|
||||
return JSON.stringify(JSON.parse(jsonString), null, 2);
|
||||
}
|
||||
|
||||
const SmallButton = styled(Button)`
|
||||
padding: 3px 8px;
|
||||
font-size: var(--pf-global--FontSize--xs);
|
||||
`;
|
||||
|
||||
const SplitItemRight = styled(SplitItem)`
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
function VariablesInput(props) {
|
||||
const { id, label, readOnly, rows, error, onError, className } = props;
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
const defaultValue = isJson(props.value)
|
||||
? formatJson(props.value)
|
||||
: props.value;
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
||||
const isControlled = !!props.onChange;
|
||||
/* eslint-enable react/destructuring-assignment */
|
||||
|
||||
const onChange = newValue => {
|
||||
if (isControlled) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`pf-c-form__group ${className || ''}`}>
|
||||
<Split gutter="sm">
|
||||
<SplitItem>
|
||||
<label htmlFor={id} className="pf-c-form__label">
|
||||
{label}
|
||||
</label>
|
||||
</SplitItem>
|
||||
<SplitItemRight>
|
||||
<ButtonGroup>
|
||||
<SmallButton
|
||||
onClick={() => {
|
||||
if (mode === YAML_MODE) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onChange(jsonToYaml(value));
|
||||
setMode(YAML_MODE);
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
}
|
||||
}}
|
||||
variant={mode === YAML_MODE ? 'primary' : 'secondary'}
|
||||
>
|
||||
YAML
|
||||
</SmallButton>
|
||||
<SmallButton
|
||||
onClick={() => {
|
||||
if (mode === JSON_MODE) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onChange(yamlToJson(value));
|
||||
setMode(JSON_MODE);
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
}
|
||||
}}
|
||||
variant={mode === JSON_MODE ? 'primary' : 'secondary'}
|
||||
>
|
||||
JSON
|
||||
</SmallButton>
|
||||
</ButtonGroup>
|
||||
</SplitItemRight>
|
||||
</Split>
|
||||
<CodeMirrorInput
|
||||
mode={mode}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
rows={rows}
|
||||
hasErrors={!!error}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
VariablesInput.propTypes = {
|
||||
id: string.isRequired,
|
||||
label: string.isRequired,
|
||||
value: string.isRequired,
|
||||
readOnly: bool,
|
||||
error: string,
|
||||
rows: number,
|
||||
onChange: func,
|
||||
onError: func,
|
||||
};
|
||||
VariablesInput.defaultProps = {
|
||||
readOnly: false,
|
||||
onChange: null,
|
||||
rows: 6,
|
||||
error: null,
|
||||
onError: () => {},
|
||||
};
|
||||
|
||||
export default styled(VariablesInput)`
|
||||
--pf-c-form__label--FontSize: var(--pf-global--FontSize--sm);
|
||||
`;
|
||||
@ -1,4 +1,5 @@
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
|
||||
export default CodeMirrorInput;
|
||||
export { default as VariablesInput } from './VariablesInput';
|
||||
export { default as VariablesField } from './VariablesField';
|
||||
|
||||
@ -26,7 +26,9 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
|
||||
`;
|
||||
|
||||
const Detail = ({ label, value, fullWidth }) => {
|
||||
if (!value) return null;
|
||||
if (!value && typeof value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<DetailName component={TextListItemVariants.dt} fullWidth={fullWidth}>
|
||||
|
||||
@ -11,6 +11,7 @@ const DetailList = ({ children, stacked, ...props }) => (
|
||||
export default styled(DetailList)`
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
align-items: baseline;
|
||||
${props =>
|
||||
props.stacked
|
||||
? `
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
CardHeader as PFCardHeader,
|
||||
PageSection,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import { JobsAPI } from '@api';
|
||||
import ContentError from '@components/ContentError';
|
||||
import CardCloseButton from '@components/CardCloseButton';
|
||||
@ -16,6 +15,7 @@ import RoutedTabs from '@components/RoutedTabs';
|
||||
|
||||
import JobDetail from './JobDetail';
|
||||
import JobOutput from './JobOutput';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from './constants';
|
||||
|
||||
class Job extends Component {
|
||||
constructor(props) {
|
||||
@ -49,7 +49,7 @@ class Job extends Component {
|
||||
|
||||
this.setState({ contentError: null, hasContentLoading: true });
|
||||
try {
|
||||
const { data } = await JobsAPI.readDetail(id);
|
||||
const { data } = await JobsAPI.readDetail(id, match.params.type);
|
||||
setBreadcrumb(data);
|
||||
this.setState({ job: data });
|
||||
} catch (err) {
|
||||
@ -60,9 +60,13 @@ class Job extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { history, match, i18n } = this.props;
|
||||
const { history, match, i18n, lookup } = this.props;
|
||||
|
||||
const { job, contentError, hasContentLoading, isInitialized } = this.state;
|
||||
let jobType;
|
||||
if (job) {
|
||||
jobType = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||
}
|
||||
|
||||
const tabsArray = [
|
||||
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
|
||||
@ -101,24 +105,41 @@ class Job extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (lookup && job) {
|
||||
return (
|
||||
<Switch>
|
||||
<Redirect from="jobs/:id" to={`/jobs/${jobType}/:id/details`} />
|
||||
<Redirect
|
||||
from="jobs/:id/details"
|
||||
to={`/jobs/${jobType}/:id/details`}
|
||||
/>
|
||||
<Redirect from="jobs/:id/output" to={`/jobs/${jobType}/:id/output`} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{cardHeader}
|
||||
<Switch>
|
||||
<Redirect from="/jobs/:id" to="/jobs/:id/details" exact />
|
||||
{job && (
|
||||
<Redirect
|
||||
from="/jobs/:type/:id"
|
||||
to="/jobs/:type/:id/details"
|
||||
exact
|
||||
/>
|
||||
{job && [
|
||||
<Route
|
||||
path="/jobs/:id/details"
|
||||
render={() => <JobDetail match={match} job={job} />}
|
||||
/>
|
||||
)}
|
||||
{job && (
|
||||
key="details"
|
||||
path="/jobs/:type/:id/details"
|
||||
render={() => <JobDetail type={match.params.type} job={job} />}
|
||||
/>,
|
||||
<Route
|
||||
path="/jobs/:id/output"
|
||||
render={() => <JobOutput match={match} job={job} />}
|
||||
/>
|
||||
)}
|
||||
key="output"
|
||||
path="/jobs/:type/:id/output"
|
||||
render={() => <JobOutput type={match.params.type} job={job} />}
|
||||
/>,
|
||||
]}
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,35 +1,164 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { CardBody, Button } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { DetailList, Detail } from '@components/DetailList';
|
||||
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
|
||||
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
|
||||
import { toTitleCase } from '@util/strings';
|
||||
import { Job } from '../../../types';
|
||||
|
||||
const ActionButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
class JobDetail extends Component {
|
||||
render() {
|
||||
const { job, i18n } = this.props;
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<b>{job.name}</b>
|
||||
|
||||
<ActionButtonWrapper>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label="close"
|
||||
component={Link}
|
||||
to="/jobs"
|
||||
>
|
||||
{i18n._(t`Close`)}
|
||||
</Button>
|
||||
</ActionButtonWrapper>
|
||||
</CardBody>
|
||||
);
|
||||
const VariablesInput = styled(_VariablesInput)`
|
||||
.pf-c-form__label {
|
||||
--pf-c-form__label--FontWeight: var(--pf-global--FontWeight--bold);
|
||||
}
|
||||
`;
|
||||
|
||||
const VERBOSITY = {
|
||||
0: '0 (Normal)',
|
||||
1: '1 (Verbose)',
|
||||
2: '2 (More Verbose)',
|
||||
3: '3 (Debug)',
|
||||
4: '4 (Connection Debug)',
|
||||
};
|
||||
|
||||
function JobDetail({ job, i18n }) {
|
||||
const {
|
||||
job_template: jobTemplate,
|
||||
project,
|
||||
inventory,
|
||||
instance_group: instanceGroup,
|
||||
credentials,
|
||||
labels,
|
||||
} = job.summary_fields;
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
{/* TODO: add status icon? */}
|
||||
<Detail label={i18n._(t`Status`)} value={toTitleCase(job.status)} />
|
||||
<Detail label={i18n._(t`Started`)} value={job.started} />
|
||||
<Detail label={i18n._(t`Finished`)} value={job.finished} />
|
||||
{jobTemplate && (
|
||||
<Detail
|
||||
label={i18n._(t`Template`)}
|
||||
value={
|
||||
<Link to={`/templates/job_template/${jobTemplate.id}`}>
|
||||
{jobTemplate.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail label={i18n._(t`Job Type`)} value={toTitleCase(job.job_type)} />
|
||||
{inventory && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={
|
||||
<Link to={`/inventory/${inventory.id}`}>{inventory.name}</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: show project status icon */}
|
||||
{project && (
|
||||
<Detail
|
||||
label={i18n._(t`Project`)}
|
||||
value={<Link to={`/projects/${project.id}`}>{project.name}</Link>}
|
||||
/>
|
||||
)}
|
||||
<Detail label={i18n._(t`Revision`)} value={job.scm_revision} />
|
||||
<Detail label={i18n._(t`Playbook`)} value={null} />
|
||||
<Detail label={i18n._(t`Limit`)} value={job.limit} />
|
||||
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
|
||||
<Detail label={i18n._(t`Environment`)} value={null} />
|
||||
<Detail label={i18n._(t`Execution Node`)} value={job.exucution_node} />
|
||||
{instanceGroup && (
|
||||
<Detail
|
||||
label={i18n._(t`Instance Group`)}
|
||||
value={
|
||||
<Link to={`/instance_groups/${instanceGroup.id}`}>
|
||||
{instanceGroup.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{typeof job.job_slice_number === 'number' &&
|
||||
typeof job.job_slice_count === 'number' && (
|
||||
<Detail
|
||||
label={i18n._(t`Job Slice`)}
|
||||
value={`${job.job_slice_number}/${job.job_slice_count}`}
|
||||
/>
|
||||
)}
|
||||
{credentials && credentials.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Credentials`)}
|
||||
value={
|
||||
<ChipGroup showOverflowAfter={5}>
|
||||
{credentials.map(c => (
|
||||
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{labels && labels.count > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Labels`)}
|
||||
value={
|
||||
<ChipGroup showOverflowAfter={5}>
|
||||
{labels.results.map(l => (
|
||||
<Chip key={l.id} isReadOnly>
|
||||
{l.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailList>
|
||||
{job.extra_vars && (
|
||||
<VariablesInput
|
||||
css="margin: 20px 0"
|
||||
id="job-variables"
|
||||
readOnly
|
||||
value={job.extra_vars}
|
||||
rows={4}
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
)}
|
||||
{job.artifacts && (
|
||||
<VariablesInput
|
||||
css="margin: 20px 0"
|
||||
id="job-artifacts"
|
||||
readOnly
|
||||
value={JSON.stringify(job.artifacts)}
|
||||
rows={4}
|
||||
label={i18n._(t`Artifacts`)}
|
||||
/>
|
||||
)}
|
||||
<ActionButtonWrapper>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label="close"
|
||||
component={Link}
|
||||
to="/jobs"
|
||||
>
|
||||
{i18n._(t`Close`)}
|
||||
</Button>
|
||||
</ActionButtonWrapper>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
JobDetail.propTypes = {
|
||||
job: Job.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(JobDetail));
|
||||
|
||||
@ -1,22 +1,60 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
|
||||
import JobDetail from './JobDetail';
|
||||
|
||||
describe('<JobDetail />', () => {
|
||||
const mockDetails = {
|
||||
name: 'Foo',
|
||||
};
|
||||
let job;
|
||||
|
||||
beforeEach(() => {
|
||||
job = {
|
||||
name: 'Foo',
|
||||
summary_fields: {},
|
||||
};
|
||||
});
|
||||
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(<JobDetail job={mockDetails} />);
|
||||
mountWithContexts(<JobDetail job={job} />);
|
||||
});
|
||||
|
||||
test('should display a Close button', () => {
|
||||
const wrapper = mountWithContexts(<JobDetail job={mockDetails} />);
|
||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
||||
|
||||
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should display details', () => {
|
||||
job.status = 'Successful';
|
||||
job.started = '2019-07-02T17:35:22.753817Z';
|
||||
job.finished = '2019-07-02T17:35:34.910800Z';
|
||||
|
||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
||||
const details = wrapper.find('Detail');
|
||||
|
||||
function assertDetail(detail, label, value) {
|
||||
expect(detail.prop('label')).toEqual(label);
|
||||
expect(detail.prop('value')).toEqual(value);
|
||||
}
|
||||
|
||||
assertDetail(details.at(0), 'Status', 'Successful');
|
||||
assertDetail(details.at(1), 'Started', job.started);
|
||||
assertDetail(details.at(2), 'Finished', job.finished);
|
||||
});
|
||||
|
||||
test('should display credentials', () => {
|
||||
job.summary_fields.credentials = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Foo',
|
||||
cloud: false,
|
||||
kind: 'ssh',
|
||||
},
|
||||
];
|
||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
||||
const credentialChip = wrapper.find('CredentialChip');
|
||||
|
||||
expect(credentialChip.prop('credential')).toEqual(
|
||||
job.summary_fields.credentials[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,10 +6,10 @@ import {
|
||||
DataListItemCells,
|
||||
DataListCheck,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import DataListCell from '@components/DataListCell';
|
||||
import VerticalSeparator from '@components/VerticalSeparator';
|
||||
import { toTitleCase } from '@util/strings';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from '../constants';
|
||||
|
||||
class JobListItem extends Component {
|
||||
render() {
|
||||
@ -32,7 +32,9 @@ class JobListItem extends Component {
|
||||
<DataListCell key="divider">
|
||||
<VerticalSeparator />
|
||||
<span>
|
||||
<Link to={`/jobs/${job.id}`}>
|
||||
<Link
|
||||
to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}
|
||||
>
|
||||
<b>{job.name}</b>
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
52
awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
Normal file
52
awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { UnifiedJobsAPI } from '@api';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from './constants';
|
||||
|
||||
class JobTypeRedirect extends Component {
|
||||
static defaultProps = {
|
||||
view: 'details',
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasError: false,
|
||||
job: null,
|
||||
};
|
||||
this.loadJob = this.loadJob.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadJob();
|
||||
}
|
||||
|
||||
async loadJob() {
|
||||
const { id } = this.props;
|
||||
try {
|
||||
const { data } = await UnifiedJobsAPI.read({ id });
|
||||
this.setState({
|
||||
job: data.results[0],
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({ hasError: true });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { path, view } = this.props;
|
||||
const { hasError, job } = this.state;
|
||||
|
||||
if (hasError) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
if (!job) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default JobTypeRedirect;
|
||||
@ -2,11 +2,11 @@ import React, { Component, Fragment } from 'react';
|
||||
import { Route, withRouter, Switch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
||||
|
||||
import Job from './Job';
|
||||
import JobTypeRedirect from './JobTypeRedirect';
|
||||
import JobList from './JobList/JobList';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from './constants';
|
||||
|
||||
class Jobs extends Component {
|
||||
constructor(props) {
|
||||
@ -28,11 +28,12 @@ class Jobs extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||
const breadcrumbConfig = {
|
||||
'/jobs': i18n._(t`Jobs`),
|
||||
[`/jobs/${job.id}`]: `${job.name}`,
|
||||
[`/jobs/${job.id}/details`]: i18n._(t`Details`),
|
||||
[`/jobs/${job.id}/output`]: i18n._(t`Output`),
|
||||
[`/jobs/${type}/${job.id}`]: `${job.name}`,
|
||||
[`/jobs/${type}/${job.id}/details`]: i18n._(t`Details`),
|
||||
[`/jobs/${type}/${job.id}/output`]: i18n._(t`Output`),
|
||||
};
|
||||
|
||||
this.setState({ breadcrumbConfig });
|
||||
@ -58,7 +59,19 @@ class Jobs extends Component {
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/:id`}
|
||||
path={`${match.path}/:id/details`}
|
||||
render={({ match: m }) => (
|
||||
<JobTypeRedirect id={m.params.id} path={m.path} view="details" />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/:id/output`}
|
||||
render={({ match: m }) => (
|
||||
<JobTypeRedirect id={m.params.id} path={m.path} view="output" />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/:type/:id`}
|
||||
render={() => (
|
||||
<Job
|
||||
history={history}
|
||||
@ -67,6 +80,12 @@ class Jobs extends Component {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/:id`}
|
||||
render={({ match: m }) => (
|
||||
<JobTypeRedirect id={m.params.id} path={m.path} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
9
awx/ui_next/src/screens/Job/constants.js
Normal file
9
awx/ui_next/src/screens/Job/constants.js
Normal file
@ -0,0 +1,9 @@
|
||||
/* eslint-disable-next-line import/prefer-default-export */
|
||||
export const JOB_TYPE_URL_SEGMENTS = {
|
||||
job: 'playbook',
|
||||
project_update: 'project',
|
||||
system_job: 'system',
|
||||
inventory_update: 'inventory',
|
||||
ad_hoc_command: 'command',
|
||||
workflow_job: 'workflow',
|
||||
};
|
||||
@ -377,9 +377,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "DetailList-sc-12g7m4-0",
|
||||
"isStatic": false,
|
||||
"lastClassName": "hndBXy",
|
||||
"lastClassName": "gmERnX",
|
||||
"rules": Array [
|
||||
"display:grid;grid-gap:20px;",
|
||||
"display:grid;grid-gap:20px;align-items:baseline;",
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
@ -397,15 +397,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<DetailList
|
||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||
stacked={true}
|
||||
>
|
||||
<TextList
|
||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||
component="dl"
|
||||
>
|
||||
<dl
|
||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||
data-pf-content={true}
|
||||
>
|
||||
<Detail
|
||||
@ -545,9 +545,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "DetailList-sc-12g7m4-0",
|
||||
"isStatic": false,
|
||||
"lastClassName": "hndBXy",
|
||||
"lastClassName": "gmERnX",
|
||||
"rules": Array [
|
||||
"display:grid;grid-gap:20px;",
|
||||
"display:grid;grid-gap:20px;align-items:baseline;",
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
@ -565,15 +565,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<DetailList
|
||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||
stacked={true}
|
||||
>
|
||||
<TextList
|
||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||
component="dl"
|
||||
>
|
||||
<dl
|
||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||
data-pf-content={true}
|
||||
>
|
||||
<Detail
|
||||
|
||||
@ -15,10 +15,9 @@ import { t } from '@lingui/macro';
|
||||
import ContentError from '@components/ContentError';
|
||||
import LaunchButton from '@components/LaunchButton';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import { ChipGroup, Chip } from '@components/Chip';
|
||||
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
|
||||
import { DetailList, Detail } from '@components/DetailList';
|
||||
import { JobTemplatesAPI } from '@api';
|
||||
import { toTitleCase } from '@util/strings';
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
@ -104,9 +103,6 @@ class JobTemplateDetail extends Component {
|
||||
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;
|
||||
|
||||
@ -206,13 +202,7 @@ class JobTemplateDetail extends Component {
|
||||
value={
|
||||
<ChipGroup showOverflowAfter={5}>
|
||||
{summary_fields.credentials.map(c => (
|
||||
<Chip key={c.id} isReadOnly>
|
||||
<strong className="credential">
|
||||
{c.kind ? credentialType(c.kind) : i18n._(t`Cloud`)}
|
||||
:
|
||||
</strong>
|
||||
{` ${c.name}`}
|
||||
</Chip>
|
||||
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
|
||||
@ -113,19 +113,21 @@ describe('<JobTemplateDetail />', () => {
|
||||
done();
|
||||
});
|
||||
|
||||
test('Credential type is Cloud if credential.kind is null', async done => {
|
||||
test('should render CredentialChip', () => {
|
||||
template.summary_fields.credentials = [{ id: 1, name: 'cred', kind: null }];
|
||||
const wrapper = mountWithContexts(
|
||||
<JobTemplateDetail template={template} />
|
||||
);
|
||||
const jobTemplateDetail = wrapper.find('JobTemplateDetail');
|
||||
jobTemplateDetail.setState({
|
||||
instanceGroups: mockInstanceGroups.data.results,
|
||||
wrapper.find('JobTemplateDetail').setState({
|
||||
instanceGroups: mockInstanceGroups,
|
||||
hasContentLoading: false,
|
||||
contentError: false,
|
||||
});
|
||||
const cred = wrapper.find('strong.credential');
|
||||
expect(cred.text()).toContain('Cloud:');
|
||||
done();
|
||||
|
||||
const chip = wrapper.find('CredentialChip');
|
||||
expect(chip).toHaveLength(1);
|
||||
expect(chip.prop('credential')).toEqual(
|
||||
template.summary_fields.credentials[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -65,8 +65,61 @@ export const QSConfig = shape({
|
||||
export const JobTemplate = shape({
|
||||
name: string.isRequired,
|
||||
description: string,
|
||||
inventory: oneOfType([number, string]).isRequired,
|
||||
inventory: number,
|
||||
job_type: oneOf(['run', 'check']),
|
||||
playbook: string.isRequired,
|
||||
project: oneOfType([number, string]).isRequired,
|
||||
playbook: string,
|
||||
project: number,
|
||||
});
|
||||
|
||||
export const Project = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
});
|
||||
|
||||
export const Inventory = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
});
|
||||
|
||||
export const InstanceGroup = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
});
|
||||
|
||||
export const Label = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
});
|
||||
|
||||
export const Credential = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
cloud: bool,
|
||||
kind: string,
|
||||
});
|
||||
|
||||
export const Job = shape({
|
||||
status: string,
|
||||
started: string,
|
||||
finished: string,
|
||||
job_type: string,
|
||||
summary_fields: shape({
|
||||
job_template: JobTemplate,
|
||||
project: Project,
|
||||
inventory: Inventory,
|
||||
instance_group: InstanceGroup,
|
||||
credentials: arrayOf(Credential),
|
||||
labels: shape({
|
||||
count: number,
|
||||
results: arrayOf(Label),
|
||||
}),
|
||||
}),
|
||||
scm_revision: string,
|
||||
limit: oneOfType([number, string]),
|
||||
verbosity: number,
|
||||
execution_mode: string,
|
||||
job_slice_number: number,
|
||||
job_slice_count: number,
|
||||
extra_vars: string,
|
||||
artifacts: shape({}),
|
||||
});
|
||||
|
||||
@ -14,9 +14,13 @@ export function ucFirst(str) {
|
||||
return `${str[0].toUpperCase()}${str.substr(1)}`;
|
||||
}
|
||||
|
||||
export const toTitleCase = string =>
|
||||
string
|
||||
export const toTitleCase = string => {
|
||||
if (!string) {
|
||||
return '';
|
||||
}
|
||||
return string
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { pluralize, getArticle, ucFirst } from './strings';
|
||||
import { pluralize, getArticle, ucFirst, toTitleCase } from './strings';
|
||||
|
||||
describe('string utils', () => {
|
||||
describe('pluralize', () => {
|
||||
@ -31,4 +31,14 @@ describe('string utils', () => {
|
||||
expect(ucFirst('team')).toEqual('Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTitleCase', () => {
|
||||
test('should upper case each word', () => {
|
||||
expect(toTitleCase('a_string_of_words')).toEqual('A String Of Words');
|
||||
});
|
||||
|
||||
test('should return empty string for undefined', () => {
|
||||
expect(toTitleCase(undefined)).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,3 +21,17 @@ export function jsonToYaml(jsonString) {
|
||||
}
|
||||
return yaml.safeDump(value);
|
||||
}
|
||||
|
||||
export function isJson(jsonString) {
|
||||
if (typeof jsonString !== 'string') {
|
||||
return false;
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user