Merge pull request #5938 from keithjgrant/4239-pagination-on-delete-followup

Pagination on delete (followup)

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-02-20 19:28:02 +00:00
committed by GitHub
7 changed files with 514 additions and 522 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useLocation, useHistory } 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 { CredentialsAPI } from '@api'; import { CredentialsAPI } from '@api';
@@ -11,12 +11,8 @@ import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '@components/PaginatedDataList'; } from '@components/PaginatedDataList';
import { import useRequest, { useDeleteItems } from '@util/useRequest';
getQSConfig, import { getQSConfig, parseQueryString } from '@util/qs';
parseQueryString,
replaceParams,
encodeNonDefaultQueryString,
} from '@util/qs';
import { CredentialListItem } from '.'; import { CredentialListItem } from '.';
const QS_CONFIG = getQSConfig('credential', { const QS_CONFIG = getQSConfig('credential', {
@@ -26,53 +22,59 @@ const QS_CONFIG = getQSConfig('credential', {
}); });
function CredentialList({ i18n }) { function CredentialList({ i18n }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [credentialCount, setCredentialCount] = useState(0);
const [credentials, setCredentials] = useState([]);
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const location = useLocation(); const location = useLocation();
const history = useHistory();
const loadCredentials = async ({ search }) => { const {
const params = parseQueryString(QS_CONFIG, search); result: { credentials, credentialCount, actions },
setContentError(null); error: contentError,
setHasContentLoading(true); isLoading,
try { request: fetchCredentials,
const [ } = useRequest(
{ useCallback(async () => {
data: { count, results }, const params = parseQueryString(QS_CONFIG, location.search);
}, const [creds, credActions] = await Promise.all([
{
data: { actions: optionActions },
},
] = await Promise.all([
CredentialsAPI.read(params), CredentialsAPI.read(params),
loadCredentialActions(), CredentialsAPI.readOptions(),
]); ]);
return {
setCredentials(results); credentials: creds.data.results,
setCredentialCount(count); credentialCount: creds.data.count,
setActions(optionActions); actions: credActions.data.actions,
} catch (error) { };
setContentError(error); }, [location]),
} finally { {
setHasContentLoading(false); credentials: [],
credentialCount: 0,
actions: {},
} }
}; );
useEffect(() => { useEffect(() => {
loadCredentials(location); fetchCredentials();
}, [location]); // eslint-disable-line react-hooks/exhaustive-deps }, [fetchCredentials]);
const loadCredentialActions = () => { const isAllSelected =
if (actions) { selected.length > 0 && selected.length === credentials.length;
return Promise.resolve({ data: { actions } }); const {
isLoading: isDeleteLoading,
deleteItems: deleteCredentials,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(selected.map(({ id }) => CredentialsAPI.destroy(id)));
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchCredentials,
} }
return CredentialsAPI.readOptions(); );
const handleDelete = async () => {
await deleteCredentials();
setSelected([]);
}; };
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
@@ -87,45 +89,15 @@ function CredentialList({ i18n }) {
} }
}; };
const handleDelete = async () => {
setHasContentLoading(true);
try {
await Promise.all(
selected.map(credential => CredentialsAPI.destroy(credential.id))
);
} catch (error) {
setDeletionError(error);
}
adjustPagination();
setSelected([]);
};
const adjustPagination = () => {
const params = parseQueryString(QS_CONFIG, location.search);
if (params.page > 1 && selected.length === credentials.length) {
const newParams = encodeNonDefaultQueryString(
QS_CONFIG,
replaceParams(params, { page: params.page - 1 })
);
history.push(`${location.pathname}?${newParams}`);
} else {
loadCredentials(location);
}
};
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected =
selected.length > 0 && selected.length === credentials.length;
return ( return (
<PageSection> <PageSection>
<Card> <Card>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={isLoading || isDeleteLoading}
items={credentials} items={credentials}
itemCount={credentialCount} itemCount={credentialCount}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
@@ -165,7 +137,7 @@ function CredentialList({ i18n }) {
isOpen={deletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more credentials.`)} {i18n._(t`Failed to delete one or more credentials.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />

View File

@@ -17,8 +17,6 @@ import { getQSConfig, parseQueryString } from '@util/qs';
import AddDropDownButton from '@components/AddDropDownButton'; import AddDropDownButton from '@components/AddDropDownButton';
import InventoryListItem from './InventoryListItem'; import InventoryListItem from './InventoryListItem';
// The type value in const QS_CONFIG below does not have a space between job_inventory and
// workflow_job_inventory so the params sent to the API match what the api expects.
const QS_CONFIG = getQSConfig('inventory', { const QS_CONFIG = getQSConfig('inventory', {
page: 1, page: 1,
page_size: 20, page_size: 20,

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,216 @@ 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 default withI18n()(JobList);
export default withI18n()(withRouter(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

@@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useLocation, useHistory, useRouteMatch } from 'react-router-dom'; import { useLocation, useRouteMatch } 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';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
import useRequest from '@util/useRequest'; import useRequest, { useDeleteItems } from '@util/useRequest';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar'; import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail'; import ErrorDetail from '@components/ErrorDetail';
@@ -13,13 +13,7 @@ import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '@components/PaginatedDataList'; } from '@components/PaginatedDataList';
import { import { getQSConfig, parseQueryString } from '@util/qs';
getQSConfig,
parseQueryString,
replaceParams,
encodeNonDefaultQueryString,
} from '@util/qs';
import OrganizationListItem from './OrganizationListItem'; import OrganizationListItem from './OrganizationListItem';
const QS_CONFIG = getQSConfig('organization', { const QS_CONFIG = getQSConfig('organization', {
@@ -30,11 +24,9 @@ const QS_CONFIG = getQSConfig('organization', {
function OrganizationsList({ i18n }) { function OrganizationsList({ i18n }) {
const location = useLocation(); const location = useLocation();
const history = useHistory();
const match = useRouteMatch(); const match = useRouteMatch();
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [deletionError, setDeletionError] = useState(null);
const addUrl = `${match.url}/add`; const addUrl = `${match.url}/add`;
@@ -63,58 +55,40 @@ function OrganizationsList({ i18n }) {
} }
); );
const {
isLoading: isDeleteLoading,
error: dError,
request: deleteOrganizations,
} = useRequest(
useCallback(async () => {
return Promise.all(
selected.map(({ id }) => OrganizationsAPI.destroy(id))
);
}, [selected])
);
useEffect(() => {
if (dError) {
setDeletionError(dError);
}
}, [dError]);
useEffect(() => { useEffect(() => {
fetchOrganizations(); fetchOrganizations();
}, [fetchOrganizations]); }, [fetchOrganizations]);
const isAllSelected =
selected.length === organizations.length && selected.length > 0;
const {
isLoading: isDeleteLoading,
deleteItems: deleteOrganizations,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(({ id }) => OrganizationsAPI.destroy(id))
);
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchOrganizations,
}
);
const handleOrgDelete = async () => { const handleOrgDelete = async () => {
await deleteOrganizations(); await deleteOrganizations();
await adjustPagination();
setSelected([]); setSelected([]);
}; };
const adjustPagination = () => {
const params = parseQueryString(QS_CONFIG, location.search);
if (params.page > 1 && selected.length === organizations.length) {
const newParams = encodeNonDefaultQueryString(
QS_CONFIG,
replaceParams(params, { page: params.page - 1 })
);
history.push(`${location.pathname}?${newParams}`);
} else {
fetchOrganizations();
}
};
const hasContentLoading = isDeleteLoading || isOrgsLoading; const hasContentLoading = isDeleteLoading || isOrgsLoading;
const canAdd = actions && actions.POST; const canAdd = actions && actions.POST;
const isAllSelected =
selected.length === organizations.length && selected.length > 0;
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
if (isSelected) { setSelected(isSelected ? [...organizations] : []);
setSelected(organizations);
} else {
setSelected([]);
}
}; };
const handleSelect = row => { const handleSelect = row => {
@@ -197,7 +171,7 @@ function OrganizationsList({ i18n }) {
isOpen={deletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more organizations.`)} {i18n._(t`Failed to delete one or more organizations.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom'; import { useParams, 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';
@@ -15,6 +15,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 AddDropDownButton from '@components/AddDropDownButton'; import AddDropDownButton from '@components/AddDropDownButton';
@@ -31,82 +32,80 @@ const QS_CONFIG = getQSConfig('template', {
function TemplateList({ i18n }) { function TemplateList({ i18n }) {
const { id: projectId } = useParams(); const { id: projectId } = useParams();
const { pathname, search } = useLocation(); const location = useLocation();
const [deletionError, setDeletionError] = useState(null);
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const [jtActions, setJTActions] = useState(null);
const [wfjtActions, setWFJTActions] = useState(null);
const [count, setCount] = useState(0);
const [templates, setTemplates] = useState([]);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
useEffect( const {
() => { result: { templates, count, jtActions, wfjtActions },
const loadTemplates = async () => { error: contentError,
const params = { isLoading,
...parseQueryString(QS_CONFIG, search), request: fetchTemplates,
}; } = useRequest(
useCallback(async () => {
let jtOptionsPromise; const params = parseQueryString(QS_CONFIG, location.search);
if (jtActions) { if (location.pathname.startsWith('/projects') && projectId) {
jtOptionsPromise = Promise.resolve({ params.jobtemplate__project = projectId;
data: { actions: jtActions }, }
}); const results = await Promise.all([
} else { UnifiedJobTemplatesAPI.read(params),
jtOptionsPromise = JobTemplatesAPI.readOptions(); JobTemplatesAPI.readOptions(),
} WorkflowJobTemplatesAPI.readOptions(),
]);
let wfjtOptionsPromise; return {
if (wfjtActions) { templates: results[0].data.results,
wfjtOptionsPromise = Promise.resolve({ count: results[0].data.count,
data: { actions: wfjtActions }, jtActions: results[1].data.actions,
}); wfjtActions: results[2].data.actions,
} else {
wfjtOptionsPromise = WorkflowJobTemplatesAPI.readOptions();
}
if (pathname.startsWith('/projects') && projectId) {
params.jobtemplate__project = projectId;
}
const promises = Promise.all([
UnifiedJobTemplatesAPI.read(params),
jtOptionsPromise,
wfjtOptionsPromise,
]);
setDeletionError(null);
try {
const [
{
data: { count: itemCount, results },
},
{
data: { actions: jobTemplateActions },
},
{
data: { actions: workFlowJobTemplateActions },
},
] = await promises;
setJTActions(jobTemplateActions);
setWFJTActions(workFlowJobTemplateActions);
setCount(itemCount);
setTemplates(results);
setHasContentLoading(false);
} catch (err) {
setContentError(err);
}
}; };
loadTemplates(); }, [location, projectId]),
}, {
// eslint-disable-next-line react-hooks/exhaustive-deps templates: [],
[pathname, search, count, projectId] count: 0,
jtActions: {},
wfjtActions: {},
}
); );
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
const isAllSelected =
selected.length === templates.length && selected.length > 0;
const {
isLoading: isDeleteLoading,
deleteItems: deleteTemplates,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(({ type, id }) => {
if (type === 'job_template') {
return JobTemplatesAPI.destroy(id);
}
if (type === 'workflow_job_template') {
return WorkflowJobTemplatesAPI.destroy(id);
}
return false;
})
);
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchTemplates,
}
);
const handleTemplateDelete = async () => {
await deleteTemplates();
setSelected([]);
};
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
const selectedItems = isSelected ? [...templates] : []; setSelected(isSelected ? [...templates] : []);
setSelected(selectedItems);
}; };
const handleSelect = template => { const handleSelect = template => {
@@ -117,26 +116,6 @@ function TemplateList({ i18n }) {
} }
}; };
const handleTemplateDelete = async () => {
setHasContentLoading(true);
try {
await Promise.all(
selected.map(({ type, id }) => {
let deletePromise;
if (type === 'job_template') {
deletePromise = JobTemplatesAPI.destroy(id);
} else if (type === 'workflow_job_template') {
deletePromise = WorkflowJobTemplatesAPI.destroy(id);
}
return deletePromise;
})
);
setCount(count - selected.length);
} catch (err) {
setDeletionError(err);
}
};
const canAddJT = const canAddJT =
jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
const canAddWFJT = const canAddWFJT =
@@ -154,8 +133,6 @@ function TemplateList({ i18n }) {
url: `/templates/workflow_job_template/add/`, url: `/templates/workflow_job_template/add/`,
}); });
} }
const isAllSelected =
selected.length === templates.length && selected.length > 0;
const addButton = ( const addButton = (
<AddDropDownButton key="add" dropdownItems={addButtonOptions} /> <AddDropDownButton key="add" dropdownItems={addButtonOptions} />
); );
@@ -164,7 +141,7 @@ function TemplateList({ i18n }) {
<Card> <Card>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={isLoading || isDeleteLoading}
items={templates} items={templates}
itemCount={count} itemCount={count}
pluralizedItemName={i18n._(t`Templates`)} pluralizedItemName={i18n._(t`Templates`)}
@@ -247,7 +224,7 @@ function TemplateList({ i18n }) {
key={template.id} key={template.id}
value={template.name} value={template.name}
template={template} template={template}
detailUrl={`${pathname}/${template.type}/${template.id}`} detailUrl={`${location.pathname}/${template.type}/${template.id}`}
onSelect={() => handleSelect(template)} onSelect={() => handleSelect(template)}
isSelected={selected.some(row => row.id === template.id)} isSelected={selected.some(row => row.id === template.id)}
/> />
@@ -259,7 +236,7 @@ function TemplateList({ i18n }) {
isOpen={deletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more templates.`)} {i18n._(t`Failed to delete one or more templates.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />

View File

@@ -1,4 +1,10 @@
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import {
parseQueryString,
replaceParams,
encodeNonDefaultQueryString,
} from './qs';
/* /*
* The useRequest hook accepts a request function and returns an object with * The useRequest hook accepts a request function and returns an object with
@@ -47,3 +53,40 @@ export default function useRequest(makeRequest, initialValue) {
}, [makeRequest]), }, [makeRequest]),
}; };
} }
export function useDeleteItems(
makeRequest,
{ qsConfig, allItemsSelected, fetchItems }
) {
const location = useLocation();
const history = useHistory();
const [showError, setShowError] = useState(false);
const { error, isLoading, request } = useRequest(makeRequest, null);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
const deleteItems = async () => {
await request();
const params = parseQueryString(qsConfig, location.search);
if (params.page > 1 && allItemsSelected) {
const newParams = encodeNonDefaultQueryString(
qsConfig,
replaceParams(params, { page: params.page - 1 })
);
history.push(`${location.pathname}?${newParams}`);
} else {
fetchItems();
}
};
return {
isLoading,
deleteItems,
deletionError: showError && error,
clearDeletionError: () => setShowError(false),
};
}