import React, { ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useErrorHandler } from "react-error-boundary"; 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 { asyncStateFetch } from "../../context/auth/AdminClient"; import { ListEmptyState } from "../list-empty-state/ListEmptyState"; type Row = { data: T; selected: boolean; cells: (keyof T | JSX.Element)[]; }; type DataTableProps = { ariaLabelKey: string; columns: Field[]; rows: Row[]; actions?: IActions; actionResolver?: IActionsResolver; onSelect?: (isSelected: boolean, rowIndex: number) => void; canSelectAll: boolean; }; function DataTable({ columns, rows, actions, actionResolver, ariaLabelKey, onSelect, canSelectAll, }: DataTableProps) { const { t } = useTranslation(); return ( onSelect(isSelected, rowIndex) : undefined } canSelectAll={canSelectAll} cells={columns.map((column) => { return { ...column, title: t(column.displayKey || column.name) }; })} rows={rows} actions={actions} actionResolver={actionResolver} aria-label={t(ariaLabelKey)} >
); } export type Field = { name: string; displayKey?: string; cellFormatters?: IFormatter[]; transforms?: ITransform[]; cellRenderer?: (row: T) => ReactNode; }; export type Action = IAction & { onRowClick?: (row: T) => Promise | void; }; export type DataListProps = { loader: (first?: number, max?: number, search?: string) => Promise; onSelect?: (value: T[]) => void; canSelectAll?: boolean; isPaginated?: boolean; ariaLabelKey: string; searchPlaceholderKey?: string; columns: Field[]; actions?: Action[]; actionResolver?: IActionsResolver; searchTypeComponent?: ReactNode; toolbarItem?: ReactNode; emptyState?: ReactNode; }; /** * 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 * Promise} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true * @param {Field} props.columns - definition of the 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({ ariaLabelKey, searchPlaceholderKey, isPaginated = false, onSelect, canSelectAll = false, loader, columns, actions, actionResolver, searchTypeComponent, toolbarItem, emptyState, }: DataListProps) { const { t } = useTranslation(); const [selected, setSelected] = useState([]); const [rows, setRows] = useState[]>(); const [unPaginatedData, setUnPaginatedData] = useState(); const [filteredData, setFilteredData] = useState[]>(); const [loading, setLoading] = useState(false); const [max, setMax] = useState(10); const [first, setFirst] = useState(0); const [search, setSearch] = useState(""); const [key, setKey] = useState(0); const refresh = () => setKey(new Date().getTime()); const handleError = useErrorHandler(); useEffect(() => { return asyncStateFetch( async () => { setLoading(true); let data = unPaginatedData || (await loader(first, max, search)); if (!isPaginated) { setUnPaginatedData(data); data = data.slice(first, first + max); } return convertToColumns(data); }, (result) => { setRows(result); setFilteredData(result); setLoading(false); }, handleError ); }, [key, first, max, search]); const getNodeText = (node: keyof T | JSX.Element): 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(node.props.children); } return ""; }; const convertToColumns = (data: T[]) => { return data!.map((value) => { return { data: value, selected: !!selected.find((v) => (v as any).id === (value as any).id), cells: columns.map((col) => { if (col.cellRenderer) { return col.cellRenderer(value); } return _.get(value, col.name); }), }; }); }; const filter = (search: string) => { setFilteredData( convertToColumns(unPaginatedData!).filter((row) => row.cells.some( (cell) => cell && getNodeText(cell).toLowerCase().includes(search.toLowerCase()) ) ) ); setSearch; }; const convertAction = () => actions && _.cloneDeep(actions).map((action: Action, index: number) => { delete action.onRowClick; action.onClick = async (_, rowIndex) => { const result = await actions[index].onRowClick!( (filteredData || rows)![rowIndex].data ); if (result) { refresh(); } }; return action; }); const Loading = () => (
); const _onSelect = (isSelected: boolean, rowIndex: number) => { if (rowIndex === -1) { setRows( rows!.map((row) => { row.selected = isSelected; return row; }) ); } else { rows![rowIndex].selected = isSelected; setRows([...rows!]); } const difference = _.differenceBy( selected, rows!.map((row) => row.data), "id" ); const selectedRows = [ ...difference, ...rows!.filter((row) => row.selected).map((row) => row.data), ]; setSelected(selectedRows); onSelect!(selectedRows); }; return ( <> {rows && ( { setFirst(first); setMax(max); }} inputGroupName={ searchPlaceholderKey ? `${ariaLabelKey}input` : undefined } inputGroupOnEnter={ isPaginated ? setSearch : (search) => filter(search) } inputGroupPlaceholder={t(searchPlaceholderKey || "")} searchTypeComponent={searchTypeComponent} toolbarItem={toolbarItem} > {!loading && (filteredData || rows).length > 0 && ( )} {!loading && rows.length === 0 && search !== "" && ( )} {loading && } )} <>{!loading && rows?.length === 0 && search === "" && emptyState} ); }