LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-table.tsx Coverage Total Hit
Test: cockpit Lines: 99.2 % 243 241
Test Date: 2026-07-03 07:31:16

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2019 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          579 : import cockpit from 'cockpit';
       7              : 
       8          579 : import React, { useState, useEffect } from 'react';
       9              : import {
      10              :     ExpandableRowContent,
      11              :     Table, Thead, Tbody, Tr, Th, Td,
      12              :     SortByDirection,
      13              : } from '@patternfly/react-table';
      14              : import type {
      15              :     TdProps, ThProps, TrProps, TableProps,
      16              :     OnSelect,
      17              : } from '@patternfly/react-table';
      18              : import { EmptyState, EmptyStateBody, EmptyStateFooter, EmptyStateActions } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
      19              : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
      20              : 
      21              : import './cockpit-components-table.scss';
      22              : 
      23          579 : const _ = cockpit.gettext;
      24              : 
      25              : /* This is a wrapper around PF Table component
      26              :  * See https://www.patternfly.org/components/table/
      27              :  * Properties (all optional unless specified otherwise):
      28              :  * - caption
      29              :  * - id: optional identifier
      30              :  * - className: additional classes added to the Table
      31              :  * - actions: additional listing-wide actions (displayed next to the list's title)
      32              :  * - columns: { title: string, header: boolean, sortable: boolean }[] or string[]
      33              :  * - rows: {
      34              :  *      columns: (React.Node or string or { title: string, key: string, ...extraProps: object}}[]
      35              :                  Through extraProps the consumers can pass arbitrary properties to the <td>
      36              :  *      props: { key: string, ...extraProps: object }
      37              :                This property is mandatory and should contain a unique `key`, all additional properties are optional.
      38              :                Through extraProps the consumers can pass arbitrary properties to the <tr>
      39              :  *      expandedContent: (React.Node)[])
      40              :  *      selected: boolean option if the row is selected
      41              :  *      initiallyExpanded : the entry will be initially rendered as expanded, but then behaves normally
      42              :  *   }[]
      43              :  * - emptyCaption: header caption to show if list is empty
      44              :  * - emptyCaptionDetail: extra details to show after emptyCaption if list is empty
      45              :  * - emptyComponent: Whole empty state component to show if the list is empty
      46              :  * - isEmptyStateInTable: if empty state is result of a filter function this should be set, otherwise false
      47              :  * - loading: Set to string when the content is still loading. This string is shown.
      48              :  * - variant: For compact tables pass 'compact'
      49              :  * - gridBreakPoint: Specifies the grid breakpoints ('', 'grid' | 'grid-md' | 'grid-lg' | 'grid-xl' | 'grid-2xl')
      50              :  * - sortBy: { index: Number, direction: SortByDirection }
      51              :  * - sortMethod: callback function used for sorting rows. Called with 3 parameters: sortMethod(rows, activeSortDirection, activeSortIndex)
      52              :  * - style: object of additional css rules
      53              :  * - afterToggle: function to be called when content is toggled
      54              :  * - onExpand: function to be called when content is expanded
      55              :  * - onSelect: function to be called when a checkbox is clicked. Called with 5 parameters:
      56              :  *   event, isSelected, rowIndex, rowData, extraData. rowData contains props with an id property of the clicked row.
      57              :  * - onHeaderSelect: event, isSelected.
      58              :  */
      59              : 
      60              : interface ListingTableRowColumnProps {
      61              :     title: React.ReactNode;
      62              :     sortKey?: string;
      63              :     props?: TdProps | ThProps;
      64              : }
      65              : 
      66              : type ListingTableRowColumn = React.ReactNode | ListingTableRowColumnProps;
      67              : 
      68          140 : function cell_is_props(cell: ListingTableRowColumn): cell is ListingTableRowColumnProps {
      69          138 :     return typeof cell == "object" && (cell as ListingTableRowColumnProps).title !== undefined;
      70          140 : }
      71              : 
      72              : export interface ListingTableRowProps {
      73              :     columns: ListingTableRowColumn[];
      74              :     props?: TrProps;
      75              :     expandedContent?: React.ReactNode;
      76              :     selected?: boolean;
      77              :     initiallyExpanded?: boolean;
      78              :     hasPadding?: boolean;
      79              : }
      80              : 
      81              : export interface ListingTableColumnProps {
      82              :     title: string;
      83              :     header?: boolean;
      84              :     sortable?: boolean;
      85              :     props?: ThProps;
      86              : }
      87              : 
      88              : export type RowRecord = Record<string | number, boolean>;
      89              : 
      90              : export interface ListingTableProps extends Omit<TableProps, 'rows' | 'onSelect'> {
      91              :     actions?: React.ReactNode[],
      92              :     afterToggle?: (expanded: boolean) => void,
      93              :     onExpand?: (rows: RowRecord) => void,
      94              :     caption?: string,
      95              :     className?: string,
      96              :     columns: (string | ListingTableColumnProps)[],
      97              :     emptyCaption?: React.ReactNode,
      98              :     emptyCaptionDetail?: React.ReactNode,
      99              :     emptyComponent?: React.ReactNode,
     100              :     isEmptyStateInTable?: boolean,
     101              :     loading?: string,
     102              :     onRowClick?: (event: React.KeyboardEvent | React.MouseEvent | undefined, row: ListingTableRowProps) => void,
     103              :     onSelect?: OnSelect;
     104              :     onHeaderSelect?: OnSelect,
     105              :     rows: ListingTableRowProps[],
     106              :     showHeader?: boolean,
     107              :     sortBy?: { index: number, direction: SortByDirection },
     108              :     sortMethod?: (rows: ListingTableRowProps[], dir: SortByDirection, index: number) => ListingTableRowProps[],
     109              : }
     110              : 
     111          141 : export const ListingTable = ({
     112          141 :     actions = [],
     113          141 :     afterToggle,
     114          141 :     onExpand,
     115          141 :     caption = '',
     116          141 :     className = '',
     117          141 :     columns: cells = [],
     118          141 :     emptyCaption = '',
     119          141 :     emptyCaptionDetail,
     120          141 :     emptyComponent,
     121          141 :     isEmptyStateInTable = false,
     122          141 :     loading = '',
     123          141 :     onRowClick,
     124          141 :     onSelect,
     125          141 :     onHeaderSelect,
     126          141 :     rows: tableRows = [],
     127          141 :     showHeader = true,
     128          141 :     sortBy,
     129          141 :     sortMethod,
     130          141 :     ...extraProps
     131          141 : } : ListingTableProps) => {
     132          141 :     let rows = [...tableRows];
     133          141 :     const [expanded, setExpanded] = useState<RowRecord>({});
     134          141 :     const [newItems, setNewItems] = useState<React.Key[]>([]);
     135          141 :     const [currentRowsKeys, setCurrentRowsKeys] = useState<React.Key[]>([]);
     136           24 :     const [activeSortIndex, setActiveSortIndex] = useState(sortBy?.index ?? 0);
     137           24 :     const [activeSortDirection, setActiveSortDirection] = useState(sortBy?.direction ?? SortByDirection.asc);
     138          111 :     const rowKeys = rows.map(row => row.props?.key)
     139          140 :             .filter(key => key !== undefined);
     140          141 :     const rowKeysStr = JSON.stringify(rowKeys);
     141          141 :     const currentRowsKeysStr = JSON.stringify(currentRowsKeys);
     142              : 
     143          141 :     useEffect(() => {
     144              :         // Don't highlight all when the list gets loaded
     145          141 :         const _currentRowsKeys: React.Key[] = JSON.parse(currentRowsKeysStr);
     146          141 :         const _rowKeys: React.Key[] = JSON.parse(rowKeysStr);
     147              : 
     148          111 :         if (_currentRowsKeys.length !== 0) {
     149          111 :             const new_keys = _rowKeys.filter(key => _currentRowsKeys.indexOf(key) === -1);
     150           59 :             if (new_keys.length) {
     151           36 :                 setTimeout(() => setNewItems(items => items.filter(item => new_keys.indexOf(item) < 0)), 4000);
     152           46 :                 setNewItems(ni => [...ni, ...new_keys]);
     153           59 :             }
     154          111 :         }
     155              : 
     156          141 :         setCurrentRowsKeys(crk => [...new Set([...crk, ..._rowKeys])]);
     157          141 :     }, [currentRowsKeysStr, rowKeysStr]);
     158              : 
     159          141 :     useEffect(() => {
     160          141 :         if (onExpand)
     161           17 :             onExpand(expanded);
     162          141 :     }, [expanded, onExpand]);
     163              : 
     164          141 :     const isSortable = cells.some(col => typeof col != "string" && col.sortable);
     165          140 :     const isExpandable = rows.some(row => row.expandedContent);
     166              : 
     167          141 :     const tableProps: TableProps = {
     168              :         // Animations will be default in PF v7 and can be removed with that bump
     169              :         // https://github.com/patternfly/patternfly-react/issues/11612
     170          141 :         hasAnimations: true,
     171          141 :         isExpandable
     172          141 :     };
     173              : 
     174              :     /* Basic table properties */
     175          141 :     tableProps.className = "ct-table";
     176          141 :     if (className)
     177           59 :         tableProps.className = tableProps.className + " " + className;
     178          141 :     if (rows.length == 0)
     179           38 :         tableProps.className += ' ct-table-empty';
     180              : 
     181          141 :     const header = (
     182          141 :         (caption || actions.length != 0)
     183           17 :             ? <header className='ct-table-header'>
     184           17 :                 <h3 className='ct-table-heading'> {caption} </h3>
     185           17 :                 {actions && <div className='ct-table-actions'> {actions} </div>}
     186           17 :             </header>
     187          141 :             : null
     188              :     );
     189              : 
     190          141 :     if (loading)
     191           17 :         return <EmptyState>
     192           17 :             <EmptyStateBody>
     193           17 :                 {loading}
     194           17 :             </EmptyStateBody>
     195           17 :         </EmptyState>;
     196              : 
     197           38 :     if (rows.length == 0) {
     198           38 :         let emptyState = null;
     199           38 :         if (emptyComponent)
     200           17 :             emptyState = emptyComponent;
     201              :         else
     202           36 :             emptyState = (
     203           36 :                 <EmptyState>
     204           36 :                     <EmptyStateBody>
     205           36 :                         <div>{emptyCaption}</div>
     206           36 :                         <Content component={ContentVariants.small}>
     207           36 :                             {emptyCaptionDetail}
     208           36 :                         </Content>
     209           36 :                     </EmptyStateBody>
     210           36 :                     {actions.length > 0 &&
     211           17 :                     <EmptyStateFooter>
     212           17 :                         <EmptyStateActions>{actions}</EmptyStateActions>
     213           17 :                     </EmptyStateFooter>}
     214           36 :                 </EmptyState>
     215              :             );
     216           38 :         if (!isEmptyStateInTable)
     217           37 :             return emptyState;
     218              : 
     219           18 :         const emptyStateCell = (
     220           18 :             [{
     221           18 :                 props: { colSpan: cells.length },
     222           18 :                 title: emptyState
     223           18 :             }]
     224              :         );
     225              : 
     226           18 :         rows = [{ columns: emptyStateCell }];
     227           18 :     }
     228              : 
     229            2 :     const sortRows = (): ListingTableRowProps[] => {
     230            2 :         function sortkey(col: ListingTableRowColumn): string {
     231            2 :             if (typeof col == "string")
     232            2 :                 return col;
     233            1 :             if (cell_is_props(col)) {
     234            1 :                 if (col.sortKey)
     235            1 :                     return col.sortKey;
     236            1 :                 if (typeof col.title == "string")
     237            1 :                     return col.title;
     238            1 :             }
     239            1 :             return "";
     240            2 :         }
     241              : 
     242            2 :         const sortedRows = rows.sort((a, b) => {
     243            2 :             const aitem = a.columns[activeSortIndex];
     244            2 :             const bitem = b.columns[activeSortIndex];
     245              : 
     246            2 :             return sortkey(aitem).localeCompare(sortkey(bitem));
     247            2 :         });
     248            1 :         return activeSortDirection === SortByDirection.asc ? sortedRows : sortedRows.reverse();
     249            2 :     };
     250              : 
     251            1 :     const onSort = (_event: unknown, index: number, direction: SortByDirection) => {
     252            1 :         setActiveSortIndex(index);
     253            1 :         setActiveSortDirection(direction);
     254            1 :     };
     255              : 
     256           17 :     const rowsComponents = (isSortable ? (sortMethod ? sortMethod(rows, activeSortDirection, activeSortIndex) : sortRows()) : rows).map((row, rowIndex) => {
     257           47 :         const rowProps = row.props || {};
     258           17 :         if (onRowClick) {
     259           17 :             rowProps.isClickable = true;
     260            0 :             rowProps.onRowClick = (event) => onRowClick(event, row);
     261           17 :         }
     262              : 
     263          110 :         if (rowProps.key && newItems.indexOf(rowProps.key) >= 0)
     264           56 :             rowProps.className = (rowProps.className || "") + " ct-new-item";
     265              : 
     266          140 :         cockpit.assert(typeof rowProps.key != "bigint");
     267              : 
     268           54 :         const rowKey = rowProps.key || rowIndex;
     269           29 :         const isExpanded = expanded[rowKey] === undefined ? !!row.initiallyExpanded : expanded[rowKey];
     270           38 :         if (isExpandable) {
     271           38 :             rowProps.isContentExpanded = Boolean(row.expandedContent) && isExpanded;
     272           38 :         }
     273          140 :         let columnSpanCnt = 0;
     274          140 :         const rowPair = (
     275          140 :             <React.Fragment key={rowKey + "-inner-row"}>
     276          140 :                 <Tr {...rowProps}>
     277          140 :                     {isExpandable
     278           38 :                         ? (row.expandedContent
     279           38 :                             ? <Td
     280           38 :                                 data-ouia-component-id={`toggle-${rowKey.toString()}`}
     281           38 :                                 expand={{
     282           38 :                                     rowIndex,
     283           38 :                                     isExpanded,
     284           13 :                                     onToggle: () => {
     285           13 :                                         if (afterToggle)
     286            1 :                                             afterToggle(!expanded[rowKey]);
     287           13 :                                         setExpanded({ ...expanded, [rowKey]: !expanded[rowKey] });
     288           13 :                                     }
     289           38 :                                 }} />
     290           19 :                             : <Td className="pf-v6-c-table__toggle" />)
     291          120 :                         : null
     292              :                     }
     293          140 :                     {onSelect &&
     294           19 :                         <Td select={{
     295           19 :                             rowIndex,
     296           19 :                             onSelect,
     297           19 :                             isSelected: !!row.selected,
     298           19 :                             props: {
     299           19 :                                 id: rowKey
     300           19 :                             }
     301           19 :                         }} />
     302              :                     }
     303          140 :                     {row.columns.map(cell => {
     304          140 :                         let props: TdProps | ThProps;
     305          140 :                         let children: React.ReactNode;
     306          138 :                         if (cell_is_props(cell)) {
     307           95 :                             props = cell.props || {};
     308          138 :                             children = cell.title;
     309           53 :                         } else {
     310           55 :                             props = {};
     311           55 :                             children = cell;
     312           55 :                         }
     313          140 :                         const { key, ...cellProps } = props;
     314          140 :                         const headerCell = cells[columnSpanCnt];
     315           57 :                         const dataLabel = typeof headerCell == 'object' ? headerCell.title : headerCell;
     316           94 :                         const colKey = dataLabel || columnSpanCnt;
     317              : 
     318          140 :                         columnSpanCnt += cellProps.colSpan || 1;
     319              : 
     320           77 :                         if (typeof headerCell != "string" && headerCell?.header) {
     321           77 :                             return (
     322           77 :                                 <Th key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
     323           77 :                                     {...cellProps as ThProps}>
     324           77 :                                     {children}
     325           77 :                                 </Th>
     326              :                             );
     327           77 :                         }
     328              : 
     329          140 :                         return (
     330          140 :                             <Td key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
     331          140 :                                 {...cellProps as TdProps}>
     332          140 :                                 {children}
     333          140 :                             </Td>
     334              :                         );
     335          140 :                     })}
     336          140 :                 </Tr>
     337           38 :                 {row.expandedContent && <Tr id={"expanded-content" + rowIndex} isExpanded={isExpanded}>
     338           17 :                     <Td noPadding={row.hasPadding !== true} colSpan={row.columns.length + 1 + (onSelect ? 1 : 0)}>
     339           38 :                         <ExpandableRowContent>{row.expandedContent}</ExpandableRowContent>
     340           38 :                     </Td>
     341           38 :                 </Tr>}
     342          140 :             </React.Fragment>
     343              :         );
     344              : 
     345           38 :         return <Tbody key={rowKey} isExpanded={Boolean(row.expandedContent) && isExpanded}>{rowPair}</Tbody>;
     346          140 :     });
     347              : 
     348          141 :     return (
     349          141 :         <>
     350          141 :             {header}
     351          141 :             <Table {...extraProps} {...tableProps}>
     352          111 :                 {showHeader && <Thead>
     353          111 :                     <Tr>
     354              :                         {/* HACK - https://github.com/patternfly/patternfly/issues/6643
     355              :                             We should probably be using screenReaderText instead of aria-label
     356              :                             for the first two here, but that will change the table layout.
     357              :                           */}
     358           35 :                         {isExpandable && <Th aria-label={_("Row expansion")} />}
     359           17 :                         {!onHeaderSelect && onSelect && <Th aria-label={_("Row select")} />}
     360           17 :                         {onHeaderSelect && onSelect && <Th aria-label={_("Row select")} select={{
     361           17 :                             onSelect: onHeaderSelect,
     362            0 :                             isSelected: rows.every(r => r.selected)
     363           17 :                         }} />}
     364          107 :                         {cells.map((column, columnIndex) => {
     365           14 :                             const columnProps = typeof column == "string" ? {} : column.props;
     366          107 :                             const sortParams = (
     367          107 :                                 (typeof column != "string" && column.sortable)
     368           26 :                                     ? {
     369           26 :                                         sort: {
     370           26 :                                             sortBy: {
     371           26 :                                                 index: activeSortIndex,
     372           26 :                                                 direction: activeSortDirection
     373           26 :                                             },
     374           26 :                                             onSort,
     375           26 :                                             columnIndex
     376           26 :                                         }
     377           26 :                                     }
     378          107 :                                     : {}
     379              :                             );
     380              : 
     381          107 :                             return (
     382          107 :                                 <Th key={columnIndex} {...columnProps} {...sortParams}>
     383           14 :                                     {typeof column == 'object' ? column.title : column}
     384          107 :                                 </Th>
     385              :                             );
     386          107 :                         })}
     387          111 :                     </Tr>
     388          111 :                 </Thead>}
     389          141 :                 {rowsComponents}
     390          141 :             </Table>
     391          141 :         </>
     392              :     );
     393          141 : };
        

Generated by: LCOV version 2.0-1