Merge pull request #10329 from keithjgrant/6853-select-all-quirks

Clear list selections on pagination/search/sort

SUMMARY
Updates nearly every list* so that URL param changes (pagination, search, or sort) clear the selection. This prevents the list of selected items in state from including items that may no longer appear on screen — preventing the user from accidentally deleting or otherwise altering an item they may not realize they still have selected.
This also updates the useSelected hook to provide selectAll and clearSelected functions. Any lists that weren't yet already using this hook have been updated to do so.
Addresses #6853 and #7509
ISSUE TYPE

Bugfix Pull Request

COMPONENT NAME

UI

ADDITIONAL INFORMATION
*Lists that do not include this change are modals where the user is expected to paginate through screens and make several selections along the way (e.g. Multi Credential select modal), and lists that still use PaginatedDataList and are yet to be converted to PaginatedTable
Note: I originally wanted to make the clearSelected prop on PaginatedTable required, so any lists that don't have this fix applied would fail loudly. Unfortunately that wasn't possible, as there were a few lists that should not have this behavior, so I had to leave it as an optional prop.

Reviewed-by: Alex Corey <Alex.swansboro@gmail.com>
Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-06-02 23:11:41 +00:00 committed by GitHub
commit 168c022d3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 353 additions and 284 deletions

View File

