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:
softwarefactory-project-zuul[bot] 2019-07-18 17:15:38 +00:00 committed by GitHub
commit 8ccccfecf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 692 additions and 88 deletions

View File

@ -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;

View File

@ -1,6 +1,5 @@
import React from 'react';
import { mount } from 'enzyme';
import Chip from './Chip';
describe('Chip', () => {

View 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);

View 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');
});
});

View File

@ -1,2 +1,3 @@
export { default as ChipGroup } from './ChipGroup';
export { default as Chip } from './Chip';
export { default as CredentialChip } from './CredentialChip';

View File

@ -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}

View 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);
`;

View File

@ -1,4 +1,5 @@
import CodeMirrorInput from './CodeMirrorInput';
export default CodeMirrorInput;
export { default as VariablesInput } from './VariablesInput';
export { default as VariablesField } from './VariablesField';

View File

@ -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}>

View File

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

View File

@ -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>

View File

@ -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));

View File

@ -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]
);
});
});

View File

@ -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>

View 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;

View File

@ -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>
);

View 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',
};

View File

@ -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

View File

@ -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>
}

View File

@ -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]
);
});
});

View File

@ -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({}),
});

View File

@ -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(' ');
};

View File

@ -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('');
});
});
});

View File

@ -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;
}