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