@ -37,7 +37,6 @@ function DataListToolbar({
onExpand,
onSelectAll,
additionalControls,
qsConfig,
pagination,
}) {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
@ -13,7 +13,7 @@ import useRequest, {
useDismissableError,
} from '../../util/useRequest';
import { useConfig } from '../../contexts/Config';
import useSelected from '../../util/useSelected';
import { isJobRunning, getJobModel } from '../../util/jobs';
import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem';
@ -35,8 +35,6 @@ function JobList({ defaultParams, showTypeColumn = false }) {
);
const { me } = useConfig();
const [selected, setSelected] = useState([]);
const location = useLocation();
const {
result: { results, count, relatedSearchableKeys, searchableKeys },
@ -88,7 +86,13 @@ function JobList({ defaultParams, showTypeColumn = false }) {
const jobs = useWsJobs(results, fetchJobsById, qsConfig);
const isAllSelected = selected.length === jobs.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(jobs);
const {
error: cancelJobsError,
@ -135,24 +139,12 @@ function JobList({ defaultParams, showTypeColumn = false }) {
const handleJobCancel = async () => {
await cancelJobs();
setSelected([]);
clearSelected();
};
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));
}
clearSelected();
};
const cannotDeleteItems = selected.filter(job => isJobRunning(job.status));
@ -226,6 +218,7 @@ function JobList({ defaultParams, showTypeColumn = false }) {
<HeaderCell>{t`Actions`}</HeaderCell>
</HeaderRow>
}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
@ -233,7 +226,7 @@ function JobList({ defaultParams, showTypeColumn = false }) {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={qsConfig}
additionalControls={[
<ToolbarDeleteButton

View File

@ -1,5 +1,5 @@
import 'styled-components/macro';
import React, { Fragment } from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { TableComposable, Tbody } from '@patternfly/react-table';
@ -33,10 +33,16 @@ function PaginatedTable({
showPageSizeOptions,
renderToolbar,
emptyContentMessage,
clearSelected,
ouiaId,
}) {
const { search, pathname } = useLocation();
const history = useHistory();
const location = useLocation();
useEffect(() => {
clearSelected();
}, [location.search, clearSelected]);
const pushHistoryState = qs => {
history.push(qs ? `${pathname}?${qs}` : pathname);
@ -127,7 +133,7 @@ function PaginatedTable({
);
return (
<Fragment>
<>
<ListHeader
itemCount={itemCount}
renderToolbar={renderToolbar}
@ -159,7 +165,7 @@ function PaginatedTable({
onPerPageSelect={handleSetPageSize}
/>
) : null}
</Fragment>
</>
);
}
@ -182,6 +188,7 @@ PaginatedTable.propTypes = {
renderToolbar: PropTypes.func,
hasContentLoading: PropTypes.bool,
contentError: PropTypes.shape(),
clearSelected: PropTypes.func,
ouiaId: PropTypes.string,
};
@ -195,6 +202,7 @@ PaginatedTable.defaultProps = {
showPageSizeOptions: true,
renderToolbar: props => <DataListToolbar {...props} />,
ouiaId: null,
clearSelected: () => {},
};
export { PaginatedTable as _PaginatedTable };

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { bool, func } from 'prop-types';
@ -10,6 +10,7 @@ import PaginatedTable, { HeaderRow, HeaderCell } from '../../PaginatedTable';
import DataListToolbar from '../../DataListToolbar';
import { ToolbarAddButton, ToolbarDeleteButton } from '../../PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import ScheduleListItem from './ScheduleListItem';
@ -27,8 +28,6 @@ function ScheduleList({
launchConfig,
surveyConfig,
}) {
const [selected, setSelected] = useState([]);
const location = useLocation();
const {
@ -76,8 +75,13 @@ function ScheduleList({
fetchSchedules();
}, [fetchSchedules]);
const isAllSelected =
selected.length === schedules.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(schedules);
const {
isLoading: isDeleteLoading,
@ -95,21 +99,9 @@ function ScheduleList({
}
);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...schedules] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const handleDelete = async () => {
await deleteJobs();
setSelected([]);
clearSelected();
};
const canAdd =
@ -183,6 +175,7 @@ function ScheduleList({
isMissingSurvey={isTemplate && hasMissingSurveyValue(item)}
/>
)}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -209,7 +202,7 @@ function ScheduleList({
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -1,4 +1,4 @@
import React, { Fragment, useEffect, useState, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
import { Card, DropdownItem } from '@patternfly/react-core';
@ -13,6 +13,7 @@ import ErrorDetail from '../ErrorDetail';
import { ToolbarDeleteButton } from '../PaginatedDataList';
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
import useRequest, { useDeleteItems } from '../../util/useRequest';
import useSelected from '../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../util/qs';
import useWsTemplates from '../../util/useWsTemplates';
import AddDropDownButton from '../AddDropDownButton';
@ -35,8 +36,6 @@ function TemplateList({ defaultParams }) {
);
const location = useLocation();
const [selected, setSelected] = useState([]);
const {
result: {
results,
@ -87,8 +86,14 @@ function TemplateList({ defaultParams }) {
const templates = useWsTemplates(results);
const isAllSelected =
selected.length === templates.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(templates);
const {
isLoading: isDeleteLoading,
deleteItems: deleteTemplates,
@ -117,19 +122,7 @@ function TemplateList({ defaultParams }) {
const handleTemplateDelete = async () => {
await deleteTemplates();
setSelected([]);
};
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...templates] : []);
};
const handleSelect = template => {
if (selected.some(s => s.id === template.id)) {
setSelected(selected.filter(s => s.id !== template.id));
} else {
setSelected(selected.concat(template));
}
clearSelected();
};
const canAddJT =
@ -179,7 +172,7 @@ function TemplateList({ defaultParams }) {
);
return (
<Fragment>
<>
<Card>
<PaginatedTable
contentError={contentError}
@ -188,7 +181,7 @@ function TemplateList({ defaultParams }) {
itemCount={count}
pluralizedItemName={t`Templates`}
qsConfig={qsConfig}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -235,7 +228,7 @@ function TemplateList({ defaultParams }) {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={qsConfig}
additionalControls={[
...(canAddJT || canAddWFJT ? [addButton] : []),
@ -281,7 +274,7 @@ function TemplateList({ defaultParams }) {
{t`Failed to delete one or more templates.`}
<ErrorDetail error={deletionError} />
</AlertModal>
</Fragment>
</>
);
}

View File

@ -77,9 +77,13 @@ function ApplicationsList() {
fetchApplications();
}, [fetchApplications]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
applications
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(applications);
const {
isLoading: deleteLoading,
@ -99,7 +103,7 @@ function ApplicationsList() {
const handleDeleteApplications = async () => {
await deleteApplications();
setSelected([]);
clearSelected();
};
const canAdd = actions && actions.POST;
@ -115,7 +119,7 @@ function ApplicationsList() {
itemCount={itemCount}
pluralizedItemName={t`Applications`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -134,9 +138,7 @@ function ApplicationsList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...applications] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -76,9 +76,14 @@ function CredentialList() {
fetchCredentials();
}, [fetchCredentials]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
credentials
);
const {
selected,
isAllSelected,
handleSelect,
setSelected,
selectAll,
clearSelected,
} = useSelected(credentials);
const {
isLoading: isDeleteLoading,
@ -115,7 +120,7 @@ function CredentialList() {
items={credentials}
itemCount={credentialCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
@ -160,9 +165,7 @@ function CredentialList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...credentials] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -77,9 +77,13 @@ function CredentialTypeList() {
fetchCredentialTypes();
}, [fetchCredentialTypes]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
credentialTypes
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(credentialTypes);
const {
isLoading: deleteLoading,
@ -101,7 +105,7 @@ function CredentialTypeList() {
const handleDelete = async () => {
await deleteCredentialTypes();
setSelected([]);
clearSelected();
};
const canAdd = actions && actions.POST;
@ -121,7 +125,7 @@ function CredentialTypeList() {
itemCount={credentialTypesCount}
pluralizedItemName={t`Credential Types`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -148,9 +152,7 @@ function CredentialTypeList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...credentialTypes] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -76,9 +76,13 @@ function ExecutionEnvironmentList() {
fetchExecutionEnvironments();
}, [fetchExecutionEnvironments]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
executionEnvironments
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(executionEnvironments);
const {
isLoading: deleteLoading,
@ -100,7 +104,7 @@ function ExecutionEnvironmentList() {
const handleDelete = async () => {
await deleteExecutionEnvironments();
setSelected([]);
clearSelected();
};
const canAdd = actions && actions.POST;
@ -119,7 +123,7 @@ function ExecutionEnvironmentList() {
itemCount={executionEnvironmentsCount}
pluralizedItemName={t`Execution Environments`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
@ -164,9 +168,7 @@ function ExecutionEnvironmentList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...executionEnvironments] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro';
@ -17,6 +17,7 @@ import PaginatedTable, {
HeaderCell,
} from '../../../components/PaginatedTable';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import {
encodeQueryString,
getQSConfig,
@ -36,7 +37,6 @@ function HostList() {
const history = useHistory();
const location = useLocation();
const match = useRouteMatch();
const [selected, setSelected] = useState([]);
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
const nonDefaultSearchParams = {};
@ -86,7 +86,14 @@ function HostList() {
fetchHosts();
}, [fetchHosts]);
const isAllSelected = selected.length === hosts.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(hosts);
const {
isLoading: isDeleteLoading,
deleteItems: deleteHosts,
@ -105,19 +112,7 @@ function HostList() {
const handleHostDelete = async () => {
await deleteHosts();
setSelected([]);
};
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []);
};
const handleSelect = host => {
if (selected.some(h => h.id === host.id)) {
setSelected(selected.filter(h => h.id !== host.id));
} else {
setSelected(selected.concat(host));
}
clearSelected();
};
const handleSmartInventoryClick = () => {
@ -141,7 +136,7 @@ function HostList() {
itemCount={count}
pluralizedItemName={t`Hosts`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -175,7 +170,7 @@ function HostList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -92,9 +92,13 @@ function InstanceGroupList() {
fetchInstanceGroups();
}, [fetchInstanceGroups]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
instanceGroups
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(instanceGroups);
const modifiedSelected = modifyInstanceGroups(selected);
@ -118,7 +122,7 @@ function InstanceGroupList() {
const handleDelete = async () => {
await deleteInstanceGroups();
setSelected([]);
clearSelected();
};
const canAdd = actions && actions.POST;
@ -201,7 +205,7 @@ function InstanceGroupList() {
itemCount={instanceGroupsCount}
pluralizedItemName={pluralizedItemName}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
@ -209,9 +213,7 @@ function InstanceGroupList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...instanceGroups] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [addButton] : []),

View File

@ -77,9 +77,13 @@ function InventoryGroupsList() {
fetchData();
}, [fetchData]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
groups
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(groups);
const renderTooltip = () => {
const itemsUnableToDelete = selected
@ -111,7 +115,7 @@ function InventoryGroupsList() {
items={groups}
itemCount={groupCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -159,9 +163,7 @@ function InventoryGroupsList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...groups] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
@ -185,7 +187,7 @@ function InventoryGroupsList() {
}
onAfterDelete={() => {
fetchData();
setSelected([]);
clearSelected();
}}
/>
</Tooltip>,

View File

@ -84,9 +84,13 @@ function InventoryHostGroupsList() {
fetchGroups();
}, [fetchGroups]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
groups
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(groups);
const {
isLoading: isDisassociateLoading,
@ -107,7 +111,7 @@ function InventoryHostGroupsList() {
const handleDisassociate = async () => {
await disassociateHosts();
setSelected([]);
clearSelected();
};
const fetchGroupsToAssociate = useCallback(
@ -156,7 +160,7 @@ function InventoryHostGroupsList() {
items={groups}
itemCount={itemCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -195,9 +199,7 @@ function InventoryHostGroupsList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...groups] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -15,6 +15,7 @@ import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import useSelected from '../../../util/useSelected';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostItem from './InventoryHostItem';
@ -25,7 +26,6 @@ const QS_CONFIG = getQSConfig('host', {
});
function InventoryHostList() {
const [selected, setSelected] = useState([]);
const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false);
const { id } = useParams();
const { search } = useLocation();
@ -74,17 +74,14 @@ function InventoryHostList() {
fetchData();
}, [fetchData]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []);
};
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(hosts);
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const {
isLoading: isDeleteLoading,
deleteItems: deleteHosts,
@ -99,12 +96,11 @@ function InventoryHostList() {
const handleDeleteHosts = async () => {
await deleteHosts();
setSelected([]);
clearSelected();
};
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length > 0 && selected.length === hosts.length;
return (
<>
@ -115,6 +111,7 @@ function InventoryHostList() {
itemCount={hostCount}
pluralizedItemName={t`Hosts`}
qsConfig={QS_CONFIG}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
@ -128,7 +125,7 @@ function InventoryHostList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -1,9 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useLocation, useRouteMatch, Link } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
@ -27,7 +28,6 @@ const QS_CONFIG = getQSConfig('inventory', {
function InventoryList() {
const location = useLocation();
const match = useRouteMatch();
const [selected, setSelected] = useState([]);
const {
result: {
@ -89,8 +89,14 @@ function InventoryList() {
QS_CONFIG
);
const isAllSelected =
selected.length === inventories.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(inventories);
const {
isLoading: isDeleteLoading,
deleteItems: deleteInventories,
@ -107,26 +113,12 @@ function InventoryList() {
const handleInventoryDelete = async () => {
await deleteInventories();
setSelected([]);
clearSelected();
};
const hasContentLoading = isDeleteLoading || isLoading;
const canAdd = actions && actions.POST;
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...inventories] : []);
};
const handleSelect = row => {
if (!row.pending_deletion) {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
}
};
const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(
selected[0]
);
@ -197,6 +189,7 @@ function InventoryList() {
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
clearSelected={clearSelected}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
@ -211,7 +204,7 @@ function InventoryList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd ? [addButton] : []),
@ -251,7 +244,11 @@ function InventoryList() {
? `${match.url}/smart_inventory/${inventory.id}/details`
: `${match.url}/inventory/${inventory.id}/details`
}
onSelect={() => handleSelect(inventory)}
onSelect={() => {
if (!inventory.pending_deletion) {
handleSelect(inventory);
}
}}
isSelected={selected.some(row => row.id === inventory.id)}
/>
)}

View File

@ -83,9 +83,13 @@ function InventorySourceList() {
fetchSources();
}, [fetchSources]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
sources
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(sources);
const {
isLoading: isDeleteLoading,
@ -140,7 +144,7 @@ function InventorySourceList() {
if (!deleteRelatedResourcesError) {
await handleDeleteSources();
}
setSelected([]);
clearSelected();
};
const canAdd =
sourceChoicesOptions &&
@ -164,14 +168,13 @@ function InventorySourceList() {
itemCount={sourceCount}
pluralizedItemName={t`Inventory Sources`}
qsConfig={QS_CONFIG}
clearSelected={clearSelected}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...sources] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -306,7 +306,7 @@ function NotificationTemplateDetail({ template, defaultMessages }) {
label={t`HTTP Headers`}
value={JSON.stringify(configuration.headers)}
mode="json"
rows="6"
rows={6}
dataCy="nt-detail-webhook-headers"
/>
</>

View File

@ -82,9 +82,13 @@ function NotificationTemplatesList() {
fetchTemplates();
}, [fetchTemplates]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
templates
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(templates);
const {
isLoading: isDeleteLoading,
@ -106,7 +110,7 @@ function NotificationTemplatesList() {
const handleDelete = async () => {
await deleteTemplates();
setSelected([]);
clearSelected();
};
const addTestToast = useCallback(notification => {
@ -133,7 +137,7 @@ function NotificationTemplatesList() {
itemCount={count}
pluralizedItemName={t`Notification Templates`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -176,7 +180,7 @@ function NotificationTemplatesList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={set => setSelected(set ? [...templates] : [])}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
@ -17,6 +17,7 @@ import PaginatedTable, {
HeaderCell,
} from '../../../components/PaginatedTable';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useSelected from '../../../util/useSelected';
import OrganizationListItem from './OrganizationListItem';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
@ -30,8 +31,6 @@ function OrganizationsList() {
const location = useLocation();
const match = useRouteMatch();
const [selected, setSelected] = useState([]);
const addUrl = `${match.url}/add`;
const {
@ -77,8 +76,14 @@ function OrganizationsList() {
fetchOrganizations();
}, [fetchOrganizations]);
const isAllSelected =
selected.length === organizations.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(organizations);
const {
isLoading: isDeleteLoading,
deleteItems: deleteOrganizations,
@ -99,23 +104,12 @@ function OrganizationsList() {
const handleOrgDelete = async () => {
await deleteOrganizations();
setSelected([]);
clearSelected();
};
const hasContentLoading = isDeleteLoading || isOrgsLoading;
const canAdd = actions && actions.POST;
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...organizations] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const deleteDetailsRequests = relatedResourceDeleteRequests.organization(
selected[0]
);
@ -131,6 +125,7 @@ function OrganizationsList() {
itemCount={organizationCount}
pluralizedItemName={t`Organizations`}
qsConfig={QS_CONFIG}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -165,7 +160,7 @@ function OrganizationsList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -1,4 +1,4 @@
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
@ -17,6 +17,7 @@ import PaginatedTable, {
HeaderCell,
} from '../../../components/PaginatedTable';
import useWsProjects from './useWsProjects';
import useSelected from '../../../util/useSelected';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
import { getQSConfig, parseQueryString } from '../../../util/qs';
@ -31,7 +32,6 @@ const QS_CONFIG = getQSConfig('project', {
function ProjectList() {
const location = useLocation();
const match = useRouteMatch();
const [selected, setSelected] = useState([]);
const {
result: {
@ -78,8 +78,15 @@ function ProjectList() {
const projects = useWsProjects(results);
const isAllSelected =
selected.length === projects.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
setSelected,
selectAll,
clearSelected,
} = useSelected(projects);
const {
isLoading: isDeleteLoading,
deleteItems: deleteProjects,
@ -104,24 +111,12 @@ function ProjectList() {
const hasContentLoading = isDeleteLoading || isLoading;
const canAdd = actions && actions.POST;
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...projects] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const deleteDetailsRequests = relatedResourceDeleteRequests.project(
selected[0]
);
return (
<Fragment>
<>
<PageSection>
<Card>
<PaginatedTable
@ -131,7 +126,7 @@ function ProjectList() {
itemCount={itemCount}
pluralizedItemName={t`Projects`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -182,7 +177,7 @@ function ProjectList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
@ -239,7 +234,7 @@ function ProjectList() {
{t`Failed to delete one or more projects.`}
<ErrorDetail error={deletionError} />
</AlertModal>
</Fragment>
</>
);
}

View File

@ -52,7 +52,7 @@ function SubscriptionModal({
[]
);
const { selected, handleSelect } = useSelected(subscriptions);
const { selected, setSelected } = useSelected(subscriptions);
function handleConfirm() {
const [subscription] = selected;
@ -64,6 +64,14 @@ function SubscriptionModal({
fetchSubscriptions();
}, [fetchSubscriptions]);
const handleSelect = item => {
if (selected.some(s => s.pool_id === item.pool_id)) {
setSelected(selected.filter(s => s.pool_id !== item.pool_id));
} else {
setSelected(selected.concat(item));
}
};
useEffect(() => {
if (selectedSubscription?.pool_id) {
handleSelect({ pool_id: selectedSubscription.pool_id });

View File

@ -1,4 +1,4 @@
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro';
@ -17,6 +17,7 @@ import {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import TeamListItem from './TeamListItem';
@ -30,7 +31,6 @@ const QS_CONFIG = getQSConfig('team', {
function TeamList() {
const location = useLocation();
const match = useRouteMatch();
const [selected, setSelected] = useState([]);
const {
result: {
@ -75,7 +75,14 @@ function TeamList() {
fetchTeams();
}, [fetchTeams]);
const isAllSelected = selected.length === teams.length && selected.length > 0;
const {
selected,
isAllSelected,
handleSelect,
selectAll,
clearSelected,
} = useSelected(teams);
const {
isLoading: isDeleteLoading,
deleteItems: deleteTeams,
@ -94,26 +101,14 @@ function TeamList() {
const handleTeamDelete = async () => {
await deleteTeams();
setSelected([]);
clearSelected();
};
const hasContentLoading = isDeleteLoading || isLoading;
const canAdd = actions && actions.POST;
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...teams] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
return (
<Fragment>
<>
<PageSection>
<Card>
<PaginatedTable
@ -123,7 +118,7 @@ function TeamList() {
itemCount={itemCount}
pluralizedItemName={t`Teams`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -161,7 +156,7 @@ function TeamList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
@ -208,7 +203,7 @@ function TeamList() {
{t`Failed to delete one or more teams.`}
<ErrorDetail error={deletionError} />
</AlertModal>
</Fragment>
</>
);
}

View File

@ -19,6 +19,7 @@ import { ToolbarAddButton } from '../../../components/PaginatedDataList';
import SurveyListItem from './SurveyListItem';
import SurveyToolbar from './SurveyToolbar';
import SurveyPreviewModal from './SurveyPreviewModal';
import useSelected from '../../../util/useSelected';
const Button = styled(_Button)`
margin: 20px;
@ -36,15 +37,16 @@ function SurveyList({
const match = useRouteMatch();
const questions = survey?.spec || [];
const [selected, setSelected] = useState([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const isAllSelected =
selected.length === questions?.length && selected.length > 0;
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...questions] : []);
};
const {
selected,
isAllSelected,
setSelected,
selectAll,
clearSelected,
} = useSelected(questions);
const handleSelect = item => {
if (selected.some(q => q.variable === item.variable)) {
@ -61,7 +63,7 @@ function SurveyList({
await updateSurvey(questions.filter(q => !selected.includes(q)));
}
setIsDeleteModalOpen(false);
setSelected([]);
clearSelected();
};
const moveUp = question => {
@ -91,7 +93,7 @@ function SurveyList({
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setSelected([]);
clearSelected();
}}
actions={[
<Button
@ -110,7 +112,7 @@ function SurveyList({
aria-label={t`cancel delete`}
onClick={() => {
setIsDeleteModalOpen(false);
setSelected([]);
clearSelected();
}}
>
{t`Cancel`}
@ -181,7 +183,7 @@ function SurveyList({
<>
<SurveyToolbar
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={selectAll}
surveyEnabled={surveyEnabled}
onToggleSurvey={toggleSurvey}
isDeleteDisabled={selected?.length === 0}

View File

@ -73,9 +73,13 @@ function UserList() {
fetchUsers();
}, [fetchUsers]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
users
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(users);
const {
isLoading: isDeleteLoading,
@ -95,7 +99,7 @@ function UserList() {
const handleUserDelete = async () => {
await deleteUsers();
setSelected([]);
clearSelected();
};
const hasContentLoading = isDeleteLoading || isLoading;
@ -112,7 +116,7 @@ function UserList() {
itemCount={itemCount}
pluralizedItemName={t`Users`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Username`,
@ -135,9 +139,7 @@ function UserList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...users] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -85,9 +85,13 @@ function UserTeamList() {
fetchTeams();
}, [fetchTeams]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
teams
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(teams);
const disassociateUserRoles = team => {
return [
@ -141,7 +145,7 @@ function UserTeamList() {
const handleDisassociate = async () => {
await disassociateTeams();
setSelected([]);
clearSelected();
};
const { error, dismissError } = useDismissableError(
@ -176,7 +180,7 @@ function UserTeamList() {
itemCount={count}
pluralizedItemName={t`Teams`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
@ -200,9 +204,7 @@ function UserTeamList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...teams] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd

View File

@ -84,9 +84,13 @@ function WorkflowApprovalsList() {
QS_CONFIG
);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
workflowApprovals
);
const {
selected,
isAllSelected,
handleSelect,
clearSelected,
selectAll,
} = useSelected(workflowApprovals);
const {
isLoading: isDeleteLoading,
@ -108,7 +112,7 @@ function WorkflowApprovalsList() {
const handleDelete = async () => {
await deleteWorkflowApprovals();
setSelected([]);
clearSelected();
};
const {
@ -126,7 +130,7 @@ function WorkflowApprovalsList() {
const handleApprove = async () => {
await approveWorkflowApprovals();
setSelected([]);
clearSelected();
};
const {
@ -144,7 +148,7 @@ function WorkflowApprovalsList() {
const handleDeny = async () => {
await denyWorkflowApprovals();
setSelected([]);
clearSelected();
};
const {
@ -168,7 +172,7 @@ function WorkflowApprovalsList() {
itemCount={count}
pluralizedItemName={t`Workflow Approvals`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchColumns={[
{
name: t`Name`,
@ -187,9 +191,7 @@ function WorkflowApprovalsList() {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={set =>
setSelected(set ? [...workflowApprovals] : [])
}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<WorkflowApprovalListApproveButton

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
/**
* useSelected hook provides a way to read and update a selected list
@ -8,6 +8,7 @@ import { useState } from 'react';
* isAllSelected: boolean that indicates if all items are selected
* handleSelect: function that adds and removes items from selected list
* setSelected: setter function
* clearSelected: de-select all items
* }
*/
@ -16,6 +17,9 @@ export default function useSelected(list = []) {
const isAllSelected = selected.length > 0 && selected.length === list.length;
const handleSelect = row => {
if (!row.id) {
throw new Error(`Selected row does not have an id`);
}
if (selected.some(s => s.id === row.id)) {
setSelected(prevState => [...prevState.filter(i => i.id !== row.id)]);
} else {
@ -23,5 +27,23 @@ export default function useSelected(list = []) {
}
};
return { selected, isAllSelected, handleSelect, setSelected };
const selectAll = useCallback(
isSelected => {
setSelected(isSelected ? [...list] : []);
},
[list]
);
const clearSelected = useCallback(() => {
setSelected([]);
}, []);
return {
selected,
isAllSelected,
handleSelect,
setSelected,
selectAll,
clearSelected,
};
}

View File

@ -19,6 +19,8 @@ describe('useSelected hook', () => {
let isAllSelected;
let handleSelect;
let setSelected;
let selectAll;
let clearSelected;
test('should return expected initial values', () => {
testHook(() => {
@ -72,4 +74,51 @@ describe('useSelected hook', () => {
expect(selected).toEqual([]);
expect(isAllSelected).toEqual(false);
});
test('should return selectAll', () => {
testHook(() => {
({
selected,
isAllSelected,
handleSelect,
setSelected,
selectAll,
} = useSelected(array));
});
act(() => {
selectAll(true);
});
expect(isAllSelected).toEqual(true);
expect(selected).toEqual(array);
act(() => {
selectAll(false);
});
expect(isAllSelected).toEqual(false);
expect(selected).toEqual([]);
});
test('should return clearSelected', () => {
testHook(() => {
({
selected,
isAllSelected,
handleSelect,
setSelected,
selectAll,
clearSelected,
} = useSelected(array));
});
act(() => {
selectAll(true);
});
act(() => {
clearSelected();
});
expect(isAllSelected).toEqual(false);
expect(selected).toEqual([]);
});
});