mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user