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 {