mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 18:40:01 -03:30
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:
commit
87a0e40331
@ -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} />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user