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