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 { 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 (
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
items={jobs}
itemCount={itemCount}
pluralizedItemName="Jobs"
qsConfig={QS_CONFIG}
onRowClick={this.handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Label Name`),
key: 'labels__name',
},
{
name: i18n._(t`Job Type`),
key: `type`,
options: [
[`project_update`, i18n._(t`SCM Update`)],
[`inventory_update`, i18n._(t`Inventory Sync`)],
[`job`, i18n._(t`Playbook Run`)],
[`ad_hoc_command`, i18n._(t`Command`)],
[`system_job`, i18n._(t`Management Job`)],
[`workflow_job`, i18n._(t`Workflow Job`)],
],
},
{
name: i18n._(t`Launched By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Status`),
key: 'status',
options: [
[`new`, i18n._(t`New`)],
[`pending`, i18n._(t`Pending`)],
[`waiting`, i18n._(t`Waiting`)],
[`running`, i18n._(t`Running`)],
[`successful`, i18n._(t`Successful`)],
[`failed`, i18n._(t`Failed`)],
[`error`, i18n._(t`Error`)],
[`canceled`, i18n._(t`Canceled`)],
],
},
{
name: i18n._(t`Limit`),
key: 'job__limit',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Finish Time`),
key: 'finished',
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Launched By`),
key: 'created_by__id',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'unified_job_template__project__id',
},
{
name: i18n._(t`Start Time`),
key: 'started',
},
]}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
showExpandCollapse
isAllSelected={isAllSelected}
onSelectAll={this.handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={this.handleJobDelete}
itemsToDelete={selected}
pluralizedItemName="Jobs"
/>,
]}
/>
)}
renderItem={job => (
<JobListItem
key={job.id}
value={job.name}
job={job}
detailUrl={`${match.url}/${job}/${job.id}`}
onSelect={() => this.handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)}
/>
)}
/>
</Card>
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</PageSection>
);
}
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 (
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={jobs}
itemCount={itemCount}
pluralizedItemName="Jobs"
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Label Name`),
key: 'labels__name',
},
{
name: i18n._(t`Job Type`),
key: `type`,
options: [
[`project_update`, i18n._(t`SCM Update`)],
[`inventory_update`, i18n._(t`Inventory Sync`)],
[`job`, i18n._(t`Playbook Run`)],
[`ad_hoc_command`, i18n._(t`Command`)],
[`system_job`, i18n._(t`Management Job`)],
[`workflow_job`, i18n._(t`Workflow Job`)],
],
},
{
name: i18n._(t`Launched By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Status`),
key: 'status',
options: [
[`new`, i18n._(t`New`)],
[`pending`, i18n._(t`Pending`)],
[`waiting`, i18n._(t`Waiting`)],
[`running`, i18n._(t`Running`)],
[`successful`, i18n._(t`Successful`)],
[`failed`, i18n._(t`Failed`)],
[`error`, i18n._(t`Error`)],
[`canceled`, i18n._(t`Canceled`)],
],
},
{
name: i18n._(t`Limit`),
key: 'job__limit',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Finish Time`),
key: 'finished',
},
{
name: i18n._(t`ID`),
key: 'id',
},
{
name: i18n._(t`Launched By`),
key: 'created_by__id',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'unified_job_template__project__id',
},
{
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 default withI18n()(withRouter(JobList));
// export { JobList as _JobList };
export default withI18n()(JobList);

View File

@ -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('<JobList />', () => {
test('initially renders succesfully', async done => {
const wrapper = mountWithContexts(<JobList />);
await waitForElement(
wrapper,
'JobList',
el => el.state('jobs').length === 6
);
done();
test('initially renders succesfully', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<JobList />);
});
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(<JobList />);
await waitForElement(wrapper, 'JobListItem', el => el.length === 6);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<JobList />);
});
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(<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();
InventoryUpdatesAPI.destroy = jest.fn();
JobsAPI.destroy = jest.fn();
ProjectUpdatesAPI.destroy = jest.fn();
SystemJobsAPI.destroy = jest.fn();
WorkflowJobsAPI.destroy = jest.fn();
const wrapper = mountWithContexts(<JobList />);
await waitForElement(wrapper, 'JobListItem', el => el.length === 6);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<JobList />);
});
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('<JobList />', () => {
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('<JobList />', () => {
},
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(
wrapper,
'Modal',
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 handleSelectAll = isSelected => {
if (isSelected) {
setSelected(organizations);
} else {
setSelected([]);
}
setSelected(isSelected ? [...organizations] : []);
};
const handleSelect = row => {

View File

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