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 : };
|