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:
parent
21da25e146
commit
3728bc6c72
2 changed files with 70 additions and 74 deletions
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue