diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js
index 295ee815e8..bd103c072a 100644
--- a/awx/ui_next/src/api/models/Jobs.js
+++ b/awx/ui_next/src/api/models/Jobs.js
@@ -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;
diff --git a/awx/ui_next/src/components/Chip/Chip.test.jsx b/awx/ui_next/src/components/Chip/Chip.test.jsx
index 0d4850da6d..8e38d71453 100644
--- a/awx/ui_next/src/components/Chip/Chip.test.jsx
+++ b/awx/ui_next/src/components/Chip/Chip.test.jsx
@@ -1,6 +1,5 @@
import React from 'react';
import { mount } from 'enzyme';
-
import Chip from './Chip';
describe('Chip', () => {
diff --git a/awx/ui_next/src/components/Chip/CredentialChip.jsx b/awx/ui_next/src/components/Chip/CredentialChip.jsx
new file mode 100644
index 0000000000..1e806a0c01
--- /dev/null
+++ b/awx/ui_next/src/components/Chip/CredentialChip.jsx
@@ -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 (
+
+ {type}:
+ {credential.name}
+
+ );
+}
+CredentialChip.propTypes = {
+ credential: Credential.isRequired,
+ i18n: shape({}).isRequired,
+};
+
+export { CredentialChip as _CredentialChip };
+export default withI18n()(CredentialChip);
diff --git a/awx/ui_next/src/components/Chip/CredentialChip.test.jsx b/awx/ui_next/src/components/Chip/CredentialChip.test.jsx
new file mode 100644
index 0000000000..7bcdc3dcd6
--- /dev/null
+++ b/awx/ui_next/src/components/Chip/CredentialChip.test.jsx
@@ -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(
+
+ );
+ expect(wrapper.find('CredentialChip').text()).toEqual('SSH: foo');
+ });
+
+ test('should render AWS kind', () => {
+ const credential = {
+ id: 1,
+ kind: 'aws',
+ name: 'foo',
+ };
+
+ const wrapper = mountWithContexts(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ expect(wrapper.find('CredentialChip').text()).toEqual('Other: foo');
+ });
+});
diff --git a/awx/ui_next/src/components/Chip/index.js b/awx/ui_next/src/components/Chip/index.js
index 96e20db252..56324606bf 100644
--- a/awx/ui_next/src/components/Chip/index.js
+++ b/awx/ui_next/src/components/Chip/index.js
@@ -1,2 +1,3 @@
export { default as ChipGroup } from './ChipGroup';
export { default as Chip } from './Chip';
+export { default as CredentialChip } from './CredentialChip';
diff --git a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx
index 06596aa8a9..6513322f66 100644
--- a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx
+++ b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx
@@ -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 (
onChange(val)}
mode={mode}
diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesInput.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesInput.jsx
new file mode 100644
index 0000000000..76476ea92b
--- /dev/null
+++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesInput.jsx
@@ -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 (
+
+
+
+
+
+
+
+ {
+ if (mode === YAML_MODE) {
+ return;
+ }
+ try {
+ onChange(jsonToYaml(value));
+ setMode(YAML_MODE);
+ } catch (err) {
+ onError(err.message);
+ }
+ }}
+ variant={mode === YAML_MODE ? 'primary' : 'secondary'}
+ >
+ YAML
+
+ {
+ if (mode === JSON_MODE) {
+ return;
+ }
+ try {
+ onChange(yamlToJson(value));
+ setMode(JSON_MODE);
+ } catch (err) {
+ onError(err.message);
+ }
+ }}
+ variant={mode === JSON_MODE ? 'primary' : 'secondary'}
+ >
+ JSON
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+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);
+`;
diff --git a/awx/ui_next/src/components/CodeMirrorInput/index.js b/awx/ui_next/src/components/CodeMirrorInput/index.js
index 932883224b..48940bbb37 100644
--- a/awx/ui_next/src/components/CodeMirrorInput/index.js
+++ b/awx/ui_next/src/components/CodeMirrorInput/index.js
@@ -1,4 +1,5 @@
import CodeMirrorInput from './CodeMirrorInput';
export default CodeMirrorInput;
+export { default as VariablesInput } from './VariablesInput';
export { default as VariablesField } from './VariablesField';
diff --git a/awx/ui_next/src/components/DetailList/Detail.jsx b/awx/ui_next/src/components/DetailList/Detail.jsx
index ea65fdc288..ef13ac0aa6 100644
--- a/awx/ui_next/src/components/DetailList/Detail.jsx
+++ b/awx/ui_next/src/components/DetailList/Detail.jsx
@@ -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 (
diff --git a/awx/ui_next/src/components/DetailList/DetailList.jsx b/awx/ui_next/src/components/DetailList/DetailList.jsx
index 593fb9eea6..11d55474b3 100644
--- a/awx/ui_next/src/components/DetailList/DetailList.jsx
+++ b/awx/ui_next/src/components/DetailList/DetailList.jsx
@@ -11,6 +11,7 @@ const DetailList = ({ children, stacked, ...props }) => (
export default styled(DetailList)`
display: grid;
grid-gap: 20px;
+ align-items: baseline;
${props =>
props.stacked
? `
diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx
index 67b33f6fc5..0352a7b260 100644
--- a/awx/ui_next/src/screens/Job/Job.jsx
+++ b/awx/ui_next/src/screens/Job/Job.jsx
@@ -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 (
+
+
+
+
+
+ );
+ }
+
return (
{cardHeader}
-
- {job && (
+
+ {job && [
}
- />
- )}
- {job && (
+ key="details"
+ path="/jobs/:type/:id/details"
+ render={() => }
+ />,
}
- />
- )}
+ key="output"
+ path="/jobs/:type/:id/output"
+ render={() => }
+ />,
+ ]}
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
index a4b1ea8651..51e62c6342 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
@@ -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 (
-
- {job.name}
-
-
-
-
-
- );
+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 (
+
+
+ {/* TODO: add status icon? */}
+
+
+
+ {jobTemplate && (
+
+ {jobTemplate.name}
+
+ }
+ />
+ )}
+
+ {inventory && (
+ {inventory.name}
+ }
+ />
+ )}
+ {/* TODO: show project status icon */}
+ {project && (
+ {project.name}}
+ />
+ )}
+
+
+
+
+
+
+ {instanceGroup && (
+
+ {instanceGroup.name}
+
+ }
+ />
+ )}
+ {typeof job.job_slice_number === 'number' &&
+ typeof job.job_slice_count === 'number' && (
+
+ )}
+ {credentials && credentials.length > 0 && (
+
+ {credentials.map(c => (
+
+ ))}
+
+ }
+ />
+ )}
+ {labels && labels.count > 0 && (
+
+ {labels.results.map(l => (
+
+ {l.name}
+
+ ))}
+
+ }
+ />
+ )}
+
+ {job.extra_vars && (
+
+ )}
+ {job.artifacts && (
+
+ )}
+
+
+
+
+ );
}
+JobDetail.propTypes = {
+ job: Job.isRequired,
+};
export default withI18n()(withRouter(JobDetail));
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx
index e8eaef4344..161ec74d1f 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.test.jsx
@@ -1,22 +1,60 @@
import React from 'react';
-
import { mountWithContexts } from '@testUtils/enzymeHelpers';
-
import JobDetail from './JobDetail';
describe('', () => {
- const mockDetails = {
- name: 'Foo',
- };
+ let job;
+
+ beforeEach(() => {
+ job = {
+ name: 'Foo',
+ summary_fields: {},
+ };
+ });
test('initially renders succesfully', () => {
- mountWithContexts();
+ mountWithContexts();
});
test('should display a Close button', () => {
- const wrapper = mountWithContexts();
+ const wrapper = mountWithContexts();
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();
+ 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();
+ const credentialChip = wrapper.find('CredentialChip');
+
+ expect(credentialChip.prop('credential')).toEqual(
+ job.summary_fields.credentials[0]
+ );
+ });
});
diff --git a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx
index 4d2fe70f53..7cfc4dd5ac 100644
--- a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx
+++ b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx
@@ -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 {
-
+
{job.name}
diff --git a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
new file mode 100644
index 0000000000..87738c6ce8
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
@@ -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 Error
;
+ }
+ if (!job) {
+ return Loading...
;
+ }
+ const type = JOB_TYPE_URL_SEGMENTS[job.type];
+ return ;
+ }
+}
+
+export default JobTypeRedirect;
diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx
index fc4311b2bb..9279a9f511 100644
--- a/awx/ui_next/src/screens/Job/Jobs.jsx
+++ b/awx/ui_next/src/screens/Job/Jobs.jsx
@@ -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 {
)}
/>
(
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
)}
/>
+ (
+
+ )}
+ />
);
diff --git a/awx/ui_next/src/screens/Job/constants.js b/awx/ui_next/src/screens/Job/constants.js
new file mode 100644
index 0000000000..b7fa1fc83e
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/constants.js
@@ -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',
+};
diff --git a/awx/ui_next/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap b/awx/ui_next/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap
index 5d523bfb30..c1b9903e68 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap
+++ b/awx/ui_next/src/screens/Organization/OrganizationAccess/__snapshots__/OrganizationAccessItem.test.jsx.snap
@@ -377,9 +377,9 @@ exports[` 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[` initially renders succesfully 1`] = `
stacked={true}
>
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[` initially renders succesfully 1`] = `
stacked={true}
>
- 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={
{summary_fields.credentials.map(c => (
-
-
- {c.kind ? credentialType(c.kind) : i18n._(t`Cloud`)}
- :
-
- {` ${c.name}`}
-
+
))}
}
diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
index 46dd3a5aef..dc945229b7 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
@@ -113,19 +113,21 @@ describe('', () => {
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(
);
- 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]
+ );
});
});
diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js
index ada500bc0c..cedd942859 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -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({}),
});
diff --git a/awx/ui_next/src/util/strings.js b/awx/ui_next/src/util/strings.js
index cc5e00e68e..588bc8b7b6 100644
--- a/awx/ui_next/src/util/strings.js
+++ b/awx/ui_next/src/util/strings.js
@@ -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(' ');
+};
diff --git a/awx/ui_next/src/util/strings.test.js b/awx/ui_next/src/util/strings.test.js
index ec2e6f3c13..b69db2f489 100644
--- a/awx/ui_next/src/util/strings.test.js
+++ b/awx/ui_next/src/util/strings.test.js
@@ -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('');
+ });
+ });
});
diff --git a/awx/ui_next/src/util/yaml.js b/awx/ui_next/src/util/yaml.js
index 22a2cf9815..e4e6cd71b3 100644
--- a/awx/ui_next/src/util/yaml.js
+++ b/awx/ui_next/src/util/yaml.js
@@ -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;
+}