diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.jsx index dc1fe1922a..ac87305218 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobList.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobList.jsx @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; @@ -19,6 +19,7 @@ import ErrorDetail from '@components/ErrorDetail'; import PaginatedDataList, { ToolbarDeleteButton, } from '@components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '@util/useRequest'; import { getQSConfig, parseQueryString } from '@util/qs'; import JobListItem from './JobListItem'; @@ -34,257 +35,217 @@ const QS_CONFIG = getQSConfig( ['page', 'page_size', 'id'] ); -class JobList extends Component { - constructor(props) { - super(props); +function JobList({ i18n }) { + const [selected, setSelected] = useState([]); + const location = useLocation(); - this.state = { - hasContentLoading: true, - deletionError: null, - contentError: null, - selected: [], - jobs: [], - itemCount: 0, - }; - this.loadJobs = this.loadJobs.bind(this); - this.handleSelectAll = this.handleSelectAll.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleJobDelete = this.handleJobDelete.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: null }); - } - - 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 handleJobDelete() { - const { selected, itemCount } = this.state; - this.setState({ hasContentLoading: true }); - try { - await Promise.all( - selected.map(({ type, id }) => { - let deletePromise; - switch (type) { - case 'job': - deletePromise = JobsAPI.destroy(id); - break; - case 'ad_hoc_command': - deletePromise = AdHocCommandsAPI.destroy(id); - break; - case 'system_job': - deletePromise = SystemJobsAPI.destroy(id); - break; - case 'project_update': - deletePromise = ProjectUpdatesAPI.destroy(id); - break; - case 'inventory_update': - deletePromise = InventoryUpdatesAPI.destroy(id); - break; - case 'workflow_job': - deletePromise = WorkflowJobsAPI.destroy(id); - break; - default: - break; - } - return deletePromise; - }) - ); - this.setState({ itemCount: itemCount - selected.length }); - } catch (err) { - this.setState({ deletionError: err }); - } finally { - await this.loadJobs(); - } - } - - async loadJobs() { - const { location } = this.props; - const params = parseQueryString(QS_CONFIG, location.search); - - this.setState({ contentError: null, hasContentLoading: true }); - try { + const { + result: { jobs, itemCount }, + error: contentError, + isLoading, + request: fetchJobs, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); const { data: { count, results }, } = await UnifiedJobsAPI.read(params); - this.setState({ + return { itemCount: count, jobs: results, - selected: [], - }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); + }; + }, [location]), + { + jobs: [], + itemCount: 0, } - } + ); - render() { - const { - contentError, - hasContentLoading, - deletionError, - jobs, - itemCount, - selected, - } = this.state; - const { match, i18n } = this.props; - const isAllSelected = - selected.length === jobs.length && selected.length > 0; - return ( - - - ( - , - ]} - /> - )} - renderItem={job => ( - this.handleSelect(job)} - isSelected={selected.some(row => row.id === job.id)} - /> - )} - /> - - - {i18n._(t`Failed to delete one or more jobs.`)} - - - - ); - } + useEffect(() => { + fetchJobs(); + }, [fetchJobs]); + + const isAllSelected = selected.length === jobs.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteJobs, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ type, id }) => { + switch (type) { + case 'job': + return JobsAPI.destroy(id); + case 'ad_hoc_command': + return AdHocCommandsAPI.destroy(id); + case 'system_job': + return SystemJobsAPI.destroy(id); + case 'project_update': + return ProjectUpdatesAPI.destroy(id); + case 'inventory_update': + return InventoryUpdatesAPI.destroy(id); + case 'workflow_job': + return WorkflowJobsAPI.destroy(id); + default: + return null; + } + }) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchJobs, + } + ); + + const handleJobDelete = async () => { + await deleteJobs(); + setSelected([]); + }; + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...jobs] : []); + }; + + const handleSelect = item => { + if (selected.some(s => s.id === item.id)) { + setSelected(selected.filter(s => s.id !== item.id)); + } else { + setSelected(selected.concat(item)); + } + }; + + return ( + + + ( + , + ]} + /> + )} + renderItem={job => ( + 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)); +// export { JobList as _JobList }; +export default withI18n()(JobList); diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx index 7c1654dcd4..7379b4079a 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; - import { AdHocCommandsAPI, InventoryUpdatesAPI, @@ -87,58 +87,118 @@ UnifiedJobsAPI.read.mockResolvedValue({ data: { count: 3, results: mockResults }, }); +function waitForLoaded(wrapper) { + return waitForElement( + wrapper, + 'JobList', + el => el.find('ContentLoading').length === 0 + ); +} + describe('', () => { - test('initially renders succesfully', async done => { - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'JobList', - el => el.state('jobs').length === 6 - ); - - done(); + test('initially renders succesfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + expect(wrapper.find('JobListItem')).toHaveLength(6); }); - test('select makes expected state updates', async done => { + test('should select and un-select items', async () => { const [mockItem] = mockResults; - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'JobListItem', el => el.length === 6); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); - wrapper - .find('JobListItem') - .first() - .prop('onSelect')(mockItem); - expect(wrapper.find('JobList').state('selected').length).toEqual(1); + act(() => { + wrapper + .find('JobListItem') + .first() + .invoke('onSelect')(mockItem); + }); + wrapper.update(); + expect( + wrapper + .find('JobListItem') + .first() + .prop('isSelected') + ).toEqual(true); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(1); - wrapper - .find('JobListItem') - .first() - .prop('onSelect')(mockItem); - expect(wrapper.find('JobList').state('selected').length).toEqual(0); - - done(); + act(() => { + wrapper + .find('JobListItem') + .first() + .invoke('onSelect')(mockItem); + }); + wrapper.update(); + expect( + wrapper + .find('JobListItem') + .first() + .prop('isSelected') + ).toEqual(false); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(0); }); - test('select-all-delete makes expected state updates and api calls', async done => { + test('should select and deselect all', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + wrapper.find('JobListItem'); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(6); + + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(false); + }); + wrapper.update(); + wrapper.find('JobListItem'); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(0); + }); + + test('should send all corresponding delete API requests', async () => { AdHocCommandsAPI.destroy = jest.fn(); InventoryUpdatesAPI.destroy = jest.fn(); JobsAPI.destroy = jest.fn(); ProjectUpdatesAPI.destroy = jest.fn(); SystemJobsAPI.destroy = jest.fn(); WorkflowJobsAPI.destroy = jest.fn(); - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'JobListItem', el => el.length === 6); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); - wrapper.find('DataListToolbar').prop('onSelectAll')(true); - expect(wrapper.find('JobList').state('selected').length).toEqual(6); + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + wrapper.find('JobListItem'); + expect( + wrapper.find('ToolbarDeleteButton').prop('itemsToDelete') + ).toHaveLength(6); - 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(6); - - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); expect(AdHocCommandsAPI.destroy).toHaveBeenCalledTimes(1); expect(InventoryUpdatesAPI.destroy).toHaveBeenCalledTimes(1); expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); @@ -146,12 +206,12 @@ describe('', () => { expect(SystemJobsAPI.destroy).toHaveBeenCalledTimes(1); expect(WorkflowJobsAPI.destroy).toHaveBeenCalledTimes(1); - done(); + jest.restoreAllMocks(); }); - test('error is shown when job not successfully deleted from api', async done => { - JobsAPI.destroy.mockRejectedValue( - new Error({ + test('error is shown when job not successfully deleted from api', async () => { + JobsAPI.destroy.mockImplementation(() => { + throw new Error({ response: { config: { method: 'delete', @@ -159,21 +219,29 @@ describe('', () => { }, data: 'An error occurred', }, - }) - ); - const wrapper = mountWithContexts(); - wrapper.find('JobList').setState({ - jobs: mockResults, - itemCount: 6, - selected: mockResults.slice(1, 2), + }); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + await act(async () => { + wrapper + .find('JobListItem') + .at(1) + .invoke('onSelect')(); + }); + wrapper.update(); + + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + wrapper.update(); await waitForElement( wrapper, 'Modal', el => el.props().isOpen === true && el.props().title === 'Error!' ); - - done(); }); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index 4af0ddae40..a417db86db 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -89,11 +89,7 @@ function OrganizationsList({ i18n }) { const canAdd = actions && actions.POST; const handleSelectAll = isSelected => { - if (isSelected) { - setSelected(organizations); - } else { - setSelected([]); - } + setSelected(isSelected ? [...organizations] : []); }; const handleSelect = row => { diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 167dc83106..cc688886b0 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -105,8 +105,7 @@ function TemplateList({ i18n }) { }; const handleSelectAll = isSelected => { - const selectedItems = isSelected ? [...templates] : []; - setSelected(selectedItems); + setSelected(isSelected ? [...templates] : []); }; const handleSelect = template => {