diff --git a/src/api/index.js b/src/api/index.js
index 57429507bb..c87e753f2b 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -7,6 +7,7 @@ import Organizations from './models/Organizations';
import Root from './models/Root';
import Teams from './models/Teams';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
+import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
@@ -19,6 +20,7 @@ const OrganizationsAPI = new Organizations();
const RootAPI = new Root();
const TeamsAPI = new Teams();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
+const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
@@ -32,6 +34,7 @@ export {
RootAPI,
TeamsAPI,
UnifiedJobTemplatesAPI,
+ UnifiedJobsAPI,
UsersAPI,
WorkflowJobTemplatesAPI
};
diff --git a/src/api/models/UnifiedJobs.js b/src/api/models/UnifiedJobs.js
new file mode 100644
index 0000000000..0ad142201c
--- /dev/null
+++ b/src/api/models/UnifiedJobs.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class UnifiedJobs extends Base {
+ constructor (http) {
+ super(http);
+ this.baseUrl = '/api/v2/unified_jobs/';
+ }
+}
+
+export default UnifiedJobs;
diff --git a/src/components/DataListCell/DataListCell.jsx b/src/components/DataListCell/DataListCell.jsx
new file mode 100644
index 0000000000..fdb3c984d8
--- /dev/null
+++ b/src/components/DataListCell/DataListCell.jsx
@@ -0,0 +1,14 @@
+import { DataListCell as PFDataListCell } from '@patternfly/react-core';
+import styled from 'styled-components';
+
+const DataListCell = styled(PFDataListCell)`
+ display: flex;
+ align-items: center;
+ padding-bottom: ${props => (props.righthalf ? '16px' : '8px')};
+ @media screen and (min-width: 768px) {
+ padding-bottom: 0;
+ justify-content: ${props => (props.lastcolumn ? 'flex-end' : 'inherit')};
+ }
+`;
+
+export default DataListCell;
diff --git a/src/components/DataListCell/DataListCell.test.jsx b/src/components/DataListCell/DataListCell.test.jsx
new file mode 100644
index 0000000000..55ec0b3018
--- /dev/null
+++ b/src/components/DataListCell/DataListCell.test.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+
+import DataListCell from './DataListCell';
+
+describe('DataListCell', () => {
+ test('renders without failing', () => {
+ const wrapper = mountWithContexts();
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/src/components/DataListCell/index.js b/src/components/DataListCell/index.js
new file mode 100644
index 0000000000..d925b63c7d
--- /dev/null
+++ b/src/components/DataListCell/index.js
@@ -0,0 +1 @@
+export { default } from './DataListCell';
diff --git a/src/screens/Job/Job.jsx b/src/screens/Job/Job.jsx
index ea3af16373..a64be2a894 100644
--- a/src/screens/Job/Job.jsx
+++ b/src/screens/Job/Job.jsx
@@ -13,7 +13,7 @@ import RoutedTabs from '@components/RoutedTabs';
import JobDetail from './JobDetail';
import JobOutput from './JobOutput';
-export class Job extends Component {
+class Job extends Component {
constructor (props) {
super(props);
@@ -24,22 +24,22 @@ export class Job extends Component {
isInitialized: false
};
- this.fetchJob = this.fetchJob.bind(this);
+ this.loadJob = this.loadJob.bind(this);
}
async componentDidMount () {
- await this.fetchJob();
+ await this.loadJob();
this.setState({ isInitialized: true });
}
async componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
- await this.fetchJob();
+ await this.loadJob();
}
}
- async fetchJob () {
+ async loadJob () {
const {
match,
setBreadcrumb,
@@ -153,3 +153,4 @@ export class Job extends Component {
}
export default withI18n()(withRouter(Job));
+export { Job as _Job };
diff --git a/src/screens/Job/JobList/JobList.jsx b/src/screens/Job/JobList/JobList.jsx
new file mode 100644
index 0000000000..ed581b6057
--- /dev/null
+++ b/src/screens/Job/JobList/JobList.jsx
@@ -0,0 +1,182 @@
+import React, { Component } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import {
+ Card,
+ PageSection,
+ PageSectionVariants,
+} from '@patternfly/react-core';
+
+import { UnifiedJobsAPI } from '@api';
+import AlertModal from '@components/AlertModal';
+import DatalistToolbar from '@components/DataListToolbar';
+import PaginatedDataList, {
+ ToolbarDeleteButton
+} from '@components/PaginatedDataList';
+import { getQSConfig, parseNamespacedQueryString } from '@util/qs';
+
+import JobListItem from './JobListItem';
+
+const QS_CONFIG = getQSConfig('job', {
+ page: 1,
+ page_size: 20,
+ order_by: '-finished',
+ not__launch_type: 'sync',
+});
+
+class JobList extends Component {
+ constructor (props) {
+ super(props);
+
+ this.state = {
+ hasContentLoading: true,
+ hasContentError: false,
+ deletionError: false,
+ selected: [],
+ jobs: [],
+ itemCount: 0,
+ };
+ this.loadJobs = this.loadJobs.bind(this);
+ this.handleSelectAll = this.handleSelectAll.bind(this);
+ this.handleSelect = this.handleSelect.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
+ this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
+ }
+
+ componentDidMount () {
+ this.loadJobs();
+ }
+
+ componentDidUpdate (prevProps) {
+ const { location } = this.props;
+ if (location !== prevProps.location) {
+ this.loadJobs();
+ }
+ }
+
+ handleDeleteErrorClose () {
+ this.setState({ deletionError: false });
+ }
+
+ handleSelectAll (isSelected) {
+ const { jobs } = this.state;
+ const selected = isSelected ? [...jobs] : [];
+ this.setState({ selected });
+ }
+
+ handleSelect (item) {
+ const { selected } = this.state;
+ if (selected.some(s => s.id === item.id)) {
+ this.setState({ selected: selected.filter(s => s.id !== item.id) });
+ } else {
+ this.setState({ selected: selected.concat(item) });
+ }
+ }
+
+ async handleDelete () {
+ const { selected } = this.state;
+ this.setState({ hasContentLoading: true, deletionError: false });
+ try {
+ await Promise.all(selected.map(({ id }) => UnifiedJobsAPI.destroy(id)));
+ } catch (err) {
+ this.setState({ deletionError: true });
+ } finally {
+ await this.loadJobs();
+ }
+ }
+
+ async loadJobs () {
+ const { location } = this.props;
+ const params = parseNamespacedQueryString(QS_CONFIG, location.search);
+
+ this.setState({ hasContentError: false, hasContentLoading: true });
+ try {
+ const { data: { count, results } } = await UnifiedJobsAPI.read(params);
+ this.setState({
+ itemCount: count,
+ jobs: results,
+ selected: [],
+ });
+ } catch (err) {
+ this.setState({ hasContentError: true });
+ } finally {
+ this.setState({ hasContentLoading: false });
+ }
+ }
+
+ render () {
+ const {
+ hasContentError,
+ hasContentLoading,
+ deletionError,
+ jobs,
+ itemCount,
+ selected,
+ } = this.state;
+ const {
+ match,
+ i18n
+ } = this.props;
+ const { medium } = PageSectionVariants;
+ const isAllSelected = selected.length === jobs.length;
+ const itemName = i18n._(t`Job`);
+ return (
+
+
+ (
+
+ ]}
+ />
+ )}
+ renderItem={(job) => (
+ this.handleSelect(job)}
+ isSelected={selected.some(row => row.id === job.id)}
+ />
+ )}
+ />
+
+
+ {i18n._(t`Failed to delete one or more jobs.`)}
+
+
+ );
+ }
+}
+
+export { JobList as _JobList };
+export default withI18n()(withRouter(JobList));
diff --git a/src/screens/Job/JobList/JobList.test.jsx b/src/screens/Job/JobList/JobList.test.jsx
new file mode 100644
index 0000000000..dffdf9930b
--- /dev/null
+++ b/src/screens/Job/JobList/JobList.test.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+
+import { UnifiedJobsAPI } from '@api';
+import JobList from './JobList';
+
+jest.mock('@api');
+
+const mockResults = [{
+ id: 1,
+ url: '/api/v2/project_updates/1',
+ name: 'job 1',
+ type: 'project update',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ }
+ }
+}, {
+ id: 2,
+ url: '/api/v2/jobs/2',
+ name: 'job 2',
+ type: 'job',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ }
+ }
+}, {
+ id: 3,
+ url: '/api/v2/jobs/3',
+ name: 'job 3',
+ type: 'job',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ }
+ }
+}];
+
+UnifiedJobsAPI.read.mockResolvedValue({ data: { count: 3, results: mockResults } });
+
+describe('', () => {
+ test('initially renders succesfully', async (done) => {
+ const wrapper = mountWithContexts();
+ await waitForElement(wrapper, 'JobList', (el) => el.state('jobs').length === 3);
+
+ done();
+ });
+
+ test('select makes expected state updates', async (done) => {
+ const [mockItem] = mockResults;
+ const wrapper = mountWithContexts();
+ await waitForElement(wrapper, 'JobListItem', (el) => el.length === 3);
+
+ wrapper.find('JobListItem').first().prop('onSelect')(mockItem);
+ expect(wrapper.find('JobList').state('selected').length).toEqual(1);
+
+ wrapper.find('JobListItem').first().prop('onSelect')(mockItem);
+ expect(wrapper.find('JobList').state('selected').length).toEqual(0);
+
+ done();
+ });
+
+ test('select-all-delete makes expected state updates and api calls', async (done) => {
+ const wrapper = mountWithContexts();
+ await waitForElement(wrapper, 'JobListItem', (el) => el.length === 3);
+
+ wrapper.find('DataListToolbar').prop('onSelectAll')(true);
+ expect(wrapper.find('JobList').state('selected').length).toEqual(3);
+
+ wrapper.find('DataListToolbar').prop('onSelectAll')(false);
+ expect(wrapper.find('JobList').state('selected').length).toEqual(0);
+
+ wrapper.find('DataListToolbar').prop('onSelectAll')(true);
+ expect(wrapper.find('JobList').state('selected').length).toEqual(3);
+
+ wrapper.find('ToolbarDeleteButton').prop('onDelete')();
+ expect(UnifiedJobsAPI.destroy).toHaveBeenCalledTimes(3);
+
+ done();
+ });
+});
diff --git a/src/screens/Job/JobList/JobListItem.jsx b/src/screens/Job/JobList/JobListItem.jsx
new file mode 100644
index 0000000000..eedf1c7d11
--- /dev/null
+++ b/src/screens/Job/JobList/JobListItem.jsx
@@ -0,0 +1,53 @@
+import React, { Component } from 'react';
+import { Link } from 'react-router-dom';
+import {
+ DataListItem,
+ DataListItemRow,
+ DataListItemCells,
+ DataListCheck,
+} from '@patternfly/react-core';
+
+import DataListCell from '@components/DataListCell';
+import VerticalSeparator from '@components/VerticalSeparator';
+import { toTitleCase } from '@util/strings';
+
+class JobListItem extends Component {
+ render () {
+ const {
+ job,
+ isSelected,
+ onSelect,
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ {job.name}
+
+
+ ,
+ {toTitleCase(job.type)},
+ {job.finished},
+ ]}
+ />
+
+
+ );
+ }
+}
+export { JobListItem as _JobListItem };
+export default JobListItem;
diff --git a/src/screens/Job/JobList/JobListItem.test.jsx b/src/screens/Job/JobList/JobListItem.test.jsx
new file mode 100644
index 0000000000..8150d7803c
--- /dev/null
+++ b/src/screens/Job/JobList/JobListItem.test.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { createMemoryHistory } from 'history';
+
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+
+import JobListItem from './JobListItem';
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/jobs'],
+ });
+ mountWithContexts(
+ {}}
+ />,
+ { context: { router: { history } } }
+ );
+ });
+});
diff --git a/src/screens/Job/JobList/index.js b/src/screens/Job/JobList/index.js
new file mode 100644
index 0000000000..5b0caebb8a
--- /dev/null
+++ b/src/screens/Job/JobList/index.js
@@ -0,0 +1,2 @@
+export { default as JobList } from './JobList';
+export { default as JobListItem } from './JobListItem';
diff --git a/src/screens/Job/Jobs.jsx b/src/screens/Job/Jobs.jsx
index f7a61b82c0..6f4a1208a8 100644
--- a/src/screens/Job/Jobs.jsx
+++ b/src/screens/Job/Jobs.jsx
@@ -5,7 +5,8 @@ import { t } from '@lingui/macro';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
-import { Job } from '.';
+import Job from './Job';
+import JobList from './JobList/JobList';
class Jobs extends Component {
constructor (props) {
@@ -47,6 +48,17 @@ class Jobs extends Component {
breadcrumbConfig={breadcrumbConfig}
/>
+ (
+
+ )}
+ />
(
@@ -63,4 +75,5 @@ class Jobs extends Component {
}
}
+export { Jobs as _Jobs };
export default withI18n()(withRouter(Jobs));
diff --git a/src/screens/Organization/OrganizationList/OrganizationListItem.jsx b/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
index 803e423a5e..ee785ba257 100644
--- a/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
+++ b/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
@@ -8,13 +8,13 @@ import {
DataListItemRow,
DataListItemCells,
DataListCheck,
- DataListCell as PFDataListCell,
} from '@patternfly/react-core';
import {
Link
} from 'react-router-dom';
import styled from 'styled-components';
+import DataListCell from '@components/DataListCell';
import VerticalSeparator from '@components/VerticalSeparator';
import { Organization } from '@types';
@@ -38,16 +38,6 @@ const ListGroup = styled.span`
}
`;
-const DataListCell = styled(PFDataListCell)`
- display: flex;
- align-items: center;
- padding-bottom: ${props => (props.righthalf ? '16px' : '8px')};
-
- @media screen and (min-width: 768px) {
- padding-bottom: 0;
- }
-`;
-
class OrganizationListItem extends React.Component {
static propTypes = {
organization: Organization.isRequired,
diff --git a/src/screens/Template/TemplateList/TemplateListItem.jsx b/src/screens/Template/TemplateList/TemplateListItem.jsx
index 0937a0e584..c745b61a15 100644
--- a/src/screens/Template/TemplateList/TemplateListItem.jsx
+++ b/src/screens/Template/TemplateList/TemplateListItem.jsx
@@ -5,23 +5,13 @@ import {
DataListItemRow,
DataListItemCells,
DataListCheck,
- DataListCell as PFDataListCell,
} from '@patternfly/react-core';
-import styled from 'styled-components';
-import VerticalSeparator from '@components/VerticalSeparator';
+import DataListCell from '@components/DataListCell';
import LaunchButton from '@components/LaunchButton';
+import VerticalSeparator from '@components/VerticalSeparator';
import { toTitleCase } from '@util/strings';
-const DataListCell = styled(PFDataListCell)`
- display: flex;
- align-items: center;
- @media screen and (min-width: 768px) {
- padding-bottom: 0;
- justify-content: ${props => (props.lastcolumn ? 'flex-end' : 'inherit')};
- }
-`;
-
class TemplateListItem extends Component {
render () {
const {