convert JobList to function comp w/ hooks

This commit is contained in:
Keith Grant
2020-02-17 16:11:48 -08:00
parent 54ddeaf046
commit 3b71d2a37b
4 changed files with 328 additions and 304 deletions

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
@@ -19,6 +19,7 @@ import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarDeleteButton, ToolbarDeleteButton,
} from '@components/PaginatedDataList'; } from '@components/PaginatedDataList';
import useRequest, { useDeleteItems } from '@util/useRequest';
import { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
import JobListItem from './JobListItem'; import JobListItem from './JobListItem';
@@ -34,257 +35,217 @@ const QS_CONFIG = getQSConfig(
['page', 'page_size', 'id'] ['page', 'page_size', 'id']
); );
class JobList extends Component { function JobList({ i18n }) {
constructor(props) { const [selected, setSelected] = useState([]);
super(props); const location = useLocation();
this.state = { const {
hasContentLoading: true, result: { jobs, itemCount },
deletionError: null, error: contentError,
contentError: null, isLoading,
selected: [], request: fetchJobs,
jobs: [], } = useRequest(
itemCount: 0, useCallback(async () => {
}; const params = parseQueryString(QS_CONFIG, location.search);
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 { const {
data: { count, results }, data: { count, results },
} = await UnifiedJobsAPI.read(params); } = await UnifiedJobsAPI.read(params);
this.setState({ return {
itemCount: count, itemCount: count,
jobs: results, jobs: results,
selected: [], };
}); }, [location]),
} catch (err) { {
this.setState({ contentError: err }); jobs: [],
} finally { itemCount: 0,
this.setState({ hasContentLoading: false });
} }
} );
render() { useEffect(() => {
const { fetchJobs();
contentError, }, [fetchJobs]);
hasContentLoading,
deletionError, const isAllSelected = selected.length === jobs.length && selected.length > 0;
jobs, const {
itemCount, isLoading: isDeleteLoading,
selected, deleteItems: deleteJobs,
} = this.state; deletionError,
const { match, i18n } = this.props; clearDeletionError,
const isAllSelected = } = useDeleteItems(
selected.length === jobs.length && selected.length > 0; useCallback(async () => {
return ( return Promise.all(
<PageSection> selected.map(({ type, id }) => {
<Card> switch (type) {
<PaginatedDataList case 'job':
contentError={contentError} return JobsAPI.destroy(id);
hasContentLoading={hasContentLoading} case 'ad_hoc_command':
items={jobs} return AdHocCommandsAPI.destroy(id);
itemCount={itemCount} case 'system_job':
pluralizedItemName="Jobs" return SystemJobsAPI.destroy(id);
qsConfig={QS_CONFIG} case 'project_update':
onRowClick={this.handleSelect} return ProjectUpdatesAPI.destroy(id);
toolbarSearchColumns={[ case 'inventory_update':
{ return InventoryUpdatesAPI.destroy(id);
name: i18n._(t`Name`), case 'workflow_job':
key: 'name', return WorkflowJobsAPI.destroy(id);
isDefault: true, default:
}, return null;
{ }
name: i18n._(t`ID`), })
key: 'id', );
}, }, [selected]),
{ {
name: i18n._(t`Label Name`), qsConfig: QS_CONFIG,
key: 'labels__name', allItemsSelected: isAllSelected,
}, fetchItems: fetchJobs,
{ }
name: i18n._(t`Job Type`), );
key: `type`,
options: [ const handleJobDelete = async () => {
[`project_update`, i18n._(t`SCM Update`)], await deleteJobs();
[`inventory_update`, i18n._(t`Inventory Sync`)], setSelected([]);
[`job`, i18n._(t`Playbook Run`)], };
[`ad_hoc_command`, i18n._(t`Command`)],
[`system_job`, i18n._(t`Management Job`)], const handleSelectAll = isSelected => {
[`workflow_job`, i18n._(t`Workflow Job`)], setSelected(isSelected ? [...jobs] : []);
], };
},
{ const handleSelect = item => {
name: i18n._(t`Launched By (Username)`), if (selected.some(s => s.id === item.id)) {
key: 'created_by__username', setSelected(selected.filter(s => s.id !== item.id));
}, } else {
{ setSelected(selected.concat(item));
name: i18n._(t`Status`), }
key: 'status', };
options: [
[`new`, i18n._(t`New`)], return (
[`pending`, i18n._(t`Pending`)], <PageSection>
[`waiting`, i18n._(t`Waiting`)], <Card>
[`running`, i18n._(t`Running`)], <PaginatedDataList
[`successful`, i18n._(t`Successful`)], contentError={contentError}
[`failed`, i18n._(t`Failed`)], hasContentLoading={isLoading || isDeleteLoading}
[`error`, i18n._(t`Error`)], items={jobs}
[`canceled`, i18n._(t`Canceled`)], itemCount={itemCount}
], pluralizedItemName="Jobs"
}, qsConfig={QS_CONFIG}
{ onRowClick={handleSelect}
name: i18n._(t`Limit`), toolbarSearchColumns={[
key: 'job__limit', {
}, name: i18n._(t`Name`),
]} key: 'name',
toolbarSortColumns={[ isDefault: true,
{ },
name: i18n._(t`Finish Time`), {
key: 'finished', name: i18n._(t`ID`),
}, key: 'id',
{ },
name: i18n._(t`ID`), {
key: 'id', name: i18n._(t`Label Name`),
}, key: 'labels__name',
{ },
name: i18n._(t`Launched By`), {
key: 'created_by__id', name: i18n._(t`Job Type`),
}, key: `type`,
{ options: [
name: i18n._(t`Name`), [`project_update`, i18n._(t`SCM Update`)],
key: 'name', [`inventory_update`, i18n._(t`Inventory Sync`)],
}, [`job`, i18n._(t`Playbook Run`)],
{ [`ad_hoc_command`, i18n._(t`Command`)],
name: i18n._(t`Project`), [`system_job`, i18n._(t`Management Job`)],
key: 'unified_job_template__project__id', [`workflow_job`, i18n._(t`Workflow Job`)],
}, ],
{ },
name: i18n._(t`Start Time`), {
key: 'started', name: i18n._(t`Launched By (Username)`),
}, key: 'created_by__username',
]} },
renderToolbar={props => ( {
<DatalistToolbar name: i18n._(t`Status`),
{...props} key: 'status',
showSelectAll options: [
showExpandCollapse [`new`, i18n._(t`New`)],
isAllSelected={isAllSelected} [`pending`, i18n._(t`Pending`)],
onSelectAll={this.handleSelectAll} [`waiting`, i18n._(t`Waiting`)],
qsConfig={QS_CONFIG} [`running`, i18n._(t`Running`)],
additionalControls={[ [`successful`, i18n._(t`Successful`)],
<ToolbarDeleteButton [`failed`, i18n._(t`Failed`)],
key="delete" [`error`, i18n._(t`Error`)],
onDelete={this.handleJobDelete} [`canceled`, i18n._(t`Canceled`)],
itemsToDelete={selected} ],
pluralizedItemName="Jobs" },
/>, {
]} name: i18n._(t`Limit`),
/> key: 'job__limit',
)} },
renderItem={job => ( ]}
<JobListItem toolbarSortColumns={[
key={job.id} {
value={job.name} name: i18n._(t`Finish Time`),
job={job} key: 'finished',
detailUrl={`${match.url}/${job}/${job.id}`} },
onSelect={() => this.handleSelect(job)} {
isSelected={selected.some(row => row.id === job.id)} name: i18n._(t`ID`),
/> key: 'id',
)} },
/> {
</Card> name: i18n._(t`Launched By`),
<AlertModal key: 'created_by__id',
isOpen={deletionError} },
variant="danger" {
title={i18n._(t`Error!`)} name: i18n._(t`Name`),
onClose={this.handleDeleteErrorClose} key: 'name',
> },
{i18n._(t`Failed to delete one or more jobs.`)} {
<ErrorDetail error={deletionError} /> name: i18n._(t`Project`),
</AlertModal> key: 'unified_job_template__project__id',
</PageSection> },
); {
} name: i18n._(t`Start Time`),
key: 'started',
},
]}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
showExpandCollapse
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleJobDelete}
itemsToDelete={selected}
pluralizedItemName="Jobs"
/>,
]}
/>
)}
renderItem={job => (
<JobListItem
key={job.id}
value={job.name}
job={job}
detailUrl={`${location.pathname}/${job}/${job.id}`}
onSelect={() => handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)}
/>
)}
/>
</Card>
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</PageSection>
);
} }
export { JobList as _JobList }; // export { JobList as _JobList };
export default withI18n()(withRouter(JobList)); export default withI18n()(JobList);

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { import {
AdHocCommandsAPI, AdHocCommandsAPI,
InventoryUpdatesAPI, InventoryUpdatesAPI,
@@ -87,58 +87,118 @@ UnifiedJobsAPI.read.mockResolvedValue({
data: { count: 3, results: mockResults }, data: { count: 3, results: mockResults },
}); });
function waitForLoaded(wrapper) {
return waitForElement(
wrapper,
'JobList',
el => el.find('ContentLoading').length === 0
);
}
describe('<JobList />', () => { describe('<JobList />', () => {
test('initially renders succesfully', async done => { test('initially renders succesfully', async () => {
const wrapper = mountWithContexts(<JobList />); let wrapper;
await waitForElement( await act(async () => {
wrapper, wrapper = mountWithContexts(<JobList />);
'JobList', });
el => el.state('jobs').length === 6 await waitForLoaded(wrapper);
); expect(wrapper.find('JobListItem')).toHaveLength(6);
done();
}); });
test('select makes expected state updates', async done => { test('should select and un-select items', async () => {
const [mockItem] = mockResults; const [mockItem] = mockResults;
const wrapper = mountWithContexts(<JobList />); let wrapper;
await waitForElement(wrapper, 'JobListItem', el => el.length === 6); await act(async () => {
wrapper = mountWithContexts(<JobList />);
});
await waitForLoaded(wrapper);
wrapper act(() => {
.find('JobListItem') wrapper
.first() .find('JobListItem')
.prop('onSelect')(mockItem); .first()
expect(wrapper.find('JobList').state('selected').length).toEqual(1); .invoke('onSelect')(mockItem);
});
wrapper.update();
expect(
wrapper
.find('JobListItem')
.first()
.prop('isSelected')
).toEqual(true);
expect(
wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
).toHaveLength(1);
wrapper act(() => {
.find('JobListItem') wrapper
.first() .find('JobListItem')
.prop('onSelect')(mockItem); .first()
expect(wrapper.find('JobList').state('selected').length).toEqual(0); .invoke('onSelect')(mockItem);
});
done(); 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(<JobList />);
});
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(); AdHocCommandsAPI.destroy = jest.fn();
InventoryUpdatesAPI.destroy = jest.fn(); InventoryUpdatesAPI.destroy = jest.fn();
JobsAPI.destroy = jest.fn(); JobsAPI.destroy = jest.fn();
ProjectUpdatesAPI.destroy = jest.fn(); ProjectUpdatesAPI.destroy = jest.fn();
SystemJobsAPI.destroy = jest.fn(); SystemJobsAPI.destroy = jest.fn();
WorkflowJobsAPI.destroy = jest.fn(); WorkflowJobsAPI.destroy = jest.fn();
const wrapper = mountWithContexts(<JobList />); let wrapper;
await waitForElement(wrapper, 'JobListItem', el => el.length === 6); await act(async () => {
wrapper = mountWithContexts(<JobList />);
});
await waitForLoaded(wrapper);
wrapper.find('DataListToolbar').prop('onSelectAll')(true); act(() => {
expect(wrapper.find('JobList').state('selected').length).toEqual(6); 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); await act(async () => {
expect(wrapper.find('JobList').state('selected').length).toEqual(0); wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
wrapper.find('DataListToolbar').prop('onSelectAll')(true);
expect(wrapper.find('JobList').state('selected').length).toEqual(6);
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
expect(AdHocCommandsAPI.destroy).toHaveBeenCalledTimes(1); expect(AdHocCommandsAPI.destroy).toHaveBeenCalledTimes(1);
expect(InventoryUpdatesAPI.destroy).toHaveBeenCalledTimes(1); expect(InventoryUpdatesAPI.destroy).toHaveBeenCalledTimes(1);
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
@@ -146,12 +206,12 @@ describe('<JobList />', () => {
expect(SystemJobsAPI.destroy).toHaveBeenCalledTimes(1); expect(SystemJobsAPI.destroy).toHaveBeenCalledTimes(1);
expect(WorkflowJobsAPI.destroy).toHaveBeenCalledTimes(1); expect(WorkflowJobsAPI.destroy).toHaveBeenCalledTimes(1);
done(); jest.restoreAllMocks();
}); });
test('error is shown when job not successfully deleted from api', async done => { test('error is shown when job not successfully deleted from api', async () => {
JobsAPI.destroy.mockRejectedValue( JobsAPI.destroy.mockImplementation(() => {
new Error({ throw new Error({
response: { response: {
config: { config: {
method: 'delete', method: 'delete',
@@ -159,21 +219,29 @@ describe('<JobList />', () => {
}, },
data: 'An error occurred', data: 'An error occurred',
}, },
}) });
);
const wrapper = mountWithContexts(<JobList />);
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(<JobList />);
});
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( await waitForElement(
wrapper, wrapper,
'Modal', 'Modal',
el => el.props().isOpen === true && el.props().title === 'Error!' el => el.props().isOpen === true && el.props().title === 'Error!'
); );
done();
}); });
}); });

View File

@@ -89,11 +89,7 @@ function OrganizationsList({ i18n }) {
const canAdd = actions && actions.POST; const canAdd = actions && actions.POST;
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
if (isSelected) { setSelected(isSelected ? [...organizations] : []);
setSelected(organizations);
} else {
setSelected([]);
}
}; };
const handleSelect = row => { const handleSelect = row => {

View File

@@ -105,8 +105,7 @@ function TemplateList({ i18n }) {
}; };
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
const selectedItems = isSelected ? [...templates] : []; setSelected(isSelected ? [...templates] : []);
setSelected(selectedItems);
}; };
const handleSelect = template => { const handleSelect = template => {