keycloak-scim/src/components/table-toolbar/KeycloakDataTable.tsx
2021-06-22 09:17:11 -04:00

433 lines
12 KiB
TypeScript

import React, {
isValidElement,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
IAction,
IActions,
IActionsResolver,
IFormatter,
ITransform,
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
import { Spinner } from "@patternfly/react-core";
import _ from "lodash";
import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
import { useFetch } from "../../context/auth/AdminClient";
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
type TitleCell = { title: JSX.Element };
type Cell<T> = keyof T | JSX.Element | TitleCell;
type BaseRow<T> = {
data: T;
cells: Cell<T>[];
};
type Row<T> = BaseRow<T> & {
selected: boolean;
isOpen: boolean;
disableSelection: boolean;
disableActions: boolean;
};
type SubRow<T> = BaseRow<T> & {
parent: number;
};
type DataTableProps<T> = {
ariaLabelKey: string;
columns: Field<T>[];
rows: (Row<T> | SubRow<T>)[];
actions?: IActions;
actionResolver?: IActionsResolver;
onSelect?: (isSelected: boolean, rowIndex: number) => void;
onCollapse?: (isOpen: boolean, rowIndex: number) => void;
canSelectAll: boolean;
isNotCompact?: boolean;
isRadio?: boolean;
};
function DataTable<T>({
columns,
rows,
actions,
actionResolver,
ariaLabelKey,
onSelect,
onCollapse,
canSelectAll,
isNotCompact,
isRadio,
...props
}: DataTableProps<T>) {
const { t } = useTranslation();
return (
<Table
{...props}
variant={isNotCompact ? undefined : TableVariant.compact}
onSelect={
onSelect
? (_, isSelected, rowIndex) => onSelect(isSelected, rowIndex)
: undefined
}
onCollapse={
onCollapse
? (_, rowIndex, isOpen) => onCollapse(isOpen, rowIndex)
: undefined
}
selectVariant={isRadio ? "radio" : "checkbox"}
canSelectAll={canSelectAll}
cells={columns.map((column) => {
return { ...column, title: t(column.displayKey || column.name) };
})}
rows={rows}
actions={actions}
actionResolver={actionResolver}
aria-label={t(ariaLabelKey)}
>
<TableHeader />
<TableBody />
</Table>
);
}
export type Field<T> = {
name: string;
displayKey?: string;
cellFormatters?: IFormatter[];
transforms?: ITransform[];
cellRenderer?: (row: T) => ReactNode;
};
export type DetailField<T> = {
name: string;
enabled?: (row: T) => boolean;
cellRenderer?: (row: T) => ReactNode;
};
export type Action<T> = IAction & {
onRowClick?: (row: T) => Promise<boolean> | void;
};
export type DataListProps<T> = {
loader: (first?: number, max?: number, search?: string) => Promise<T[]>;
onSelect?: (value: T[]) => void;
canSelectAll?: boolean;
detailColumns?: DetailField<T>[];
isRowDisabled?: (value: T) => boolean;
isPaginated?: boolean;
ariaLabelKey: string;
searchPlaceholderKey?: string;
columns: Field<T>[];
actions?: Action<T>[];
actionResolver?: IActionsResolver;
searchTypeComponent?: ReactNode;
toolbarItem?: ReactNode;
subToolbar?: ReactNode;
emptyState?: ReactNode;
icon?: React.ComponentClass<SVGIconProps>;
isNotCompact?: boolean;
isRadio?: boolean;
};
/**
* A generic component that can be used to show the initial list most sections have. Takes care of the loading of the date and filtering.
* All you have to define is how the columns are displayed.
* @example
* <KeycloakDataTable columns={[
* {
* name: "clientId", //name of the field from the array of object the loader returns to display in this column
* displayKey: "common:clientId", //i18n key to use to lookup the name of the column header
* cellRenderer: ClientDetailLink, //optionally you can use a component to render the column when you don't want just the content of the field, the whole row / entire object is passed in.
* }
* ]}
* @param {DataListProps} props - The properties.
* @param {string} props.ariaLabelKey - The aria label key i18n key to lookup the label
* @param {string} props.searchPlaceholderKey - The i18n key to lookup the placeholder for the search box
* @param {boolean} props.isPaginated - if true the the loader will be called with first, max and search and a pager will be added in the header
* @param {(first?: number, max?: number, search?: string) => Promise<T[]>} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true
* @param {Field<T>} props.columns - definition of the columns
* @param {Field<T>} props.detailColumns - definition of the columns expandable columns
* @param {Action[]} props.actions - the actions that appear on the row
* @param {IActionsResolver} props.actionResolver Resolver for the given action
* @param {ReactNode} props.toolbarItem - Toolbar items that appear on the top of the table {@link ToolbarItem}
* @param {ReactNode} props.emptyState - ReactNode show when the list is empty could be any component but best to use {@link ListEmptyState}
*/
export function KeycloakDataTable<T>({
ariaLabelKey,
searchPlaceholderKey,
isPaginated = false,
onSelect,
canSelectAll = false,
isNotCompact,
isRadio,
detailColumns,
isRowDisabled,
loader,
columns,
actions,
actionResolver,
searchTypeComponent,
toolbarItem,
subToolbar,
emptyState,
icon,
...props
}: DataListProps<T>) {
const { t } = useTranslation();
const [selected, setSelected] = useState<T[]>([]);
const [rows, setRows] = useState<(Row<T> | SubRow<T>)[]>();
const [unPaginatedData, setUnPaginatedData] = useState<T[]>();
const [loading, setLoading] = useState(false);
const [max, setMax] = useState(10);
const [first, setFirst] = useState(0);
const [search, setSearch] = useState<string>("");
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const convertToColumns = (data: T[]): (Row<T> | SubRow<T>)[] => {
return data
.map((value, index) => {
const disabledRow = isRowDisabled ? isRowDisabled(value) : false;
const row: (Row<T> | SubRow<T>)[] = [
{
data: value,
disableSelection: disabledRow,
disableActions: disabledRow,
selected: !!selected.find(
(v) => _.get(v, "id") === _.get(value, "id")
),
isOpen: false,
cells: columns.map((col) => {
if (col.cellRenderer) {
return { title: col.cellRenderer(value) };
}
return _.get(value, col.name);
}),
},
];
if (
detailColumns &&
detailColumns[0] &&
detailColumns[0].enabled &&
detailColumns[0].enabled(value)
) {
row.push({
parent: index * 2,
cells: detailColumns!.map((col) => {
if (col.cellRenderer) {
return { title: col.cellRenderer(value) };
}
return _.get(value, col.name);
}),
} as SubRow<T>);
}
return row;
})
.flat();
};
const getNodeText = (node: Cell<T>): string => {
if (["string", "number"].includes(typeof node)) {
return node!.toString();
}
if (node instanceof Array) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
return getNodeText(
isValidElement((node as TitleCell).title)
? (node as TitleCell).title.props.children
: (node as TitleCell).title
? (node as TitleCell).title
: (node as JSX.Element).props.children
);
}
return "";
};
const filteredData = useMemo<(Row<T> | SubRow<T>)[] | undefined>(
() =>
search === "" || isPaginated
? undefined
: convertToColumns(unPaginatedData || []).filter((row) =>
row.cells.some(
(cell) =>
cell &&
getNodeText(cell).toLowerCase().includes(search.toLowerCase())
)
),
[search]
);
useEffect(() => {
if (canSelectAll) {
const checkboxes = document
.getElementsByClassName("pf-c-table__check")
.item(0);
if (checkboxes) {
const checkAllCheckbox = checkboxes.children!.item(
0
)! as HTMLInputElement;
checkAllCheckbox.indeterminate =
selected.length > 0 &&
selected.length < (filteredData || rows)!.length;
}
}
}, [selected]);
useFetch(
async () => {
setLoading(true);
return unPaginatedData || (await loader(first, max + 1, search));
},
(data) => {
if (!isPaginated) {
setUnPaginatedData(data);
data = data.slice(first, first + max + 1);
}
const result = convertToColumns(data);
setRows(result);
setLoading(false);
},
[key, first, max, search]
);
const convertAction = () =>
actions &&
_.cloneDeep(actions).map((action: Action<T>, index: number) => {
delete action.onRowClick;
action.onClick = async (_, rowIndex) => {
const result = await actions[index].onRowClick!(
(filteredData || rows)![rowIndex].data
);
if (result) {
if (!isPaginated) {
setSearch("");
}
refresh();
}
};
return action;
});
const Loading = () => (
<div className="pf-u-text-align-center">
<Spinner />
</div>
);
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 data = filteredData || rows;
const onCollapse = (isOpen: boolean, rowIndex: number) => {
(data![rowIndex] as Row<T>).isOpen = isOpen;
setRows([...data!]);
};
return (
<>
{!rows && loading && <Loading />}
{((data && data.length > 0) || search !== "" || !emptyState) && (
<PaginatingTableToolbar
count={data?.length || 0}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
}}
inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
}
inputGroupOnEnter={setSearch}
inputGroupPlaceholder={t(searchPlaceholderKey || "")}
searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem}
subToolbar={subToolbar}
>
{!loading && data && data.length > 0 && (
<DataTable
{...props}
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
onCollapse={detailColumns ? onCollapse : undefined}
actions={convertAction()}
actionResolver={actionResolver}
rows={data}
columns={columns}
isNotCompact={isNotCompact}
isRadio={isRadio}
ariaLabelKey={ariaLabelKey}
/>
)}
{!loading &&
(!data || data.length === 0) &&
(search !== "" || !emptyState) &&
searchPlaceholderKey && (
<ListEmptyState
hasIcon={true}
icon={icon}
isSearchVariant={true}
message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
/>
)}
{loading && <Loading />}
</PaginatingTableToolbar>
)}
<>
{!loading &&
(!data || data?.length === 0) &&
search === "" &&
emptyState}
</>
</>
);
}