Line data Source code
1 : /*
2 : * Copyright (C) 2019 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 578 : import cockpit from 'cockpit';
7 :
8 578 : 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 578 : 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 141 : function cell_is_props(cell: ListingTableRowColumn): cell is ListingTableRowColumnProps {
69 138 : return typeof cell == "object" && (cell as ListingTableRowColumnProps).title !== undefined;
70 141 : }
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 142 : export const ListingTable = ({
112 142 : actions = [],
113 142 : afterToggle,
114 142 : onExpand,
115 142 : caption = '',
116 142 : className = '',
117 142 : columns: cells = [],
118 142 : emptyCaption = '',
119 142 : emptyCaptionDetail,
120 142 : emptyComponent,
121 142 : isEmptyStateInTable = false,
122 142 : loading = '',
123 142 : onRowClick,
124 142 : onSelect,
125 142 : onHeaderSelect,
126 142 : rows: tableRows = [],
127 142 : showHeader = true,
128 142 : sortBy,
129 142 : sortMethod,
130 142 : ...extraProps
131 142 : } : ListingTableProps) => {
132 142 : let rows = [...tableRows];
133 142 : const [expanded, setExpanded] = useState<RowRecord>({});
134 142 : const [newItems, setNewItems] = useState<React.Key[]>([]);
135 142 : const [currentRowsKeys, setCurrentRowsKeys] = useState<React.Key[]>([]);
136 22 : const [activeSortIndex, setActiveSortIndex] = useState(sortBy?.index ?? 0);
137 22 : const [activeSortDirection, setActiveSortDirection] = useState(sortBy?.direction ?? SortByDirection.asc);
138 110 : const rowKeys = rows.map(row => row.props?.key)
139 141 : .filter(key => key !== undefined);
140 142 : const rowKeysStr = JSON.stringify(rowKeys);
141 142 : const currentRowsKeysStr = JSON.stringify(currentRowsKeys);
142 :
143 142 : useEffect(() => {
144 : // Don't highlight all when the list gets loaded
145 142 : const _currentRowsKeys: React.Key[] = JSON.parse(currentRowsKeysStr);
146 142 : const _rowKeys: React.Key[] = JSON.parse(rowKeysStr);
147 :
148 110 : if (_currentRowsKeys.length !== 0) {
149 110 : const new_keys = _rowKeys.filter(key => _currentRowsKeys.indexOf(key) === -1);
150 56 : if (new_keys.length) {
151 34 : setTimeout(() => setNewItems(items => items.filter(item => new_keys.indexOf(item) < 0)), 4000);
152 44 : setNewItems(ni => [...ni, ...new_keys]);
153 56 : }
154 110 : }
155 :
156 142 : setCurrentRowsKeys(crk => [...new Set([...crk, ..._rowKeys])]);
157 142 : }, [currentRowsKeysStr, rowKeysStr]);
158 :
159 142 : useEffect(() => {
160 142 : if (onExpand)
161 16 : onExpand(expanded);
162 142 : }, [expanded, onExpand]);
163 :
164 142 : const isSortable = cells.some(col => typeof col != "string" && col.sortable);
165 141 : const isExpandable = rows.some(row => row.expandedContent);
166 :
167 142 : 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 142 : hasAnimations: true,
171 142 : isExpandable
172 142 : };
173 :
174 : /* Basic table properties */
175 142 : tableProps.className = "ct-table";
176 142 : if (className)
177 58 : tableProps.className = tableProps.className + " " + className;
178 142 : if (rows.length == 0)
179 37 : tableProps.className += ' ct-table-empty';
180 :
181 142 : const header = (
182 142 : (caption || actions.length != 0)
183 16 : ? <header className='ct-table-header'>
184 16 : <h3 className='ct-table-heading'> {caption} </h3>
185 16 : {actions && <div className='ct-table-actions'> {actions} </div>}
186 16 : </header>
187 142 : : null
188 : );
189 :
190 142 : if (loading)
191 16 : return <EmptyState>
192 16 : <EmptyStateBody>
193 16 : {loading}
194 16 : </EmptyStateBody>
195 16 : </EmptyState>;
196 :
197 37 : if (rows.length == 0) {
198 37 : let emptyState = null;
199 37 : if (emptyComponent)
200 16 : emptyState = emptyComponent;
201 : else
202 35 : emptyState = (
203 35 : <EmptyState>
204 35 : <EmptyStateBody>
205 35 : <div>{emptyCaption}</div>
206 35 : <Content component={ContentVariants.small}>
207 35 : {emptyCaptionDetail}
208 35 : </Content>
209 35 : </EmptyStateBody>
210 35 : {actions.length > 0 &&
211 16 : <EmptyStateFooter>
212 16 : <EmptyStateActions>{actions}</EmptyStateActions>
213 16 : </EmptyStateFooter>}
214 35 : </EmptyState>
215 : );
216 37 : if (!isEmptyStateInTable)
217 36 : return emptyState;
218 :
219 17 : const emptyStateCell = (
220 17 : [{
221 17 : props: { colSpan: cells.length },
222 17 : title: emptyState
223 17 : }]
224 : );
225 :
226 17 : rows = [{ columns: emptyStateCell }];
227 17 : }
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 16 : const rowsComponents = (isSortable ? (sortMethod ? sortMethod(rows, activeSortDirection, activeSortIndex) : sortRows()) : rows).map((row, rowIndex) => {
257 48 : const rowProps = row.props || {};
258 16 : if (onRowClick) {
259 16 : rowProps.isClickable = true;
260 0 : rowProps.onRowClick = (event) => onRowClick(event, row);
261 16 : }
262 :
263 108 : if (rowProps.key && newItems.indexOf(rowProps.key) >= 0)
264 53 : rowProps.className = (rowProps.className || "") + " ct-new-item";
265 :
266 141 : cockpit.assert(typeof rowProps.key != "bigint");
267 :
268 54 : const rowKey = rowProps.key || rowIndex;
269 28 : const isExpanded = expanded[rowKey] === undefined ? !!row.initiallyExpanded : expanded[rowKey];
270 37 : if (isExpandable) {
271 37 : rowProps.isContentExpanded = Boolean(row.expandedContent) && isExpanded;
272 37 : }
273 141 : let columnSpanCnt = 0;
274 141 : const rowPair = (
275 141 : <React.Fragment key={rowKey + "-inner-row"}>
276 141 : <Tr {...rowProps}>
277 141 : {isExpandable
278 37 : ? (row.expandedContent
279 37 : ? <Td
280 37 : data-ouia-component-id={`toggle-${rowKey.toString()}`}
281 37 : expand={{
282 37 : rowIndex,
283 37 : isExpanded,
284 13 : onToggle: () => {
285 13 : if (afterToggle)
286 1 : afterToggle(!expanded[rowKey]);
287 13 : setExpanded({ ...expanded, [rowKey]: !expanded[rowKey] });
288 13 : }
289 37 : }} />
290 18 : : <Td className="pf-v6-c-table__toggle" />)
291 121 : : null
292 : }
293 141 : {onSelect &&
294 18 : <Td select={{
295 18 : rowIndex,
296 18 : onSelect,
297 18 : isSelected: !!row.selected,
298 18 : props: {
299 18 : id: rowKey
300 18 : }
301 18 : }} />
302 : }
303 141 : {row.columns.map(cell => {
304 141 : let props: TdProps | ThProps;
305 141 : let children: React.ReactNode;
306 138 : if (cell_is_props(cell)) {
307 94 : props = cell.props || {};
308 138 : children = cell.title;
309 53 : } else {
310 56 : props = {};
311 56 : children = cell;
312 56 : }
313 141 : const { key, ...cellProps } = props;
314 141 : const headerCell = cells[columnSpanCnt];
315 56 : const dataLabel = typeof headerCell == 'object' ? headerCell.title : headerCell;
316 94 : const colKey = dataLabel || columnSpanCnt;
317 :
318 141 : columnSpanCnt += cellProps.colSpan || 1;
319 :
320 76 : if (typeof headerCell != "string" && headerCell?.header) {
321 76 : return (
322 76 : <Th key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
323 76 : {...cellProps as ThProps}>
324 76 : {children}
325 76 : </Th>
326 : );
327 76 : }
328 :
329 141 : return (
330 141 : <Td key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
331 141 : {...cellProps as TdProps}>
332 141 : {children}
333 141 : </Td>
334 : );
335 141 : })}
336 141 : </Tr>
337 37 : {row.expandedContent && <Tr id={"expanded-content" + rowIndex} isExpanded={isExpanded}>
338 16 : <Td noPadding={row.hasPadding !== true} colSpan={row.columns.length + 1 + (onSelect ? 1 : 0)}>
339 37 : <ExpandableRowContent>{row.expandedContent}</ExpandableRowContent>
340 37 : </Td>
341 37 : </Tr>}
342 141 : </React.Fragment>
343 : );
344 :
345 37 : return <Tbody key={rowKey} isExpanded={Boolean(row.expandedContent) && isExpanded}>{rowPair}</Tbody>;
346 141 : });
347 :
348 142 : return (
349 142 : <>
350 142 : {header}
351 142 : <Table {...extraProps} {...tableProps}>
352 112 : {showHeader && <Thead>
353 112 : <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 34 : {isExpandable && <Th aria-label={_("Row expansion")} />}
359 16 : {!onHeaderSelect && onSelect && <Th aria-label={_("Row select")} />}
360 16 : {onHeaderSelect && onSelect && <Th aria-label={_("Row select")} select={{
361 16 : onSelect: onHeaderSelect,
362 0 : isSelected: rows.every(r => r.selected)
363 16 : }} />}
364 108 : {cells.map((column, columnIndex) => {
365 13 : const columnProps = typeof column == "string" ? {} : column.props;
366 108 : const sortParams = (
367 108 : (typeof column != "string" && column.sortable)
368 24 : ? {
369 24 : sort: {
370 24 : sortBy: {
371 24 : index: activeSortIndex,
372 24 : direction: activeSortDirection
373 24 : },
374 24 : onSort,
375 24 : columnIndex
376 24 : }
377 24 : }
378 108 : : {}
379 : );
380 :
381 108 : return (
382 108 : <Th key={columnIndex} {...columnProps} {...sortParams}>
383 13 : {typeof column == 'object' ? column.title : column}
384 108 : </Th>
385 : );
386 108 : })}
387 112 : </Tr>
388 112 : </Thead>}
389 142 : {rowsComponents}
390 142 : </Table>
391 142 : </>
392 : );
393 142 : };
|