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 = ( - - -
- - -
-
- + + + );