diff --git a/__tests__/pages/Jobs.test.jsx b/__tests__/pages/Jobs.test.jsx
deleted file mode 100644
index 82fbe1867e..0000000000
--- a/__tests__/pages/Jobs.test.jsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import { mountWithContexts } from '../enzymeHelpers';
-import Jobs from '../../src/pages/Jobs';
-
-describe('', () => {
- let pageWrapper;
- let pageSections;
- let title;
-
- beforeEach(() => {
- pageWrapper = mountWithContexts();
- pageSections = pageWrapper.find('PageSection');
- title = pageWrapper.find('Title');
- });
-
- afterEach(() => {
- pageWrapper.unmount();
- });
-
- test('initially renders without crashing', () => {
- expect(pageWrapper.length).toBe(1);
- expect(pageSections.length).toBe(2);
- expect(title.length).toBe(1);
- expect(title.props().size).toBe('2xl');
- pageSections.forEach(section => {
- expect(section.props().variant).toBeDefined();
- });
- });
-});
diff --git a/__tests__/pages/Jobs/Jobs.test.jsx b/__tests__/pages/Jobs/Jobs.test.jsx
new file mode 100644
index 0000000000..8e25ce6220
--- /dev/null
+++ b/__tests__/pages/Jobs/Jobs.test.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { createMemoryHistory } from 'history';
+import { mountWithContexts } from '../../enzymeHelpers';
+import Jobs from '../../../src/pages/Jobs';
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts(
+
+ );
+ });
+
+ test('should display a breadcrumb heading', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/jobs'],
+ });
+ const match = { path: '/jobs', url: '/jobs', isExact: true };
+
+ const wrapper = mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match
+ }
+ }
+ }
+ }
+ );
+ expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/__tests__/pages/Jobs/screens/Job/Job.test.jsx b/__tests__/pages/Jobs/screens/Job/Job.test.jsx
new file mode 100644
index 0000000000..780fe26ad9
--- /dev/null
+++ b/__tests__/pages/Jobs/screens/Job/Job.test.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../enzymeHelpers';
+import { Job } from '../../../../../src/pages/Jobs';
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts();
+ });
+});
diff --git a/__tests__/pages/Jobs/screens/Job/JobDetail.test.jsx b/__tests__/pages/Jobs/screens/Job/JobDetail.test.jsx
new file mode 100644
index 0000000000..6f5e76e71f
--- /dev/null
+++ b/__tests__/pages/Jobs/screens/Job/JobDetail.test.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../enzymeHelpers';
+import JobDetail from '../../../../../src/pages/Jobs/JobDetail';
+
+describe('', () => {
+ const mockDetails = {
+ name: 'Foo'
+ };
+
+ test('initially renders succesfully', () => {
+ mountWithContexts(
+
+ );
+ });
+
+ test('should display a Close button', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/__tests__/pages/Jobs/screens/Job/JobOutput.test.jsx b/__tests__/pages/Jobs/screens/Job/JobOutput.test.jsx
new file mode 100644
index 0000000000..e92fa37fc7
--- /dev/null
+++ b/__tests__/pages/Jobs/screens/Job/JobOutput.test.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../enzymeHelpers';
+import JobOutput from '../../../../../src/pages/Jobs/JobOutput';
+
+describe('', () => {
+ const mockDetails = {
+ name: 'Foo'
+ };
+
+ test('initially renders succesfully', () => {
+ mountWithContexts(
+
+ );
+ });
+});
diff --git a/src/api/index.js b/src/api/index.js
index 018ae790d4..418b150b04 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -1,5 +1,6 @@
import Config from './models/Config';
import InstanceGroups from './models/InstanceGroups';
+import Jobs from './models/Jobs';
import Me from './models/Me';
import Organizations from './models/Organizations';
import Root from './models/Root';
@@ -9,6 +10,7 @@ import Users from './models/Users';
const ConfigAPI = new Config();
const InstanceGroupsAPI = new InstanceGroups();
+const JobsAPI = new Jobs();
const MeAPI = new Me();
const OrganizationsAPI = new Organizations();
const RootAPI = new Root();
@@ -19,6 +21,7 @@ const UsersAPI = new Users();
export {
ConfigAPI,
InstanceGroupsAPI,
+ JobsAPI,
MeAPI,
OrganizationsAPI,
RootAPI,
diff --git a/src/api/models/Jobs.js b/src/api/models/Jobs.js
new file mode 100644
index 0000000000..cea169b72a
--- /dev/null
+++ b/src/api/models/Jobs.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class Jobs extends Base {
+ constructor (http) {
+ super(http);
+ this.baseUrl = '/api/v2/jobs/';
+ }
+}
+
+export default Jobs;
diff --git a/src/app.scss b/src/app.scss
index 05fef0b208..b15ebc0a61 100644
--- a/src/app.scss
+++ b/src/app.scss
@@ -203,15 +203,6 @@
text-align: right;
}
-.awx-orgTabs-container{
- display: flex
-}
-
-.awx-orgTabs__bottom-border{
- flex-grow: 1;
- border-bottom: 1px solid #d2d2d2
-}
-
//
// AlertModal styles
//
diff --git a/src/components/Tabs/RoutedTabs.jsx b/src/components/Tabs/RoutedTabs.jsx
index 4100e766b6..2e2b5b4991 100644
--- a/src/components/Tabs/RoutedTabs.jsx
+++ b/src/components/Tabs/RoutedTabs.jsx
@@ -1,7 +1,35 @@
import React from 'react';
import { shape, string, number, arrayOf } from 'prop-types';
-import { Tab, Tabs } from '@patternfly/react-core';
+import { Tab, Tabs as PFTabs } from '@patternfly/react-core';
import { withRouter } from 'react-router-dom';
+import styled from 'styled-components';
+
+const Tabs = styled(PFTabs)`
+ --pf-c-tabs__button--PaddingLeft: 20px;
+ --pf-c-tabs__button--PaddingRight: 20px;
+
+ .pf-c-tabs__list {
+ li:first-of-type .pf-c-tabs__button {
+ &::before {
+ border-left: none;
+ }
+ &::after {
+ margin-left: 0;
+ }
+ }
+ }
+
+ &::before {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ content: "";
+ border: solid var(--pf-c-tabs__item--BorderColor);
+ border-width: var(--pf-c-tabs__item--BorderWidth) 0 var(--pf-c-tabs__item--BorderWidth) 0;
+ }
+`;
function RoutedTabs (props) {
const { history, tabsArray } = props;
diff --git a/src/pages/Jobs.jsx b/src/pages/Jobs.jsx
deleted file mode 100644
index e78e62cfbd..0000000000
--- a/src/pages/Jobs.jsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React, { Component, Fragment } from 'react';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import {
- PageSection,
- PageSectionVariants,
- Title,
-} from '@patternfly/react-core';
-
-class Jobs extends Component {
- render () {
- const { i18n } = this.props;
- const { light, medium } = PageSectionVariants;
-
- return (
-
-
-
- {i18n._(t`Jobs`)}
-
-
-
-
- );
- }
-}
-
-export default withI18n()(Jobs);
diff --git a/src/pages/Jobs/Job.jsx b/src/pages/Jobs/Job.jsx
new file mode 100644
index 0000000000..69c093fe6e
--- /dev/null
+++ b/src/pages/Jobs/Job.jsx
@@ -0,0 +1,153 @@
+import React, { Component } from 'react';
+import { Route, withRouter, Switch, Redirect } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import { Card, CardHeader as PFCardHeader, PageSection } from '@patternfly/react-core';
+import { JobsAPI } from '../../api';
+import ContentError from '../../components/ContentError';
+import CardCloseButton from '../../components/CardCloseButton';
+import RoutedTabs from '../../components/Tabs/RoutedTabs';
+import JobDetail from './JobDetail';
+import JobOutput from './JobOutput';
+
+export class Job extends Component {
+ constructor (props) {
+ super(props);
+
+ this.state = {
+ job: null,
+ contentError: false,
+ contentLoading: true,
+ isInitialized: false
+ };
+
+ this.fetchJob = this.fetchJob.bind(this);
+ }
+
+ async componentDidMount () {
+ await this.fetchJob();
+ this.setState({ isInitialized: true });
+ }
+
+ async componentDidUpdate (prevProps) {
+ const { location } = this.props;
+ if (location !== prevProps.location) {
+ await this.fetchJob();
+ }
+ }
+
+ async fetchJob () {
+ const {
+ match,
+ setBreadcrumb,
+ } = this.props;
+ const id = parseInt(match.params.id, 10);
+
+ this.setState({ contentError: false, contentLoading: true });
+ try {
+ const { data } = await JobsAPI.readDetail(id);
+ setBreadcrumb(data);
+ this.setState({ job: data });
+ } catch (error) {
+ this.setState({ contentError: true });
+ } finally {
+ this.setState({ contentLoading: false });
+ }
+ }
+
+ render () {
+ const {
+ history,
+ match,
+ i18n
+ } = this.props;
+
+ const {
+ job,
+ contentError,
+ contentLoading,
+ isInitialized
+ } = this.state;
+
+ const tabsArray = [
+ { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
+ { name: i18n._(t`Output`), link: `${match.url}/output`, id: 1 }
+ ];
+
+ const CardHeader = styled(PFCardHeader)`
+ --pf-c-card--first-child--PaddingTop: 0;
+ --pf-c-card--child--PaddingLeft: 0;
+ --pf-c-card--child--PaddingRight: 0;
+ position: relative;
+ `;
+
+ let cardHeader = (
+
+
+
+
+ );
+
+ if (!isInitialized) {
+ cardHeader = null;
+ }
+
+ if (!match) {
+ cardHeader = null;
+ }
+
+ if (!contentLoading && contentError) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ { cardHeader }
+
+
+ {job && (
+ (
+
+ )}
+ />
+ )}
+ {job && (
+ (
+
+ )}
+ />
+ )}
+
+
+
+ );
+ }
+}
+
+export default withI18n()(withRouter(Job));
diff --git a/src/pages/Jobs/JobDetail/JobDetail.jsx b/src/pages/Jobs/JobDetail/JobDetail.jsx
new file mode 100644
index 0000000000..fa6522382c
--- /dev/null
+++ b/src/pages/Jobs/JobDetail/JobDetail.jsx
@@ -0,0 +1,38 @@
+import React, { Component } 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';
+
+const ActionButtonWrapper = styled.div`
+ display: flex;
+ justify-content: flex-end;
+`;
+class JobDetail extends Component {
+ render () {
+ const {
+ job,
+ i18n
+ } = this.props;
+
+ return (
+
+ {job.name}
+
+
+
+
+
+ );
+ }
+}
+
+export default withI18n()(withRouter(JobDetail));
diff --git a/src/pages/Jobs/JobDetail/index.js b/src/pages/Jobs/JobDetail/index.js
new file mode 100644
index 0000000000..a13fb1cebc
--- /dev/null
+++ b/src/pages/Jobs/JobDetail/index.js
@@ -0,0 +1,4 @@
+import JobDetail from './JobDetail';
+
+export default JobDetail;
+
diff --git a/src/pages/Jobs/JobOutput/JobOutput.jsx b/src/pages/Jobs/JobOutput/JobOutput.jsx
new file mode 100644
index 0000000000..dc8ae07947
--- /dev/null
+++ b/src/pages/Jobs/JobOutput/JobOutput.jsx
@@ -0,0 +1,18 @@
+import React, { Component } from 'react';
+import { CardBody } from '@patternfly/react-core';
+
+class JobOutput extends Component {
+ render () {
+ const {
+ job
+ } = this.props;
+
+ return (
+
+ {job.name}
+
+ );
+ }
+}
+
+export default JobOutput;
diff --git a/src/pages/Jobs/JobOutput/index.js b/src/pages/Jobs/JobOutput/index.js
new file mode 100644
index 0000000000..8d482ce336
--- /dev/null
+++ b/src/pages/Jobs/JobOutput/index.js
@@ -0,0 +1,4 @@
+import JobOutput from './JobOutput';
+
+export default JobOutput;
+
diff --git a/src/pages/Jobs/Jobs.jsx b/src/pages/Jobs/Jobs.jsx
new file mode 100644
index 0000000000..125d94d770
--- /dev/null
+++ b/src/pages/Jobs/Jobs.jsx
@@ -0,0 +1,64 @@
+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 '.';
+
+class Jobs extends Component {
+ constructor (props) {
+ super(props);
+
+ const { i18n } = props;
+
+ this.state = {
+ breadcrumbConfig: {
+ '/jobs': i18n._(t`Jobs`)
+ }
+ };
+ }
+
+ setBreadcrumbConfig = (job) => {
+ const { i18n } = this.props;
+
+ if (!job) {
+ return;
+ }
+
+ 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`)
+ };
+
+ this.setState({ breadcrumbConfig });
+ }
+
+ render () {
+ const { match, history, location } = this.props;
+ const { breadcrumbConfig } = this.state;
+
+ return (
+
+
+
+ (
+
+ )}
+ />
+
+
+ );
+ }
+}
+
+export default withI18n()(withRouter(Jobs));
diff --git a/src/pages/Jobs/index.js b/src/pages/Jobs/index.js
new file mode 100644
index 0000000000..7a03fd5819
--- /dev/null
+++ b/src/pages/Jobs/index.js
@@ -0,0 +1,2 @@
+export { default as Job } from './Job';
+export { default } from './Jobs';
diff --git a/src/pages/Organizations/screens/Organization/Organization.jsx b/src/pages/Organizations/screens/Organization/Organization.jsx
index 71100980f7..1e0a1a7bc3 100644
--- a/src/pages/Organizations/screens/Organization/Organization.jsx
+++ b/src/pages/Organizations/screens/Organization/Organization.jsx
@@ -2,7 +2,8 @@ import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect } from 'react-router-dom';
-import { Card, CardHeader, PageSection } from '@patternfly/react-core';
+import { Card, CardHeader as PFCardHeader, PageSection } from '@patternfly/react-core';
+import styled from 'styled-components';
import CardCloseButton from '../../../../components/CardCloseButton';
import ContentError from '../../../../components/ContentError';
import OrganizationAccess from './OrganizationAccess';
@@ -130,22 +131,21 @@ class Organization extends Component {
});
}
+ const CardHeader = styled(PFCardHeader)`
+ --pf-c-card--first-child--PaddingTop: 0;
+ --pf-c-card--child--PaddingLeft: 0;
+ --pf-c-card--child--PaddingRight: 0;
+ position: relative;
+ `;
+
let cardHeader = (
-
-
-
-
+
+
+
);