mirror of
https://github.com/ansible/awx.git
synced 2026-03-17 17:07:33 -02: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:
@@ -1,10 +1,23 @@
|
|||||||
import Base from '../Base';
|
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 {
|
class Jobs extends Base {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/jobs/';
|
this.baseUrl = '/api/v2/jobs/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readDetail(id, type) {
|
||||||
|
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Jobs;
|
export default Jobs;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mount } from 'enzyme';
|
import { mount } from 'enzyme';
|
||||||
|
|
||||||
import Chip from './Chip';
|
import Chip from './Chip';
|
||||||
|
|
||||||
describe('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 ChipGroup } from './ChipGroup';
|
||||||
export { default as Chip } from './Chip';
|
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);
|
background: var(--pf-c-form-control--invalid--Background);
|
||||||
border-bottom-width: var(--pf-c-form-control--invalid--BorderBottomWidth);
|
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 (
|
return (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
className="pf-c-form-control"
|
className={`pf-c-form-control ${className}`}
|
||||||
value={value}
|
value={value}
|
||||||
onBeforeChange={(editor, data, val) => onChange(val)}
|
onBeforeChange={(editor, data, val) => onChange(val)}
|
||||||
mode={mode}
|
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';
|
import CodeMirrorInput from './CodeMirrorInput';
|
||||||
|
|
||||||
export default CodeMirrorInput;
|
export default CodeMirrorInput;
|
||||||
|
export { default as VariablesInput } from './VariablesInput';
|
||||||
export { default as VariablesField } from './VariablesField';
|
export { default as VariablesField } from './VariablesField';
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const Detail = ({ label, value, fullWidth }) => {
|
const Detail = ({ label, value, fullWidth }) => {
|
||||||
if (!value) return null;
|
if (!value && typeof value !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<DetailName component={TextListItemVariants.dt} fullWidth={fullWidth}>
|
<DetailName component={TextListItemVariants.dt} fullWidth={fullWidth}>
|
||||||
|
|||||||
@@ -11,6 +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;
|
||||||
${props =>
|
${props =>
|
||||||
props.stacked
|
props.stacked
|
||||||
? `
|
? `
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
CardHeader as PFCardHeader,
|
CardHeader as PFCardHeader,
|
||||||
PageSection,
|
PageSection,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
|
||||||
import { JobsAPI } from '@api';
|
import { JobsAPI } from '@api';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import CardCloseButton from '@components/CardCloseButton';
|
import CardCloseButton from '@components/CardCloseButton';
|
||||||
@@ -16,6 +15,7 @@ import RoutedTabs from '@components/RoutedTabs';
|
|||||||
|
|
||||||
import JobDetail from './JobDetail';
|
import JobDetail from './JobDetail';
|
||||||
import JobOutput from './JobOutput';
|
import JobOutput from './JobOutput';
|
||||||
|
import { JOB_TYPE_URL_SEGMENTS } from './constants';
|
||||||
|
|
||||||
class Job extends Component {
|
class Job extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -49,7 +49,7 @@ class Job extends Component {
|
|||||||
|
|
||||||
this.setState({ contentError: null, hasContentLoading: true });
|
this.setState({ contentError: null, hasContentLoading: true });
|
||||||
try {
|
try {
|
||||||
const { data } = await JobsAPI.readDetail(id);
|
const { data } = await JobsAPI.readDetail(id, match.params.type);
|
||||||
setBreadcrumb(data);
|
setBreadcrumb(data);
|
||||||
this.setState({ job: data });
|
this.setState({ job: data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -60,9 +60,13 @@ class Job extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { history, match, i18n } = this.props;
|
const { history, match, i18n, lookup } = this.props;
|
||||||
|
|
||||||
const { job, contentError, hasContentLoading, isInitialized } = this.state;
|
const { job, contentError, hasContentLoading, isInitialized } = this.state;
|
||||||
|
let jobType;
|
||||||
|
if (job) {
|
||||||
|
jobType = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||||
|
}
|
||||||
|
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
|
{ 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 (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
{cardHeader}
|
{cardHeader}
|
||||||
<Switch>
|
<Switch>
|
||||||
<Redirect from="/jobs/:id" to="/jobs/:id/details" exact />
|
<Redirect
|
||||||
{job && (
|
from="/jobs/:type/:id"
|
||||||
|
to="/jobs/:type/:id/details"
|
||||||
|
exact
|
||||||
|
/>
|
||||||
|
{job && [
|
||||||
<Route
|
<Route
|
||||||
path="/jobs/:id/details"
|
key="details"
|
||||||
render={() => <JobDetail match={match} job={job} />}
|
path="/jobs/:type/:id/details"
|
||||||
/>
|
render={() => <JobDetail type={match.params.type} job={job} />}
|
||||||
)}
|
/>,
|
||||||
{job && (
|
|
||||||
<Route
|
<Route
|
||||||
path="/jobs/:id/output"
|
key="output"
|
||||||
render={() => <JobOutput match={match} job={job} />}
|
path="/jobs/:type/:id/output"
|
||||||
/>
|
render={() => <JobOutput type={match.params.type} job={job} />}
|
||||||
)}
|
/>,
|
||||||
|
]}
|
||||||
</Switch>
|
</Switch>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|||||||
@@ -1,35 +1,164 @@
|
|||||||
import React, { Component } from 'react';
|
import React 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 { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { CardBody, Button } from '@patternfly/react-core';
|
import { CardBody, Button } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
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`
|
const ActionButtonWrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
`;
|
`;
|
||||||
class JobDetail extends Component {
|
|
||||||
render() {
|
|
||||||
const { job, i18n } = this.props;
|
|
||||||
|
|
||||||
return (
|
const VariablesInput = styled(_VariablesInput)`
|
||||||
<CardBody>
|
.pf-c-form__label {
|
||||||
<b>{job.name}</b>
|
--pf-c-form__label--FontWeight: var(--pf-global--FontWeight--bold);
|
||||||
|
|
||||||
<ActionButtonWrapper>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
aria-label="close"
|
|
||||||
component={Link}
|
|
||||||
to="/jobs"
|
|
||||||
>
|
|
||||||
{i18n._(t`Close`)}
|
|
||||||
</Button>
|
|
||||||
</ActionButtonWrapper>
|
|
||||||
</CardBody>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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));
|
export default withI18n()(withRouter(JobDetail));
|
||||||
|
|||||||
@@ -1,22 +1,60 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import JobDetail from './JobDetail';
|
import JobDetail from './JobDetail';
|
||||||
|
|
||||||
describe('<JobDetail />', () => {
|
describe('<JobDetail />', () => {
|
||||||
const mockDetails = {
|
let job;
|
||||||
name: 'Foo',
|
|
||||||
};
|
beforeEach(() => {
|
||||||
|
job = {
|
||||||
|
name: 'Foo',
|
||||||
|
summary_fields: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(<JobDetail job={mockDetails} />);
|
mountWithContexts(<JobDetail job={job} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display a Close button', () => {
|
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);
|
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
|
||||||
wrapper.unmount();
|
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,
|
DataListItemCells,
|
||||||
DataListCheck,
|
DataListCheck,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
|
||||||
import DataListCell from '@components/DataListCell';
|
import DataListCell from '@components/DataListCell';
|
||||||
import VerticalSeparator from '@components/VerticalSeparator';
|
import VerticalSeparator from '@components/VerticalSeparator';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
|
import { JOB_TYPE_URL_SEGMENTS } from '../constants';
|
||||||
|
|
||||||
class JobListItem extends Component {
|
class JobListItem extends Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -32,7 +32,9 @@ class JobListItem extends Component {
|
|||||||
<DataListCell key="divider">
|
<DataListCell key="divider">
|
||||||
<VerticalSeparator />
|
<VerticalSeparator />
|
||||||
<span>
|
<span>
|
||||||
<Link to={`/jobs/${job.id}`}>
|
<Link
|
||||||
|
to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}
|
||||||
|
>
|
||||||
<b>{job.name}</b>
|
<b>{job.name}</b>
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</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 { Route, withRouter, Switch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
||||||
|
|
||||||
import Job from './Job';
|
import Job from './Job';
|
||||||
|
import JobTypeRedirect from './JobTypeRedirect';
|
||||||
import JobList from './JobList/JobList';
|
import JobList from './JobList/JobList';
|
||||||
|
import { JOB_TYPE_URL_SEGMENTS } from './constants';
|
||||||
|
|
||||||
class Jobs extends Component {
|
class Jobs extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -28,11 +28,12 @@ class Jobs extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||||
const breadcrumbConfig = {
|
const breadcrumbConfig = {
|
||||||
'/jobs': i18n._(t`Jobs`),
|
'/jobs': i18n._(t`Jobs`),
|
||||||
[`/jobs/${job.id}`]: `${job.name}`,
|
[`/jobs/${type}/${job.id}`]: `${job.name}`,
|
||||||
[`/jobs/${job.id}/details`]: i18n._(t`Details`),
|
[`/jobs/${type}/${job.id}/details`]: i18n._(t`Details`),
|
||||||
[`/jobs/${job.id}/output`]: i18n._(t`Output`),
|
[`/jobs/${type}/${job.id}/output`]: i18n._(t`Output`),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.setState({ breadcrumbConfig });
|
this.setState({ breadcrumbConfig });
|
||||||
@@ -58,7 +59,19 @@ class Jobs extends Component {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Route
|
<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={() => (
|
render={() => (
|
||||||
<Job
|
<Job
|
||||||
history={history}
|
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>
|
</Switch>
|
||||||
</Fragment>
|
</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 {
|
"componentStyle": ComponentStyle {
|
||||||
"componentId": "DetailList-sc-12g7m4-0",
|
"componentId": "DetailList-sc-12g7m4-0",
|
||||||
"isStatic": false,
|
"isStatic": false,
|
||||||
"lastClassName": "hndBXy",
|
"lastClassName": "gmERnX",
|
||||||
"rules": Array [
|
"rules": Array [
|
||||||
"display:grid;grid-gap:20px;",
|
"display:grid;grid-gap:20px;align-items:baseline;",
|
||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -397,15 +397,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
|||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<DetailList
|
<DetailList
|
||||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<TextList
|
<TextList
|
||||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||||
component="dl"
|
component="dl"
|
||||||
>
|
>
|
||||||
<dl
|
<dl
|
||||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||||
data-pf-content={true}
|
data-pf-content={true}
|
||||||
>
|
>
|
||||||
<Detail
|
<Detail
|
||||||
@@ -545,9 +545,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": "hndBXy",
|
"lastClassName": "gmERnX",
|
||||||
"rules": Array [
|
"rules": Array [
|
||||||
"display:grid;grid-gap:20px;",
|
"display:grid;grid-gap:20px;align-items:baseline;",
|
||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -565,15 +565,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
|||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<DetailList
|
<DetailList
|
||||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<TextList
|
<TextList
|
||||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||||
component="dl"
|
component="dl"
|
||||||
>
|
>
|
||||||
<dl
|
<dl
|
||||||
className="DetailList-sc-12g7m4-0 hndBXy"
|
className="DetailList-sc-12g7m4-0 gmERnX"
|
||||||
data-pf-content={true}
|
data-pf-content={true}
|
||||||
>
|
>
|
||||||
<Detail
|
<Detail
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ import { t } from '@lingui/macro';
|
|||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import LaunchButton from '@components/LaunchButton';
|
import LaunchButton from '@components/LaunchButton';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
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 { DetailList, Detail } from '@components/DetailList';
|
||||||
import { JobTemplatesAPI } from '@api';
|
import { JobTemplatesAPI } from '@api';
|
||||||
import { toTitleCase } from '@util/strings';
|
|
||||||
|
|
||||||
const ButtonGroup = styled.div`
|
const ButtonGroup = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -104,9 +103,6 @@ class JobTemplateDetail extends Component {
|
|||||||
const generateCallBackUrl = `${window.location.origin + url}callback/`;
|
const generateCallBackUrl = `${window.location.origin + url}callback/`;
|
||||||
const isInitialized = !hasTemplateLoading && !hasContentLoading;
|
const isInitialized = !hasTemplateLoading && !hasContentLoading;
|
||||||
|
|
||||||
const credentialType = c =>
|
|
||||||
c === 'aws' || c === 'ssh' ? c.toUpperCase() : toTitleCase(c);
|
|
||||||
|
|
||||||
const renderOptionsField =
|
const renderOptionsField =
|
||||||
become_enabled || host_config_key || allow_simultaneous || use_fact_cache;
|
become_enabled || host_config_key || allow_simultaneous || use_fact_cache;
|
||||||
|
|
||||||
@@ -206,13 +202,7 @@ class JobTemplateDetail extends Component {
|
|||||||
value={
|
value={
|
||||||
<ChipGroup showOverflowAfter={5}>
|
<ChipGroup showOverflowAfter={5}>
|
||||||
{summary_fields.credentials.map(c => (
|
{summary_fields.credentials.map(c => (
|
||||||
<Chip key={c.id} isReadOnly>
|
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||||
<strong className="credential">
|
|
||||||
{c.kind ? credentialType(c.kind) : i18n._(t`Cloud`)}
|
|
||||||
:
|
|
||||||
</strong>
|
|
||||||
{` ${c.name}`}
|
|
||||||
</Chip>
|
|
||||||
))}
|
))}
|
||||||
</ChipGroup>
|
</ChipGroup>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,19 +113,21 @@ describe('<JobTemplateDetail />', () => {
|
|||||||
done();
|
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 }];
|
template.summary_fields.credentials = [{ id: 1, name: 'cred', kind: null }];
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<JobTemplateDetail template={template} />
|
<JobTemplateDetail template={template} />
|
||||||
);
|
);
|
||||||
const jobTemplateDetail = wrapper.find('JobTemplateDetail');
|
wrapper.find('JobTemplateDetail').setState({
|
||||||
jobTemplateDetail.setState({
|
instanceGroups: mockInstanceGroups,
|
||||||
instanceGroups: mockInstanceGroups.data.results,
|
|
||||||
hasContentLoading: false,
|
hasContentLoading: false,
|
||||||
contentError: false,
|
contentError: false,
|
||||||
});
|
});
|
||||||
const cred = wrapper.find('strong.credential');
|
|
||||||
expect(cred.text()).toContain('Cloud:');
|
const chip = wrapper.find('CredentialChip');
|
||||||
done();
|
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({
|
export const JobTemplate = shape({
|
||||||
name: string.isRequired,
|
name: string.isRequired,
|
||||||
description: string,
|
description: string,
|
||||||
inventory: oneOfType([number, string]).isRequired,
|
inventory: number,
|
||||||
job_type: oneOf(['run', 'check']),
|
job_type: oneOf(['run', 'check']),
|
||||||
playbook: string.isRequired,
|
playbook: string,
|
||||||
project: oneOfType([number, string]).isRequired,
|
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)}`;
|
return `${str[0].toUpperCase()}${str.substr(1)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toTitleCase = string =>
|
export const toTitleCase = string => {
|
||||||
string
|
if (!string) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return string
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split('_')
|
.split('_')
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pluralize, getArticle, ucFirst } from './strings';
|
import { pluralize, getArticle, ucFirst, toTitleCase } from './strings';
|
||||||
|
|
||||||
describe('string utils', () => {
|
describe('string utils', () => {
|
||||||
describe('pluralize', () => {
|
describe('pluralize', () => {
|
||||||
@@ -31,4 +31,14 @@ describe('string utils', () => {
|
|||||||
expect(ucFirst('team')).toEqual('Team');
|
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);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user