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
commit 87a0e40331
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 514 additions and 522 deletions

View File

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

View File

@ -17,8 +17,6 @@ import { getQSConfig, parseQueryString } from '@util/qs';
import AddDropDownButton from '@components/AddDropDownButton';
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', {
page: 1,
page_size: 20,

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,216 @@ 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 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

@ -1,11 +1,11 @@
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 { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { OrganizationsAPI } from '@api';
import useRequest from '@util/useRequest';
import useRequest, { useDeleteItems } from '@util/useRequest';
import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
@ -13,13 +13,7 @@ import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '@components/PaginatedDataList';
import {
getQSConfig,
parseQueryString,
replaceParams,
encodeNonDefaultQueryString,
} from '@util/qs';
import { getQSConfig, parseQueryString } from '@util/qs';
import OrganizationListItem from './OrganizationListItem';
const QS_CONFIG = getQSConfig('organization', {
@ -30,11 +24,9 @@ const QS_CONFIG = getQSConfig('organization', {
function OrganizationsList({ i18n }) {
const location = useLocation();
const history = useHistory();
const match = useRouteMatch();
const [selected, setSelected] = useState([]);
const [deletionError, setDeletionError] = useState(null);
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(() => {
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 () => {
await deleteOrganizations();
await adjustPagination();
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 canAdd = actions && actions.POST;
const isAllSelected =
selected.length === organizations.length && selected.length > 0;
const handleSelectAll = isSelected => {
if (isSelected) {
setSelected(organizations);
} else {
setSelected([]);
}
setSelected(isSelected ? [...organizations] : []);
};
const handleSelect = row => {
@ -197,7 +171,7 @@ function OrganizationsList({ i18n }) {
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more organizations.`)}
<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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -15,6 +15,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 AddDropDownButton from '@components/AddDropDownButton';
@ -31,82 +32,80 @@ const QS_CONFIG = getQSConfig('template', {
function TemplateList({ i18n }) {
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([]);
useEffect(
() => {
const loadTemplates = async () => {
const params = {
...parseQueryString(QS_CONFIG, search),
};
let jtOptionsPromise;
if (jtActions) {
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;
}
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);
}
const {
result: { templates, count, jtActions, wfjtActions },
error: contentError,
isLoading,
request: fetchTemplates,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
if (location.pathname.startsWith('/projects') && projectId) {
params.jobtemplate__project = projectId;
}
const results = await Promise.all([
UnifiedJobTemplatesAPI.read(params),
JobTemplatesAPI.readOptions(),
WorkflowJobTemplatesAPI.readOptions(),
]);
return {
templates: results[0].data.results,
count: results[0].data.count,
jtActions: results[1].data.actions,
wfjtActions: results[2].data.actions,
};
loadTemplates();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[pathname, search, count, projectId]
}, [location, projectId]),
{
templates: [],
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 selectedItems = isSelected ? [...templates] : [];
setSelected(selectedItems);
setSelected(isSelected ? [...templates] : []);
};
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 =
jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
const canAddWFJT =
@ -154,8 +133,6 @@ function TemplateList({ i18n }) {
url: `/templates/workflow_job_template/add/`,
});
}
const isAllSelected =
selected.length === templates.length && selected.length > 0;
const addButton = (
<AddDropDownButton key="add" dropdownItems={addButtonOptions} />
);
@ -164,7 +141,7 @@ function TemplateList({ i18n }) {
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
hasContentLoading={isLoading || isDeleteLoading}
items={templates}
itemCount={count}
pluralizedItemName={i18n._(t`Templates`)}
@ -247,7 +224,7 @@ function TemplateList({ i18n }) {
key={template.id}
value={template.name}
template={template}
detailUrl={`${pathname}/${template.type}/${template.id}`}
detailUrl={`${location.pathname}/${template.type}/${template.id}`}
onSelect={() => handleSelect(template)}
isSelected={selected.some(row => row.id === template.id)}
/>
@ -259,7 +236,7 @@ function TemplateList({ i18n }) {
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more templates.`)}
<ErrorDetail error={deletionError} />

View File

@ -1,4 +1,10 @@
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
@ -47,3 +53,40 @@ export default function useRequest(makeRequest, initialValue) {
}, [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),
};
}