diff --git a/awx/ui/client/features/projects/projectsList.controller.js b/awx/ui/client/features/projects/projectsList.controller.js index a295d6e52b..66605f8f13 100644 --- a/awx/ui/client/features/projects/projectsList.controller.js +++ b/awx/ui/client/features/projects/projectsList.controller.js @@ -353,7 +353,7 @@ function projectsListController ( }; function buildTooltips (project) { - project.statusIcon = getStatusIcon(project); + project.statusIcon = getJobStatusIcon(project); project.statusTip = getStatusTooltip(project); project.scm_update_tooltip = vm.strings.get('update.GET_LATEST'); project.scm_update_disabled = false; @@ -408,7 +408,7 @@ function projectsListController ( }; } - function getStatusIcon (project) { + function getJobStatusIcon (project) { let icon = 'none'; switch (project.status) { case 'n/a': diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx new file mode 100644 index 0000000000..8a462f72ed --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { string } from 'prop-types'; +import styled, { keyframes } from 'styled-components'; + +const Pulse = keyframes` + from { + -webkit-transform:scale(1); + } + to { + -webkit-transform:scale(0); + } +`; + +const Wrapper = styled.div` + width: 14px; + height: 14px; +`; + +const RunningJob = styled(Wrapper)` + background-color: #5cb85c; + padding-right: 0px; + text-shadow: -1px -1px 0 #ffffff, 1px -1px 0 #ffffff, -1px 1px 0 #ffffff, + 1px 1px 0 #ffffff; + animation: ${Pulse} 1.5s linear infinite alternate; +`; + +const WaitingJob = styled(Wrapper)` + border: 1px solid #d7d7d7; +`; + +const FinishedJob = styled(Wrapper)` + flex: 0 1 auto; + > * { + width: 14px; + height: 7px; + } +`; + +const SuccessfulTop = styled.div` + background-color: #5cb85c; +`; + +const SuccessfulBottom = styled.div` + border: 1px solid #b7b7b7; + border-top: 0; + background: #ffffff; +`; + +const FailedTop = styled.div` + border: 1px solid #b7b7b7; + border-bottom: 0; + background: #ffffff; +`; + +const FailedBottom = styled.div` + background-color: #d9534f; +`; + +const JobStatusIcon = ({ status, ...props }) => { + return ( +
+ {status === 'running' && } + {(status === 'new' || status === 'pending' || status === 'waiting') && ( + + )} + {(status === 'failed' || status === 'error' || status === 'canceled') && ( + + + + + )} + {status === 'successful' && ( + + + + + )} +
+ ); +}; + +JobStatusIcon.propTypes = { + status: string.isRequired, +}; + +export default JobStatusIcon; diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx new file mode 100644 index 0000000000..204d305394 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import JobStatusIcon from './JobStatusIcon'; + +describe('JobStatusIcon', () => { + test('renders the successful job', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('JobStatusIcon__SuccessfulTop')).toHaveLength(1); + expect(wrapper.find('JobStatusIcon__SuccessfulBottom')).toHaveLength(1); + }); + test('renders running job', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('JobStatusIcon__RunningJob')).toHaveLength(1); + }); + test('renders waiting job', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('JobStatusIcon__WaitingJob')).toHaveLength(1); + }); + test('renders failed job', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('JobStatusIcon__FailedTop')).toHaveLength(1); + expect(wrapper.find('JobStatusIcon__FailedBottom')).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.jsx new file mode 100644 index 0000000000..5291eeca85 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/Sparkline.jsx @@ -0,0 +1,50 @@ +import React, { Fragment } from 'react'; +import { arrayOf, object } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { Link as _Link } from 'react-router-dom'; +import { JobStatusIcon } from '@components/Sparkline'; +import { Tooltip } from '@patternfly/react-core'; +import styled from 'styled-components'; +import { t } from '@lingui/macro'; +import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; + +/* eslint-disable react/jsx-pascal-case */ +const Link = styled(props => <_Link {...props} />)` + margin-right: 5px; +`; +/* eslint-enable react/jsx-pascal-case */ + +const Sparkline = ({ i18n, jobs }) => { + const generateTooltip = job => ( + +
+ {i18n._(t`JOB ID:`)} {job.id} +
+
+ {i18n._(t`STATUS:`)} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {i18n._(t`FINISHED:`)} {job.finished} +
+ )} +
+ ); + + return jobs.map(job => ( + + + + + + )); +}; + +Sparkline.propTypes = { + jobs: arrayOf(object), +}; +Sparkline.defaultProps = { + jobs: [], +}; + +export default withI18n()(Sparkline); diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx new file mode 100644 index 0000000000..e39003a841 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import Sparkline from './Sparkline'; + +describe('Sparkline', () => { + test('renders the expected content', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + }); + test('renders an icon with tooltips and links for each job', () => { + const jobs = [ + { + id: 1, + status: 'successful', + finished: '2019-08-08T15:27:57.320120Z', + }, + { + id: 2, + status: 'failed', + finished: '2019-08-09T15:27:57.320120Z', + }, + ]; + const wrapper = mountWithContexts(); + expect(wrapper.find('JobStatusIcon')).toHaveLength(2); + expect(wrapper.find('Tooltip')).toHaveLength(2); + expect(wrapper.find('Link')).toHaveLength(2); + }); +}); diff --git a/awx/ui_next/src/components/Sparkline/index.js b/awx/ui_next/src/components/Sparkline/index.js new file mode 100644 index 0000000000..f33b83ae6c --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/index.js @@ -0,0 +1,2 @@ +export { default as Sparkline } from './Sparkline'; +export { default as JobStatusIcon } from './JobStatusIcon'; diff --git a/awx/ui_next/src/screens/Job/constants.js b/awx/ui_next/src/constants.js similarity index 100% rename from awx/ui_next/src/screens/Job/constants.js rename to awx/ui_next/src/constants.js diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx index 0352a7b260..313f051cad 100644 --- a/awx/ui_next/src/screens/Job/Job.jsx +++ b/awx/ui_next/src/screens/Job/Job.jsx @@ -15,7 +15,7 @@ import RoutedTabs from '@components/RoutedTabs'; import JobDetail from './JobDetail'; import JobOutput from './JobOutput'; -import { JOB_TYPE_URL_SEGMENTS } from './constants'; +import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; class Job extends Component { constructor(props) { diff --git a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx index 7cfc4dd5ac..93c67e7070 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx @@ -9,7 +9,7 @@ import { import DataListCell from '@components/DataListCell'; import VerticalSeparator from '@components/VerticalSeparator'; import { toTitleCase } from '@util/strings'; -import { JOB_TYPE_URL_SEGMENTS } from '../constants'; +import { JOB_TYPE_URL_SEGMENTS } from '../../../constants'; class JobListItem extends Component { render() { diff --git a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx index 87738c6ce8..20ba8b1bc0 100644 --- a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx +++ b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import { Redirect } from 'react-router-dom'; import { UnifiedJobsAPI } from '@api'; -import { JOB_TYPE_URL_SEGMENTS } from './constants'; +import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; class JobTypeRedirect extends Component { static defaultProps = { diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx index 9279a9f511..8c86472a10 100644 --- a/awx/ui_next/src/screens/Job/Jobs.jsx +++ b/awx/ui_next/src/screens/Job/Jobs.jsx @@ -6,7 +6,7 @@ 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'; +import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; class Jobs extends Component { constructor(props) { diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx index bb31945c46..964cc397d2 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx @@ -16,6 +16,7 @@ import styled from 'styled-components'; import DataListCell from '@components/DataListCell'; import LaunchButton from '@components/LaunchButton'; import VerticalSeparator from '@components/VerticalSeparator'; +import { Sparkline } from '@components/Sparkline'; import { toTitleCase } from '@util/strings'; const StyledButton = styled(PFButton)` @@ -56,6 +57,9 @@ class TemplateListItem extends Component { {toTitleCase(template.type)} , + + + , {canLaunch && template.type === 'job_template' && (