Line data Source code
1 : /*
2 : * Copyright (C) 2019 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 575 : import cockpit from 'cockpit';
7 :
8 575 : 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 575 : 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 138 : function cell_is_props(cell: ListingTableRowColumn): cell is ListingTableRowColumnProps {
69 136 : return typeof cell == "object" && (cell as ListingTableRowColumnProps).title !== undefined;
70 138 : }
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 139 : export const ListingTable = ({
112 139 : actions = [],
113 139 : afterToggle,
114 139 : onExpand,
115 139 : caption = '',
116 139 : className = '',
117 139 : columns: cells = [],
118 139 : emptyCaption = '',
119 139 : emptyCaptionDetail,
120 139 : emptyComponent,
121 139 : isEmptyStateInTable = false,
122 139 : loading = '',
123 139 : onRowClick,
124 139 : onSelect,
125 139 : onHeaderSelect,
126 139 : rows: tableRows = [],
127 139 : showHeader = true,
128 139 : sortBy,
129 139 : sortMethod,
130 139 : ...extraProps
131 139 : } : ListingTableProps) => {
132 139 : let rows = [...tableRows];
133 139 : const [expanded, setExpanded] = useState<RowRecord>({});
134 139 : const [newItems, setNewItems] = useState<React.Key[]>([]);
135 139 : 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 109 : const rowKeys = rows.map(row => row.props?.key)
139 138 : .filter(key => key !== undefined);
140 139 : const rowKeysStr = JSON.stringify(rowKeys);
141 139 : const currentRowsKeysStr = JSON.stringify(currentRowsKeys);
142 :
143 139 : useEffect(() => {
144 : // Don't highlight all when the list gets loaded
145 139 : const _currentRowsKeys: React.Key[] = JSON.parse(currentRowsKeysStr);
146 139 : const _rowKeys: React.Key[] = JSON.parse(rowKeysStr);
147 :
148 109 : if (_currentRowsKeys.length !== 0) {
149 109 : 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 43 : setNewItems(ni => [...ni, ...new_keys]);
153 56 : }
154 109 : }
155 :
156 139 : setCurrentRowsKeys(crk => [...new Set([...crk, ..._rowKeys])]);
157 139 : }, [currentRowsKeysStr, rowKeysStr]);
158 :
159 139 : useEffect(() => {
160 139 : if (onExpand)
161 16 : onExpand(expanded);
162 139 : }, [expanded, onExpand]);
163 :
164 139 : const isSortable = cells.some(col => typeof col != "string" && col.sortable);
165 138 : const isExpandable = rows.some(row => row.expandedContent);
166 :
167 139 : 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 139 : hasAnimations: true,
171 139 : isExpandable
172 139 : };
173 :
174 : /* Basic table properties */
175 139 : tableProps.className = "ct-table";
176 139 : if (className)
177 58 : tableProps.className = tableProps.className + " " + className;
178 139 : if (rows.length == 0)
179 36 : tableProps.className += ' ct-table-empty';
180 :
181 139 : const header = (
182 139 : (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 139 : : null
188 : );
189 :
190 139 : if (loading)
191 16 : return <EmptyState>
192 16 : <EmptyStateBody>
193 16 : {loading}
194 16 : </EmptyStateBody>
195 16 : </EmptyState>;
196 :
197 36 : if (rows.length == 0) {
198 36 : let emptyState = null;
199 36 : if (emptyComponent)
200 16 : emptyState = emptyComponent;
201 : else
202 34 : emptyState = (
203 34 : <EmptyState>
204 34 : <EmptyStateBody>
205 34 : <div>{emptyCaption}</div>
206 34 : <Content component={ContentVariants.small}>
207 34 : {emptyCaptionDetail}
208 34 : </Content>
209 34 : </EmptyStateBody>
210 34 : {actions.length > 0 &&
211 16 : <EmptyStateFooter>
212 16 : <EmptyStateActions>{actions}</EmptyStateActions>
213 16 : </EmptyStateFooter>}
214 34 : </EmptyState>
215 : );
216 36 : if (!isEmptyStateInTable)
217 35 : 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 46 : 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 56 : rowProps.className = (rowProps.className || "") + " ct-new-item";
265 :
266 138 : cockpit.assert(typeof rowProps.key != "bigint");
267 :
268 51 : 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 138 : let columnSpanCnt = 0;
274 138 : const rowPair = (
275 138 : <React.Fragment key={rowKey + "-inner-row"}>
276 138 : <Tr {...rowProps}>
277 138 : {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 118 : : null
292 : }
293 138 : {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 138 : {row.columns.map(cell => {
304 138 : let props: TdProps | ThProps;
305 138 : let children: React.ReactNode;
306 136 : if (cell_is_props(cell)) {
307 93 : props = cell.props || {};
308 136 : children = cell.title;
309 52 : } else {
310 54 : props = {};
311 54 : children = cell;
312 54 : }
313 138 : const { key, ...cellProps } = props;
314 138 : const headerCell = cells[columnSpanCnt];
315 55 : const dataLabel = typeof headerCell == 'object' ? headerCell.title : headerCell;
316 92 : const colKey = dataLabel || columnSpanCnt;
317 :
318 138 : columnSpanCnt += cellProps.colSpan || 1;
319 :
320 75 : if (typeof headerCell != "string" && headerCell?.header) {
321 75 : return (
322 75 : <Th key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
323 75 : {...cellProps as ThProps}>
324 75 : {children}
325 75 : </Th>
326 : );
327 75 : }
328 :
329 138 : return (
330 138 : <Td key={key || `row_${rowKey}_cell_${colKey}`} dataLabel={dataLabel}
331 138 : {...cellProps as TdProps}>
332 138 : {children}
333 138 : </Td>
334 : );
335 138 : })}
336 138 : </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 138 : </React.Fragment>
343 : );
344 :
345 37 : return <Tbody key={rowKey} isExpanded={Boolean(row.expandedContent) && isExpanded}>{rowPair}</Tbody>;
346 138 : });
347 :
348 139 : return (
349 139 : <>
350 139 : {header}
351 139 : <Table {...extraProps} {...tableProps}>
352 109 : {showHeader && <Thead>
353 109 : <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 105 : {cells.map((column, columnIndex) => {
365 13 : const columnProps = typeof column == "string" ? {} : column.props;
366 105 : const sortParams = (
367 105 : (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 105 : : {}
379 : );
380 :
381 105 : return (
382 105 : <Th key={columnIndex} {...columnProps} {...sortParams}>
383 13 : {typeof column == 'object' ? column.title : column}
384 105 : </Th>
385 : );
386 105 : })}
387 109 : </Tr>
388 109 : </Thead>}
389 139 : {rowsComponents}
390 139 : </Table>
391 139 : </>
392 : );
393 139 : };
|