import React, { ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useErrorHandler } from "react-error-boundary"; import { IAction, IActions, IActionsResolver, IFormatter, Table, TableBody, TableHeader, TableVariant, } from "@patternfly/react-table"; import { Spinner } from "@patternfly/react-core"; import _ from "lodash"; import { PaginatingTableToolbar } from "./PaginatingTableToolbar"; import { TableToolbar } from "./TableToolbar"; import { asyncStateFetch } from "../../context/auth/AdminClient"; 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[]; 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; setRefresher?: (refresher: () => void) => void; 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, setRefresher, canSelectAll = false, loader, columns, actions, actionResolver, searchTypeComponent, toolbarItem, emptyState, }: DataListProps) { const { t } = useTranslation(); const [rows, setRows] = 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(() => { setRefresher && setRefresher(refresh); }, []); useEffect(() => { return asyncStateFetch( async () => { setLoading(true); const data = await loader(first, max, search); const result = data!.map((value) => { return { data: value, selected: false, cells: columns.map((col) => { if (col.cellRenderer) { return col.cellRenderer(value); } return _.get(value, col.name); }), }; }); return result; }, (result) => { setRows(result); setFilteredData(result); setLoading(false); }, handleError ); }, [key, first, max]); 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 filter = (search: string) => { setFilteredData( rows!.filter((row) => row.cells.some( (cell) => cell && getNodeText(cell).toLowerCase().includes(search.toLowerCase()) ) ) ); }; 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 searchOnChange = (value: string) => { if (isPaginated) { setSearch(value); } else { filter(value); } }; 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!]); } onSelect!(rows!.filter((row) => row.selected).map((row) => row.data)); }; return ( <> {!rows && } {rows && isPaginated && ( { setFirst(first); setMax(max); }} inputGroupName={`${ariaLabelKey}input`} inputGroupOnChange={searchOnChange} inputGroupOnClick={refresh} inputGroupPlaceholder={t(searchPlaceholderKey)} searchTypeComponent={searchTypeComponent} toolbarItem={toolbarItem} > {!loading && (emptyState === undefined || rows.length !== 0) && ( )} {!loading && rows.length === 0 && emptyState} {loading && } )} {rows && !isPaginated && ( {}} inputGroupPlaceholder={t(searchPlaceholderKey)} toolbarItem={toolbarItem} searchTypeComponent={searchTypeComponent} > {(emptyState === undefined || rows.length !== 0) && ( )} {rows.length === 0 && emptyState} )} ); }