Keep the selection over page bounds (#33337)

see: https://github.com/keycloak/keycloak/issues/30976#issuecomment-2367086750

relates: #30976

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-10-23 11:39:21 +02:00 committed by GitHub
parent 21da25e146
commit 3728bc6c72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 70 additions and 74 deletions

View file

@ -1,4 +1,5 @@
import { Button, ButtonVariant, ToolbarItem } from "@patternfly/react-core"; import { Button, ButtonVariant, ToolbarItem } from "@patternfly/react-core";
import { SyncAltIcon } from "@patternfly/react-icons";
import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon"; import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
import { import {
ActionsColumn, ActionsColumn,
@ -19,7 +20,7 @@ import {
Thead, Thead,
Tr, Tr,
} from "@patternfly/react-table"; } from "@patternfly/react-table";
import { cloneDeep, differenceBy, get } from "lodash-es"; import { cloneDeep, get, intersectionBy } from "lodash-es";
import { import {
ComponentClass, ComponentClass,
ReactNode, ReactNode,
@ -32,13 +33,11 @@ import {
type JSX, type JSX,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useStoredState } from "../../utils/useStoredState";
import { useFetch } from "../../utils/useFetch"; import { useFetch } from "../../utils/useFetch";
import { useStoredState } from "../../utils/useStoredState";
import { KeycloakSpinner } from "../KeycloakSpinner";
import { ListEmptyState } from "./ListEmptyState"; import { ListEmptyState } from "./ListEmptyState";
import { PaginatingTableToolbar } from "./PaginatingTableToolbar"; import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
import { SyncAltIcon } from "@patternfly/react-icons";
import { KeycloakSpinner } from "../KeycloakSpinner";
type TitleCell = { title: JSX.Element }; type TitleCell = { title: JSX.Element };
type Cell<T> = keyof T | JSX.Element | TitleCell; type Cell<T> = keyof T | JSX.Element | TitleCell;
@ -65,9 +64,11 @@ type DataTableProps<T> = {
rows: (Row<T> | SubRow<T>)[]; rows: (Row<T> | SubRow<T>)[];
actions?: IActions; actions?: IActions;
actionResolver?: IActionsResolver; actionResolver?: IActionsResolver;
onSelect?: (isSelected: boolean, rowIndex: number) => void; selected?: T[];
onSelect?: (value: T[]) => void;
onCollapse?: (isOpen: boolean, rowIndex: number) => void; onCollapse?: (isOpen: boolean, rowIndex: number) => void;
canSelectAll: boolean; canSelectAll: boolean;
canSelect: boolean;
isNotCompact?: boolean; isNotCompact?: boolean;
isRadio?: boolean; isRadio?: boolean;
}; };
@ -90,37 +91,68 @@ function DataTable<T>({
actions, actions,
actionResolver, actionResolver,
ariaLabelKey, ariaLabelKey,
selected,
onSelect, onSelect,
onCollapse, onCollapse,
canSelectAll, canSelectAll,
canSelect,
isNotCompact, isNotCompact,
isRadio, isRadio,
...props ...props
}: DataTableProps<T>) { }: DataTableProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedRows, setSelectedRows] = useState<T[]>(selected || []);
const [selectedRows, setSelectedRows] = useState<boolean[]>([]);
const [expandedRows, setExpandedRows] = useState<boolean[]>([]); const [expandedRows, setExpandedRows] = useState<boolean[]>([]);
const updateState = (rowIndex: number, isSelected: boolean) => { const rowsSelectedOnPage = useMemo(
const items = [ () =>
...(rowIndex === -1 ? Array(rows.length).fill(isSelected) : selectedRows), intersectionBy(
]; selectedRows,
items[rowIndex] = isSelected; rows.map((row) => row.data),
setSelectedRows(items); "id",
}; ),
[selectedRows, rows],
);
useEffect(() => { useEffect(() => {
if (canSelectAll) { if (canSelectAll) {
const selectAllCheckbox = document.getElementsByName("check-all").item(0); const selectAllCheckbox = document.getElementsByName("check-all").item(0);
if (selectAllCheckbox) { if (selectAllCheckbox) {
const checkbox = selectAllCheckbox as HTMLInputElement; const checkbox = selectAllCheckbox as HTMLInputElement;
const selected = selectedRows.filter((r) => r === true);
checkbox.indeterminate = checkbox.indeterminate =
selected.length < rows.length && selected.length > 0; rowsSelectedOnPage.length < rows.length &&
rowsSelectedOnPage.length > 0;
} }
} }
}, [selectedRows]); }, [selectedRows, canSelectAll, rows]);
const updateSelectedRows = (selected: T[]) => {
setSelectedRows(selected);
onSelect?.(selected);
};
const updateState = (rowIndex: number, isSelected: boolean) => {
if (rowIndex === -1) {
const rowsSelectedOnPageIds = rowsSelectedOnPage.map((v) => get(v, "id"));
updateSelectedRows(
isSelected
? [...selectedRows, ...rows.map((row) => row.data)]
: selectedRows.filter(
(v) => !rowsSelectedOnPageIds.includes(get(v, "id")),
),
);
} else {
if (isSelected) {
updateSelectedRows([...selectedRows, rows[rowIndex].data]);
} else {
updateSelectedRows(
selectedRows.filter(
(v) => get(v, "id") !== (rows[rowIndex] as IRow).data.id,
),
);
}
}
};
return ( return (
<Table <Table
@ -133,16 +165,14 @@ function DataTable<T>({
{onCollapse && <Th />} {onCollapse && <Th />}
{canSelectAll && ( {canSelectAll && (
<Th <Th
aria-label={t("selectAll")}
select={ select={
!isRadio !isRadio
? { ? {
onSelect: (_, isSelected, rowIndex) => { onSelect: (_, isSelected) => {
onSelect!(isSelected, rowIndex);
updateState(-1, isSelected); updateState(-1, isSelected);
}, },
isSelected: isSelected: rowsSelectedOnPage.length === rows.length,
selectedRows.filter((r) => r === true).length ===
rows.length,
} }
: undefined : undefined
} }
@ -150,7 +180,7 @@ function DataTable<T>({
)} )}
{columns.map((column) => ( {columns.map((column) => (
<Th <Th
key={column.displayKey} key={column.displayKey || column.name}
className={column.transforms?.[0]().className} className={column.transforms?.[0]().className}
> >
{t(column.displayKey || column.name)} {t(column.displayKey || column.name)}
@ -162,15 +192,16 @@ function DataTable<T>({
<Tbody> <Tbody>
{(rows as IRow[]).map((row, index) => ( {(rows as IRow[]).map((row, index) => (
<Tr key={index} isExpanded={expandedRows[index]}> <Tr key={index} isExpanded={expandedRows[index]}>
{onSelect && ( {canSelect && (
<Td <Td
select={{ select={{
rowIndex: index, rowIndex: index,
onSelect: (_, isSelected, rowIndex) => { onSelect: (_, isSelected, rowIndex) => {
onSelect!(isSelected, rowIndex);
updateState(rowIndex, isSelected); updateState(rowIndex, isSelected);
}, },
isSelected: selectedRows[index], isSelected: !!selectedRows.find(
(v) => get(v, "id") === row.data.id,
),
variant: isRadio ? "radio" : "checkbox", variant: isRadio ? "radio" : "checkbox",
}} }}
/> />
@ -476,38 +507,6 @@ export function KeycloakDataTable<T>({
return action; return action;
}); });
const _onSelect = (isSelected: boolean, rowIndex: number) => {
const data = filteredData || rows;
if (rowIndex === -1) {
setRows(
data!.map((row) => {
(row as Row<T>).selected = isSelected;
return row;
}),
);
} else {
(data![rowIndex] as Row<T>).selected = isSelected;
setRows([...rows!]);
}
// Keeps selected items when paginating
const difference = differenceBy(
selected,
data!.map((row) => row.data),
"id",
);
// Selected rows are any rows previously selected from a different page, plus current page selections
const selectedRows = [
...difference,
...data!.filter((row) => (row as Row<T>).selected).map((row) => row.data),
];
setSelected(selectedRows);
onSelect!(selectedRows);
};
const onCollapse = (isOpen: boolean, rowIndex: number) => { const onCollapse = (isOpen: boolean, rowIndex: number) => {
(data![rowIndex] as Row<T>).isOpen = isOpen; (data![rowIndex] as Row<T>).isOpen = isOpen;
setRows([...data!]); setRows([...data!]);
@ -557,7 +556,12 @@ export function KeycloakDataTable<T>({
<DataTable <DataTable
{...props} {...props}
canSelectAll={canSelectAll} canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined} canSelect={!!onSelect}
selected={selected}
onSelect={(selected) => {
setSelected(selected);
onSelect?.(selected);
}}
onCollapse={detailColumns ? onCollapse : undefined} onCollapse={detailColumns ? onCollapse : undefined}
actions={convertAction()} actions={convertAction()}
actionResolver={actionResolver} actionResolver={actionResolver}

View file

@ -32,19 +32,14 @@ export const TableToolbar = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState<string>(""); const [searchValue, setSearchValue] = useState<string>("");
const onSearch = () => { const onSearch = (searchValue: string) => {
if (searchValue !== "") { setSearchValue(searchValue.trim());
setSearchValue(searchValue); inputGroupOnEnter?.(searchValue.trim());
inputGroupOnEnter?.(searchValue);
} else {
setSearchValue("");
inputGroupOnEnter?.("");
}
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { if (e.key === "Enter") {
onSearch(); onSearch(searchValue);
} }
}; };
@ -65,12 +60,9 @@ export const TableToolbar = ({
onChange={(_, value) => { onChange={(_, value) => {
setSearchValue(value); setSearchValue(value);
}} }}
onSearch={onSearch} onSearch={() => onSearch(searchValue)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onClear={() => { onClear={() => onSearch("")}
setSearchValue("");
inputGroupOnEnter?.("");
}}
/> />
)} )}
</InputGroup> </InputGroup>