Grafana UI: Add experimental InteractiveTable component (#58223)

* wip

* move table

* refine example

* move to experimental

* add row expansion example

* add expanded row to kitchen sink

* add column prop docs

* add props docs

* remove useless example

* WIP

* use unique id per row & proper aria attrs for expander

* add custom cell rendering example

* Remove multisort

* rename shrink to disableGrow

* move isTruthy type guard to @grafana/data

* add missing prop from TableData interface

* make column id required

* fix correlations table

* expand on docs

* remove leftover comment

* rename to InteractiveTable

* add some tests

* add expansion tests

* fix tests

* revert unneeded changes

* remove extra header rule
This commit is contained in:
Giordano Ricci
2022-11-29 16:18:55 +00:00
committed by GitHub
parent 191ca1df86
commit dc918f7e91
16 changed files with 605 additions and 70 deletions

View File

@@ -1,4 +1,13 @@
import { render, waitFor, screen, fireEvent, waitForElementToBeRemoved, within, Matcher } from '@testing-library/react';
import {
render,
waitFor,
screen,
fireEvent,
waitForElementToBeRemoved,
within,
Matcher,
getByRole,
} from '@testing-library/react';
import { merge, uniqueId } from 'lodash';
import React from 'react';
import { DeepPartial } from 'react-hook-form';
@@ -411,7 +420,7 @@ describe('CorrelationsPage', () => {
});
it('correctly sorts by source', async () => {
const sourceHeader = getHeaderByName('Source');
const sourceHeader = getByRole(getHeaderByName('Source'), 'button');
fireEvent.click(sourceHeader);
let cells = queryCellsByColumnName('Source');
cells.forEach((cell, i, allCells) => {

View File

@@ -1,11 +1,22 @@
import { css } from '@emotion/css';
import { negate } from 'lodash';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { CellProps, SortByFn } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import { Badge, Button, DeleteButton, HorizontalGroup, LoadingPlaceholder, useStyles2, Alert } from '@grafana/ui';
import {
Badge,
Button,
DeleteButton,
HorizontalGroup,
LoadingPlaceholder,
useStyles2,
Alert,
InteractiveTable,
type Column,
type CellProps,
type SortByFn,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { useNavModel } from 'app/core/hooks/useNavModel';
@@ -14,7 +25,6 @@ import { AccessControlAction } from 'app/types';
import { AddCorrelationForm } from './Forms/AddCorrelationForm';
import { EditCorrelationForm } from './Forms/EditCorrelationForm';
import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA';
import { Column, Table } from './components/Table';
import type { RemoveCorrelationParams } from './types';
import { CorrelationData, useCorrelations } from './useCorrelations';
@@ -97,8 +107,9 @@ export default function CorrelationsPage() {
const columns = useMemo<Array<Column<CorrelationData>>>(
() => [
{
id: 'info',
cell: InfoCell,
shrink: true,
disableGrow: true,
visible: (data) => data.some(isSourceReadOnly),
},
{
@@ -115,8 +126,9 @@ export default function CorrelationsPage() {
},
{ id: 'label', header: 'Label', sortType: 'alphanumeric' },
{
id: 'actions',
cell: RowActions,
shrink: true,
disableGrow: true,
visible: (data) => canWriteCorrelations && data.some(negate(isSourceReadOnly)),
},
],
@@ -166,7 +178,7 @@ export default function CorrelationsPage() {
{isAdding && <AddCorrelationForm onClose={() => setIsAdding(false)} onCreated={handleAdded} />}
{data && data.length >= 1 && (
<Table
<InteractiveTable
renderExpandedRow={(correlation) => (
<ExpendedRow
correlation={correlation}

View File

@@ -1,24 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { CellProps } from 'react-table';
import { IconButton } from '@grafana/ui';
const expanderContainerStyles = css`
display: flex;
align-items: center;
height: 100%;
`;
export function ExpanderCell<K extends object>({ row }: CellProps<K, void>) {
return (
<div className={expanderContainerStyles}>
<IconButton
// @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz
name={row.isExpanded ? 'angle-down' : 'angle-right'}
// @ts-expect-error same as the line above
{...row.getToggleRowExpandedProps({})}
/>
</div>
);
}

View File

@@ -1,161 +0,0 @@
import { cx, css } from '@emotion/css';
import React, { useMemo, Fragment, ReactNode } from 'react';
import {
CellProps,
SortByFn,
useExpanded,
useSortBy,
useTable,
DefaultSortTypes,
TableOptions,
IdType,
} from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { isTruthy } from 'app/core/utils/types';
import { EXPANDER_CELL_ID, getColumns } from './utils';
const getStyles = (theme: GrafanaTheme2) => ({
table: css`
border-radius: ${theme.shape.borderRadius()};
border: solid 1px ${theme.colors.border.weak};
background-color: ${theme.colors.background.secondary};
width: 100%;
td,
th {
padding: ${theme.spacing(1)};
min-width: ${theme.spacing(3)};
}
`,
evenRow: css`
background: ${theme.colors.background.primary};
`,
shrink: css`
width: 0%;
`,
});
export interface Column<TableData extends object> {
/**
* ID of the column.
* Set this to the matching object key of your data or `undefined` if the column doesn't have any associated data with it.
* This must be unique among all other columns.
*/
id?: IdType<TableData>;
cell?: (props: CellProps<TableData>) => ReactNode;
header?: (() => ReactNode | string) | string;
sortType?: DefaultSortTypes | SortByFn<TableData>;
shrink?: boolean;
visible?: (col: TableData[]) => boolean;
}
interface Props<TableData extends object> {
columns: Array<Column<TableData>>;
data: TableData[];
renderExpandedRow?: (row: TableData) => JSX.Element;
className?: string;
getRowId: TableOptions<TableData>['getRowId'];
}
/**
* non-viz table component.
* Will need most likely to be moved in @grafana/ui
*/
export function Table<TableData extends object>({
data,
className,
columns,
renderExpandedRow,
getRowId,
}: Props<TableData>) {
const styles = useStyles2(getStyles);
const tableColumns = useMemo(() => {
const cols = getColumns<TableData>(columns);
return cols;
}, [columns]);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable<TableData>(
{
columns: tableColumns,
data,
autoResetExpanded: false,
autoResetSortBy: false,
getRowId,
initialState: {
hiddenColumns: [
!renderExpandedRow && EXPANDER_CELL_ID,
...tableColumns
.filter((col) => !(col.visible?.(data) ?? true))
.map((c) => c.id)
.filter(isTruthy),
].filter(isTruthy),
},
},
useSortBy,
useExpanded
);
// This should be called only for rows thar we'd want to actually render, which is all at this stage.
// We may want to revisit this if we decide to add pagination and/or virtualized tables.
rows.forEach(prepareRow);
return (
<table {...getTableProps()} className={cx(styles.table, className)}>
<thead>
{headerGroups.map((headerGroup) => {
const { key, ...headerRowProps } = headerGroup.getHeaderGroupProps();
return (
<tr key={key} {...headerRowProps}>
{headerGroup.headers.map((column) => {
// TODO: if the column is a function, it should also provide an accessible name as a string to be used a the column title in getSortByToggleProps
const { key, ...headerCellProps } = column.getHeaderProps(
column.canSort ? column.getSortByToggleProps() : undefined
);
return (
<th key={key} className={cx(column.width === 0 && styles.shrink)} {...headerCellProps}>
{column.render('Header')}
{column.isSorted && <Icon name={column.isSortedDesc ? 'angle-down' : 'angle-up'} />}
</th>
);
})}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row, rowIndex) => {
const className = cx(rowIndex % 2 === 0 && styles.evenRow);
const { key, ...otherRowProps } = row.getRowProps();
return (
<Fragment key={key}>
<tr className={className} {...otherRowProps}>
{row.cells.map((cell) => {
const { key, ...otherCellProps } = cell.getCellProps();
return (
<td key={key} {...otherCellProps}>
{cell.render('Cell')}
</td>
);
})}
</tr>
{
// @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz
row.isExpanded && renderExpandedRow && (
<tr className={className} {...otherRowProps}>
<td colSpan={row.cells.length}>{renderExpandedRow(row.original)}</td>
</tr>
)
}
</Fragment>
);
})}
</tbody>
</table>
);
}

View File

@@ -1,36 +0,0 @@
import { uniqueId } from 'lodash';
import { Column as RTColumn } from 'react-table';
import { ExpanderCell } from './ExpanderCell';
import { Column } from '.';
export const EXPANDER_CELL_ID = '__expander';
type InternalColumn<T extends object> = RTColumn<T> & {
visible?: (data: T[]) => boolean;
};
// Returns the columns in a "react-table" acceptable format
export function getColumns<K extends object>(columns: Array<Column<K>>): Array<InternalColumn<K>> {
return [
{
id: EXPANDER_CELL_ID,
Cell: ExpanderCell,
disableSortBy: true,
width: 0,
},
// @ts-expect-error react-table expects each column key(id) to have data associated with it and therefore complains about
// column.id being possibly undefined and not keyof T (where T is the data object)
// We do not want to be that strict as we simply pass undefined to cells that do not have data associated with them.
...columns.map((column) => ({
Header: column.header || (() => null),
accessor: column.id || uniqueId(),
sortType: column.sortType || 'alphanumeric',
disableSortBy: !Boolean(column.sortType),
width: column.shrink ? 0 : undefined,
visible: column.visible,
...(column.cell && { Cell: column.cell }),
})),
];
}

View File

@@ -3,6 +3,7 @@ import { inRange } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { isTruthy } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { ErrorBoundaryAlert, usePanelContext } from '@grafana/ui';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
@@ -10,7 +11,6 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { isTruthy } from 'app/core/utils/types';
import { useDispatch, useSelector } from 'app/types';
import { ExploreId, ExploreQueryParams } from 'app/types/explore';