/* eslint-disable */ // @ts-nocheck 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 { ActionsColumn, ExpandableRowContent, IAction, IActions, IActionsResolver, IFormatter, IRow, IRowCell, ITransform, Table, TableProps, TableVariant, Tbody, Td, Th, Thead, Tr, } from "../../../@patternfly/react-table"; import { cloneDeep, get, intersectionBy } from "lodash-es"; import { ComponentClass, ReactNode, isValidElement, useEffect, useId, useMemo, useRef, useState, type JSX, } from "react"; import { useTranslation } from "react-i18next"; import { useFetch } from "../../utils/useFetch"; import { useStoredState } from "../../utils/useStoredState"; import { KeycloakSpinner } from "../KeycloakSpinner"; import { ListEmptyState } from "./ListEmptyState"; import { PaginatingTableToolbar } from "./PaginatingTableToolbar"; type TitleCell = { title: JSX.Element }; type Cell = keyof T | JSX.Element | TitleCell; type BaseRow = { data: T; cells: Cell[]; }; type Row = BaseRow & { selected: boolean; isOpen?: boolean; disableSelection: boolean; disableActions: boolean; }; type SubRow = BaseRow & { parent: number; }; type DataTableProps = { ariaLabelKey: string; columns: Field[]; rows: (Row | SubRow)[]; actions?: IActions; actionResolver?: IActionsResolver; selected?: T[]; onSelect?: (value: T[]) => void; onCollapse?: (isOpen: boolean, rowIndex: number) => void; canSelectAll: boolean; canSelect: boolean; isNotCompact?: boolean; isRadio?: boolean; }; type CellRendererProps = { row: IRow; index?: number; actions?: IActions; actionResolver?: IActionsResolver; }; const isRow = (c: ReactNode | IRowCell): c is IRowCell => !!c && (c as IRowCell).title !== undefined; const CellRenderer = ({ row, index, actions, actionResolver, }: CellRendererProps) => { const items = actions || actionResolver?.(row, {}); return ( <> {row.cells!.map((c, i) => ( {(isRow(c) ? c.title : c) as ReactNode} ))} {items && items.length > 0 && !row.disableActions && ( )} ); }; const ExpandableRowRenderer = ({ row }: CellRendererProps) => row.cells!.map((c, i) => (
{(isRow(c) ? c.title : c) as ReactNode}
)); function DataTable({ columns, rows, actions, actionResolver, ariaLabelKey, selected, onSelect, onCollapse, canSelectAll, canSelect, isNotCompact, isRadio, ...props }: DataTableProps) { const { t } = useTranslation(); const [selectedRows, setSelectedRows] = useState(selected || []); const [expandedRows, setExpandedRows] = useState([]); const rowsSelectedOnPage = useMemo( () => intersectionBy( selectedRows, rows.map((row) => row.data), "id", ), [selectedRows, rows], ); useEffect(() => { if (canSelectAll) { const selectAllCheckbox = document.getElementsByName("check-all").item(0); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- DOM query can return null at runtime if (selectAllCheckbox) { const checkbox = selectAllCheckbox as HTMLInputElement; checkbox.indeterminate = rowsSelectedOnPage.length < rows.length && rowsSelectedOnPage.length > 0; } } }, [selectedRows, canSelectAll, rows]); const updateSelectedRows = (selected: T[]) => { setSelectedRows(selected); onSelect?.(selected); }; const updateState = (rowIndex: number, isSelected: boolean) => { if (isRadio) { const selectedRow = isSelected ? [rows[rowIndex].data] : []; updateSelectedRows(selectedRow); } else { 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 ( {onCollapse && ))} {!onCollapse ? ( {(rows as IRow[]).map((row, index) => ( {canSelect && ( ))} ) : ( (rows as IRow[]).map((row, index) => ( {index % 2 === 0 ? ( ) : ( )} )) )}
} {canSelectAll && ( { updateState(-1, isSelected); }, isSelected: rowsSelectedOnPage.length === rows.length, } : undefined } /> )} {columns.map((column) => ( {t(column.displayKey || column.name)}
{ updateState(rowIndex, isSelected); }, isSelected: !!selectedRows.find( (v) => get(v, "id") === row.data.id, ), variant: isRadio ? "radio" : "checkbox", isDisabled: row.disableSelection, }} /> )}
{ onCollapse(isOpen, rowIndex); const expand = [...expandedRows]; expand[index] = isOpen; setExpandedRows(expand); }, } } />
); } export type Field = { name: string; displayKey?: string; cellFormatters?: IFormatter[]; transforms?: ITransform[]; cellRenderer?: (row: T) => JSX.Element | string; }; export type DetailField = { name: string; enabled?: (row: T) => boolean; cellRenderer?: (row: T) => JSX.Element | string; }; export type Action = IAction & { onRowClick?: (row: T) => Promise | void; }; export type LoaderFunction = ( first?: number, max?: number, search?: string, ) => Promise; export type SignaledLoader = { readonly signal: any; loader: LoaderFunction; }; export type DataListProps = Omit< TableProps, "rows" | "cells" | "onSelect" > & { loader: T[] | LoaderFunction | SignaledLoader; onSelect?: (value: T[]) => void; canSelectAll?: boolean; detailColumns?: DetailField[]; isRowDisabled?: (value: T) => boolean; isPaginated?: boolean; ariaLabelKey: string; searchPlaceholderKey?: string; columns: Field[]; actions?: Action[]; actionResolver?: IActionsResolver; searchTypeComponent?: ReactNode; toolbarItem?: ReactNode; subToolbar?: ReactNode; emptyState?: ReactNode; icon?: ComponentClass; isNotCompact?: boolean; isRadio?: boolean; isSearching?: 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 * 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 {Field} 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({ ariaLabelKey, searchPlaceholderKey, isPaginated = false, onSelect, canSelectAll = false, isNotCompact, isRadio, detailColumns, isRowDisabled, loader, columns, actions, actionResolver, searchTypeComponent, toolbarItem, subToolbar, emptyState, icon, isSearching = false, ...props }: DataListProps) { const { t } = useTranslation(); const [selected, setSelected] = useState([]); const [rows, setRows] = useState<(Row | SubRow)[]>(); const [unPaginatedData, setUnPaginatedData] = useState(); const [loading, setLoading] = useState(false); const [defaultPageSize, setDefaultPageSize] = useStoredState( localStorage, "pageSize", 10, ); const [max, setMax] = useState(defaultPageSize); const [first, setFirst] = useState(0); const [search, setSearch] = useState(""); const prevSearch = useRef(); const [key, setKey] = useState(0); const prevKey = useRef(); const refresh = () => setKey(key + 1); const id = useId(); const renderCell = (columns: (Field | DetailField)[], value: T) => { return columns.map((col) => { if ("cellFormatters" in col) { const v = get(value, col.name); return col.cellFormatters?.reduce((s, f) => f(s), v); } if (col.cellRenderer) { const Component = col.cellRenderer; //@ts-ignore return { title: }; } return get(value, col.name); }); }; const convertToColumns = (data: T[]): (Row | SubRow)[] => { const isDetailColumnsEnabled = (value: T) => detailColumns?.[0]?.enabled?.(value); return data .map((value, index) => { const disabledRow = isRowDisabled ? isRowDisabled(value) : false; const row: (Row | SubRow)[] = [ { data: value, disableSelection: disabledRow, disableActions: disabledRow, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- lodash get performs dynamic property access at runtime selected: !!selected.find((v) => get(v, "id") === get(value, "id")), isOpen: isDetailColumnsEnabled(value) ? false : undefined, cells: renderCell(columns, value), }, ]; if (detailColumns) { row.push({ parent: index * 2, cells: isDetailColumnsEnabled(value) ? renderCell(detailColumns!, value) : [], } as SubRow); } return row; }) .flat(); }; const getNodeText = (node: Cell): string => { if (["string", "number"].includes(typeof node)) { return node!.toString(); } if (node instanceof Array) { return node.map(getNodeText).join(""); } if (typeof node === "object") { return getNodeText( isValidElement((node as TitleCell).title) ? (node as TitleCell).title.props : Object.values(node), ); } return ""; }; const filteredData = useMemo<(Row | SubRow)[] | undefined>( () => search === "" || isPaginated ? undefined : convertToColumns(unPaginatedData || []) .filter((row) => row.cells.some( (cell) => cell && getNodeText(cell) .toLowerCase() .includes(search.toLowerCase()), ), ) .slice(first, first + max + 1), [search, first, max], ); useFetch( async () => { setLoading(true); const newSearch = prevSearch.current === "" && search !== ""; if (newSearch) { setFirst(0); } prevSearch.current = search; const loaderFn = typeof loader === "function" ? loader : "loader" in loader ? loader.loader : async () => loader; return await loaderFn(newSearch ? 0 : first, max + 1, search); }, (data) => { prevKey.current = key; if (!isPaginated) { setUnPaginatedData(data); if (data.length > first) { data = data.slice(first, first + max + 1); } else { setFirst(0); } } const result = convertToColumns(data); setRows(result); setLoading(false); }, [ key, first, max, search, typeof loader !== "function" ? "signal" in loader ? loader.signal : loader : undefined, ], ); 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) { if (!isPaginated) { setSearch(""); } refresh(); } }; return action; }); const onCollapse = (isOpen: boolean, rowIndex: number) => { (data![rowIndex] as Row).isOpen = isOpen; setRows([...data!]); }; const data = filteredData || rows; const noData = !data || data.length === 0; const searching = search !== "" || isSearching; // if we use detail columns there are twice the number of rows const maxRows = detailColumns ? max * 2 : max; const rowLength = detailColumns ? (data?.length || 0) / 2 : data?.length || 0; return ( <> {(!noData || searching) && ( { setFirst(first); setMax(max); setDefaultPageSize(max); }} inputGroupName={ searchPlaceholderKey ? `${ariaLabelKey}input` : undefined } inputGroupOnEnter={setSearch} inputGroupPlaceholder={t(searchPlaceholderKey || "")} searchTypeComponent={searchTypeComponent} toolbarItem={ <> {toolbarItem} {" "} } subToolbar={subToolbar} > {!loading && !noData && ( { setSelected(selected); onSelect?.(selected); }} onCollapse={detailColumns ? onCollapse : undefined} actions={convertAction()} actionResolver={actionResolver} rows={data.slice(0, maxRows)} columns={columns} isNotCompact={isNotCompact} isRadio={isRadio} ariaLabelKey={ariaLabelKey} /> )} {!loading && noData && searching && ( setSearch(""), type: ButtonVariant.link, }, ] : [] } /> )} )} {loading && } {!loading && noData && !searching && emptyState} ); }