mirror-cockpit-project-cockpit/pkg/lib/cockpit-components-table.tsx
2026-10-27 12:18:28 +01:00

393 lines
16 KiB
TypeScript

/*
* Copyright (C) 2019 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from 'cockpit';
import React, { useState, useEffect } from 'react';
import {
ExpandableRowContent,
Table, Thead, Tbody, Tr, Th, Td,
SortByDirection,
} from '@patternfly/react-table';
import type {
TdProps, ThProps, TrProps, TableProps,
OnSelect,
} from '@patternfly/react-table';
import { EmptyState, EmptyStateBody, EmptyStateFooter, EmptyStateActions } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import './cockpit-components-table.scss';
const _ = cockpit.gettext;
/* This is a wrapper around PF Table component
* See https://www.patternfly.org/components/table/
* Properties (all optional unless specified otherwise):
* - caption
* - id: optional identifier
* - className: additional classes added to the Table
* - actions: additional listing-wide actions (displayed next to the list's title)
* - columns: { title: string, header: boolean, sortable: boolean }[] or string[]
* - rows: {
* columns: (React.Node or string or { title: string, key: string, ...extraProps: object}}[]
Through extraProps the consumers can pass arbitrary properties to the <td>
* props: { key: string, ...extraProps: object }
This property is mandatory and should contain a unique `key`, all additional properties are optional.
Through extraProps the consumers can pass arbitrary properties to the <tr>
* expandedContent: (React.Node)[])
* selected: boolean option if the row is selected
* initiallyExpanded : the entry will be initially rendered as expanded, but then behaves normally
* }[]
* - emptyCaption: header caption to show if list is empty
* - emptyCaptionDetail: extra details to show after emptyCaption if list is empty
* - emptyComponent: Whole empty state component to show if the list is empty
* - isEmptyStateInTable: if empty state is result of a filter function this should be set, otherwise false
* - loading: Set to string when the content is still loading. This string is shown.
* - variant: For compact tables pass 'compact'
* - gridBreakPoint: Specifies the grid breakpoints ('', 'grid' | 'grid-md' | 'grid-lg' | 'grid-xl' | 'grid-2xl')
* - sortBy: { index: Number, direction: SortByDirection }
* - sortMethod: callback function used for sorting rows. Called with 3 parameters: sortMethod(rows, activeSortDirection, activeSortIndex)
* - style: object of additional css rules
* - afterToggle: function to be called when content is toggled
* - onExpand: function to be called when content is expanded
* - onSelect: function to be called when a checkbox is clicked. Called with 5 parameters:
* event, isSelected, rowIndex, rowData, extraData. rowData contains props with an id property of the clicked row.
* - onHeaderSelect: event, isSelected.
*/
interface ListingTableRowColumnProps {
title: React.ReactNode;
sortKey?: string;
props?: TdProps | ThProps;
}
type ListingTableRowColumn = React.ReactNode | ListingTableRowColumnProps;
function cell_is_props(cell: ListingTableRowColumn): cell is ListingTableRowColumnProps {
return typeof cell == "object" && (cell as ListingTableRowColumnProps).title !== undefined;
}
export interface ListingTableRowProps {
columns: ListingTableRowColumn[];
props?: TrProps;
expandedContent?: React.ReactNode;
selected?: boolean;
initiallyExpanded?: boolean;
hasPadding?: boolean;
}
export interface ListingTableColumnProps {
title: string;
header?: boolean;
sortable?: boolean;
props?: ThProps;
}
export type RowRecord = Record<string | number, boolean>;
export interface ListingTableProps extends Omit<TableProps, 'rows' | 'onSelect'> {
actions?: React.ReactNode[],
afterToggle?: (expanded: boolean) => void,
onExpand?: (rows: RowRecord) => void,
caption?: string,
className?: string,
columns: (string | ListingTableColumnProps)[],
emptyCaption?: React.ReactNode,
emptyCaptionDetail?: React.ReactNode,
emptyComponent?: React.ReactNode,
isEmptyStateInTable?: boolean,
loading?: string,
onRowClick?: (event: React.KeyboardEvent | React.MouseEvent | undefined, row: ListingTableRowProps) => void,
onSelect?: OnSelect;
onHeaderSelect?: OnSelect,
rows: ListingTableRowProps[],
showHeader?: boolean,
sortBy?: { index: number, direction: SortByDirection },
sortMethod?: (rows: ListingTableRowProps[], dir: SortByDirection, index: number) => ListingTableRowProps[],
}
export const ListingTable = ({
actions = [],
afterToggle,
onExpand,
caption = '',
className = '',
columns: cells = [],
emptyCaption = '',
emptyCaptionDetail,
emptyComponent,
isEmptyStateInTable = false,
loading = '',
onRowClick,
onSelect,
onHeaderSelect,
rows: tableRows = [],
showHeader = true,
sortBy,
sortMethod,
...extraProps
} : ListingTableProps) => {
let rows = [...tableRows];
const [expanded, setExpanded] = useState<RowRecord>({});
const [newItems, setNewItems] = useState<React.Key[]>([]);
const [currentRowsKeys, setCurrentRowsKeys] = useState<React.Key[]>([]);
const [activeSortIndex, setActiveSortIndex] = useState(sortBy?.index ?? 0);
const [activeSortDirection, setActiveSortDirection] = useState(sortBy?.direction ?? SortByDirection.asc);
const rowKeys = rows.map(row => row.props?.key)
.filter(key => key !== undefined);
const rowKeysStr = JSON.stringify(rowKeys);
const currentRowsKeysStr = JSON.stringify(currentRowsKeys);
useEffect(() => {
// Don't highlight all when the list gets loaded
const _currentRowsKeys: React.Key[] = JSON.parse(currentRowsKeysStr);
const _rowKeys: React.Key[] = JSON.parse(rowKeysStr);
if (_currentRowsKeys.length !== 0) {
const new_keys = _rowKeys.filter(key => _currentRowsKeys.indexOf(key) === -1);
if (new_keys.length) {
setTimeout(() => setNewItems(items => items.filter(item => new_keys.indexOf(item) < 0)), 4000);
setNewItems(ni => [...ni, ...new_keys]);
}
}
setCurrentRowsKeys(crk => [...new Set([...crk, ..._rowKeys])]);
}, [currentRowsKeysStr, rowKeysStr]);
useEffect(() => {
if (onExpand)
onExpand(expanded);
}, [expanded, onExpand]);
const isSortable = cells.some(col => typeof col != "string" && col.sortable);
const isExpandable = rows.some(row => row.expandedContent);
const tableProps: TableProps = {
// Animations will be default in PF v7 and can be removed with that bump
// https://github.com/patternfly/patternfly-react/issues/11612
hasAnimations: true,
isExpandable
};
/* Basic table properties */
tableProps.className = "ct-table";
if (className)
tableProps.className = tableProps.className + " " + className;
if (rows.length === 0)
tableProps.className += ' ct-table-empty';
const header = (
(caption || actions.length !== 0)
? <header className='ct-table-header'>
<h3 className='ct-table-heading'> {caption} </h3>
{actions && <div className='ct-table-actions'> {actions} </div>}
</header>
: null
);
if (loading)
return <EmptyState>
<EmptyStateBody>
{loading}
</EmptyStateBody>
</EmptyState>;
if (rows.length === 0) {
let emptyState = null;
if (emptyComponent)
emptyState = emptyComponent;
else
emptyState = (
<EmptyState>
<EmptyStateBody>
<div>{emptyCaption}</div>
<Content component={ContentVariants.small}>
{emptyCaptionDetail}
</Content>
</EmptyStateBody>
{actions.length > 0 &&
<EmptyStateFooter>
<EmptyStateActions>{actions}</EmptyStateActions>
</EmptyStateFooter>}
</EmptyState>
);
if (!isEmptyStateInTable)
return emptyState;
const emptyStateCell = (
[{
props: { colSpan: cells.length },
title: emptyState
}]
);
rows = [{ columns: emptyStateCell }];
}
const sortRows = (): ListingTableRowProps[] => {
function sortkey(col: ListingTableRowColumn): string {
if (typeof col == "string")
return col;
if (cell_is_props(col)) {
if (col.sortKey)
return col.sortKey;
if (typeof col.title == "string")
return col.title;
}
return "";
}
const sortedRows = rows.sort((a, b) => {
const aitem = a.columns[activeSortIndex];
const bitem = b.columns[activeSortIndex];
return sortkey(aitem).localeCompare(sortkey(bitem));
});
return activeSortDirection === SortByDirection.asc ? sortedRows : sortedRows.reverse();
};
const onSort = (_event: unknown, index: number, direction: SortByDirection) => {
setActiveSortIndex(index);
setActiveSortDirection(direction);
};
const rowsComponents = (isSortable ? (sortMethod ? sortMethod(rows, activeSortDirection, activeSortIndex) : sortRows()) : rows).map((row, rowIndex) => {
const rowProps = row.props || {};
if (onRowClick) {
rowProps.isClickable = true;
rowProps.onRowClick = (event) => onRowClick(event, row);
}
if (rowProps.key && newItems.indexOf(rowProps.key) >= 0)
rowProps.className = (rowProps.className || "") + " ct-new-item";
cockpit.assert(typeof rowProps.key != "bigint");
const rowKey = rowProps.key || rowIndex;
const isExpanded = expanded[rowKey] === undefined ? !!row.initiallyExpanded : expanded[rowKey];
if (isExpandable) {
rowProps.isContentExpanded = Boolean(row.expandedContent) && isExpanded;
}
let columnSpanCnt = 0;
const rowPair = (
<React.Fragment key={rowKey + "-inner-row"}>
<Tr {...rowProps}>
{isExpandable
? (row.expandedContent
? <Td
data-ouia-component-id={`toggle-${rowKey.toString()}`}
expand={{
rowIndex,
isExpanded,
onToggle: () => {
if (afterToggle)
afterToggle(!expanded[rowKey]);
setExpanded({ ...expanded, [rowKey]: !expanded[rowKey] });
}
}} />
: <Td className="pf-v6-c-table__toggle" />)
: null
}
{onSelect &&
<Td select={{
rowIndex,
onSelect,
isSelected: !!row.selected,
props: {
id: rowKey
}
}} />
}
{row.columns.map(cell => {
let props: TdProps | ThProps;
let children: React.ReactNode;
if (cell_is_props(cell)) {
props = cell.props || {};
children = cell.title;
} else {
props = {};
children = cell;
}
const { key, ...cellProps } = props;
const headerCell = cells[columnSpanCnt];
const dataLabel = typeof headerCell == 'object' ? headerCell.title : headerCell;
const colKey = dataLabel || columnSpanCnt;
columnSpanCnt += cellProps.colSpan || 1;
if (typeof headerCell != "string" && headerCell?.header) {
return (
<Th key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
{...cellProps as ThProps}>
{children}
</Th>
);
}
return (
<Td key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
{...cellProps as TdProps}>
{children}
</Td>
);
})}
</Tr>
{row.expandedContent && <Tr id={"expanded-content" + rowIndex} isExpanded={isExpanded}>
<Td noPadding={row.hasPadding !== true} colSpan={row.columns.length + 1 + (onSelect ? 1 : 0)}>
<ExpandableRowContent>{row.expandedContent}</ExpandableRowContent>
</Td>
</Tr>}
</React.Fragment>
);
return <Tbody key={rowKey} isExpanded={Boolean(row.expandedContent) && isExpanded}>{rowPair}</Tbody>;
});
return (
<>
{header}
<Table {...extraProps} {...tableProps}>
{showHeader && <Thead>
<Tr>
{/* HACK - https://github.com/patternfly/patternfly/issues/6643
We should probably be using screenReaderText instead of aria-label
for the first two here, but that will change the table layout.
*/}
{isExpandable && <Th aria-label={_("Row expansion")} />}
{!onHeaderSelect && onSelect && <Th aria-label={_("Row select")} />}
{onHeaderSelect && onSelect && <Th aria-label={_("Row select")} select={{
onSelect: onHeaderSelect,
isSelected: rows.every(r => r.selected)
}} />}
{cells.map((column, columnIndex) => {
const columnProps = typeof column == "string" ? {} : column.props;
const sortParams = (
(typeof column != "string" && column.sortable)
? {
sort: {
sortBy: {
index: activeSortIndex,
direction: activeSortDirection
},
onSort,
columnIndex
}
}
: {}
);
return (
<Th key={columnIndex} {...columnProps} {...sortParams}>
{typeof column == 'object' ? column.title : column}
</Th>
);
})}
</Tr>
</Thead>}
{rowsComponents}
</Table>
</>
);
};