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' && (