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