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);
} finally {
setHasContentLoading(false);
}
}; };
}, [location]),
{
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,139 +35,100 @@ 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 () => {
};
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); 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(() => {
fetchJobs();
}, [fetchJobs]);
const isAllSelected = selected.length === jobs.length && selected.length > 0;
const { const {
contentError, isLoading: isDeleteLoading,
hasContentLoading, deleteItems: deleteJobs,
deletionError, deletionError,
jobs, clearDeletionError,
itemCount, } = useDeleteItems(
selected, useCallback(async () => {
} = this.state; return Promise.all(
const { match, i18n } = this.props; selected.map(({ type, id }) => {
const isAllSelected = switch (type) {
selected.length === jobs.length && selected.length > 0; 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 ( return (
<PageSection> <PageSection>
<Card> <Card>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={isLoading || isDeleteLoading}
items={jobs} items={jobs}
itemCount={itemCount} itemCount={itemCount}
pluralizedItemName="Jobs" pluralizedItemName="Jobs"
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
onRowClick={this.handleSelect} onRowClick={handleSelect}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -248,12 +210,12 @@ class JobList extends Component {
showSelectAll showSelectAll
showExpandCollapse showExpandCollapse
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
onSelectAll={this.handleSelectAll} onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[ additionalControls={[
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
onDelete={this.handleJobDelete} onDelete={handleJobDelete}
itemsToDelete={selected} itemsToDelete={selected}
pluralizedItemName="Jobs" pluralizedItemName="Jobs"
/>, />,
@@ -265,8 +227,8 @@ class JobList extends Component {
key={job.id} key={job.id}
value={job.name} value={job.name}
job={job} job={job}
detailUrl={`${match.url}/${job}/${job.id}`} detailUrl={`${location.pathname}/${job}/${job.id}`}
onSelect={() => this.handleSelect(job)} onSelect={() => handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)} isSelected={selected.some(row => row.id === job.id)}
/> />
)} )}
@@ -276,7 +238,7 @@ class JobList extends Component {
isOpen={deletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more jobs.`)} {i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />
@@ -284,7 +246,5 @@ class JobList extends Component {
</PageSection> </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 },
}); });
describe('<JobList />', () => { function waitForLoaded(wrapper) {
test('initially renders succesfully', async done => { return waitForElement(
const wrapper = mountWithContexts(<JobList />);
await waitForElement(
wrapper, wrapper,
'JobList', 'JobList',
el => el.state('jobs').length === 6 el => el.find('ContentLoading').length === 0
); );
}
done(); describe('<JobList />', () => {
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 [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);
act(() => {
wrapper wrapper
.find('JobListItem') .find('JobListItem')
.first() .first()
.prop('onSelect')(mockItem); .invoke('onSelect')(mockItem);
expect(wrapper.find('JobList').state('selected').length).toEqual(1); });
wrapper.update();
expect(
wrapper wrapper
.find('JobListItem') .find('JobListItem')
.first() .first()
.prop('onSelect')(mockItem); .prop('isSelected')
expect(wrapper.find('JobList').state('selected').length).toEqual(0); ).toEqual(true);
expect(
wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
).toHaveLength(1);
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(); 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({
data: { actions: jtActions },
});
} else {
jtOptionsPromise = JobTemplatesAPI.readOptions();
}
let wfjtOptionsPromise;
if (wfjtActions) {
wfjtOptionsPromise = Promise.resolve({
data: { actions: wfjtActions },
});
} else {
wfjtOptionsPromise = WorkflowJobTemplatesAPI.readOptions();
}
if (pathname.startsWith('/projects') && projectId) {
params.jobtemplate__project = projectId; params.jobtemplate__project = projectId;
} }
const results = await Promise.all([
const promises = Promise.all([
UnifiedJobTemplatesAPI.read(params), UnifiedJobTemplatesAPI.read(params),
jtOptionsPromise, JobTemplatesAPI.readOptions(),
wfjtOptionsPromise, WorkflowJobTemplatesAPI.readOptions(),
]); ]);
setDeletionError(null); return {
templates: results[0].data.results,
try { count: results[0].data.count,
const [ jtActions: results[1].data.actions,
{ wfjtActions: results[2].data.actions,
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),
};
}