Explore: Sub-tables support for Table component (#58682)

* First commit with working version of sub-tables using subData array

* Update TableContainer and query result to support a dataframe array for the table result

* Fix border issue by moving the subtable to above the cells in the DOM

* Allow header to be configurable using custom options.

* Update TablePanel to support sub-tables

* Fix main row links

* Added tests

* Fix TablePanel correctly splitting frames and sub-frames by using refId
This commit is contained in:
Andre Pereira
2022-11-23 17:49:32 +00:00
committed by GitHub
parent b981a93f9a
commit 183b279274
16 changed files with 413 additions and 115 deletions
@@ -0,0 +1,41 @@
import React, { FC } from 'react';
import { Row } from 'react-table';
import { useStyles2 } from '../../themes';
import { Icon } from '../Icon/Icon';
import { getTableStyles } from './styles';
export interface Props {
row: Row;
expandedIndexes: Set<number>;
setExpandedIndexes: (indexes: Set<number>) => void;
}
export const RowExpander: FC<Props> = ({ row, expandedIndexes, setExpandedIndexes }) => {
const tableStyles = useStyles2(getTableStyles);
const isExpanded = expandedIndexes.has(row.index);
// Use Cell to render an expander for each row.
// We can use the getToggleRowExpandedProps prop-getter
// to build the expander.
return (
<div
className={tableStyles.expanderCell}
onClick={() => {
const newExpandedIndexes = new Set(expandedIndexes);
if (isExpanded) {
newExpandedIndexes.delete(row.index);
} else {
newExpandedIndexes.add(row.index);
}
setExpandedIndexes(newExpandedIndexes);
}}
>
<Icon
aria-label={isExpanded ? 'Close trace' : 'Open trace'}
name={isExpanded ? 'angle-down' : 'angle-right'}
size="xl"
/>
</div>
);
};
@@ -5,6 +5,14 @@ import { Table } from './Table';
Used for displaying tabular data
## Sub-tables
Sub-tables are supported through the usage of the prop `subData` Dataframe array.
The frames are linked to each row using the following custom properties under `dataframe.meta.custom`
- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component)
- **noHeader**: boolean - Sets the noHeader of each sub-table
## Usage
<Props of={Table} />
@@ -36,7 +36,7 @@ const meta: ComponentMeta<typeof Table> = {
args: {
width: 700,
height: 500,
columnMinWidth: 150,
columnMinWidth: 130,
},
};
@@ -98,6 +98,61 @@ function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): D
return prepDataForStorybook([data], theme)[0];
}
function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): DataFrame[] {
const frames: DataFrame[] = [];
for (let i = 0; i < 1000; i++) {
const data = new MutableDataFrame({
meta: {
custom: {
parentRowIndex: i,
},
},
fields: [
{ name: 'Time', type: FieldType.time, values: [] }, // The time field
{
name: 'Quantity',
type: FieldType.number,
values: [],
config: {
decimals: 0,
custom: {
align: 'center',
},
},
},
{ name: 'Quality', type: FieldType.string, values: [] }, // The time field
{
name: 'Progress',
type: FieldType.number,
values: [],
config: {
unit: 'percent',
min: 0,
max: 100,
},
},
],
});
for (const field of data.fields) {
field.config = merge(field.config, config[field.name]);
}
for (let i = 0; i < Math.random() * 4 + 1; i++) {
data.appendRow([
new Date().getTime(),
Math.random() * 2,
Math.random() > 0.7 ? 'Good' : 'Bad',
Math.random() * 100,
]);
}
frames.push(data);
}
return prepDataForStorybook(frames, theme);
}
function buildFooterData(data: DataFrame): FooterItem[] {
const values = data.fields[3].values.toArray();
const valueSum = values.reduce((prev, curr) => {
@@ -195,4 +250,23 @@ Pagination.args = {
enablePagination: true,
};
export const SubTables: ComponentStory<typeof Table> = (args) => {
const theme = useTheme2();
const data = buildData(theme, {});
const subData = buildSubTablesData(theme, {
Progress: {
custom: {
displayMode: 'gradient-gauge',
},
thresholds: defaultThresholds,
},
});
return (
<div className="panel-container" style={{ width: 'auto', height: 'unset' }}>
<Table {...args} data={data} subData={subData} />
</div>
);
};
export default meta;
@@ -398,4 +398,54 @@ describe('Table', () => {
expect(() => screen.getByTestId('table-footer')).toThrow('Unable to find an element');
});
});
describe('when mounted with data and sub-data', () => {
it('then correct rows should be rendered and new table is rendered when expander is clicked', () => {
getTestContext({
subData: new Array(getDefaultDataFrame().length).fill(0).map((i) =>
toDataFrame({
name: 'A',
fields: [
{
name: 'number' + i,
type: FieldType.number,
values: [i, i, i],
config: {
custom: {
filterable: true,
},
},
},
],
meta: {
custom: {
parentRowIndex: i,
},
},
})
),
});
expect(getTable()).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(4);
expect(getColumnHeader(/time/)).toBeInTheDocument();
expect(getColumnHeader(/temperature/)).toBeInTheDocument();
expect(getColumnHeader(/img/)).toBeInTheDocument();
const rows = within(getTable()).getAllByRole('row');
expect(rows).toHaveLength(5);
expect(getRowsData(rows)).toEqual([
{ time: '2021-01-01 00:00:00', temperature: '10', link: '10' },
{ time: '2021-01-01 03:00:00', temperature: 'NaN', link: 'NaN' },
{ time: '2021-01-01 01:00:00', temperature: '11', link: '11' },
{ time: '2021-01-01 02:00:00', temperature: '12', link: '12' },
]);
within(rows[1]).getByLabelText('Open trace').click();
const rowsAfterClick = within(getTable()).getAllByRole('row');
expect(within(rowsAfterClick[1]).getByRole('table')).toBeInTheDocument();
expect(within(rowsAfterClick[1]).getByText(/number0/)).toBeInTheDocument();
expect(within(rowsAfterClick[2]).queryByRole('table')).toBeNull();
});
});
});
@@ -9,7 +9,8 @@ import {
useSortBy,
useTable,
} from 'react-table';
import { FixedSizeList } from 'react-window';
import usePrevious from 'react-use/lib/usePrevious';
import { VariableSizeList } from 'react-window';
import { DataFrame, getFieldDisplayName, Field } from '@grafana/data';
@@ -30,7 +31,14 @@ import {
TableFooterCalc,
GrafanaTableColumn,
} from './types';
import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils';
import {
getColumns,
sortCaseInsensitive,
sortNumber,
getFooterItems,
createFooterCalculationValues,
EXPANDER_WIDTH,
} from './utils';
const COLUMN_MIN_WIDTH = 150;
@@ -51,6 +59,8 @@ export interface Props {
footerOptions?: TableFooterCalc;
footerValues?: FooterItem[];
enablePagination?: boolean;
/** @alpha */
subData?: DataFrame[];
}
function useTableStateReducer({ onColumnResize, onSortByChange, data }: Props) {
@@ -121,6 +131,7 @@ export const Table = memo((props: Props) => {
const {
ariaLabel,
data,
subData,
height,
onCellFilterAdded,
width,
@@ -134,13 +145,15 @@ export const Table = memo((props: Props) => {
enablePagination,
} = props;
const listRef = useRef<FixedSizeList>(null);
const listRef = useRef<VariableSizeList>(null);
const tableDivRef = useRef<HTMLDivElement>(null);
const fixedSizeListScrollbarRef = useRef<HTMLDivElement>(null);
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
const tableStyles = useStyles2(getTableStyles);
const theme = useTheme2();
const headerHeight = noHeader ? 0 : tableStyles.cellHeight;
const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues);
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(new Set());
const prevExpandedIndexes = usePrevious(expandedIndexes);
const footerHeight = useMemo(() => {
const EXTENDED_ROW_HEIGHT = 33;
@@ -177,8 +190,8 @@ export const Table = memo((props: Props) => {
// React-table column definitions
const memoizedColumns = useMemo(
() => getColumns(data, width, columnMinWidth, footerItems),
[data, width, columnMinWidth, footerItems]
() => getColumns(data, width, columnMinWidth, expandedIndexes, setExpandedIndexes, !!subData?.length, footerItems),
[data, width, columnMinWidth, footerItems, subData, expandedIndexes]
);
// Internal react table state reducer
@@ -260,14 +273,23 @@ export const Table = memo((props: Props) => {
setPageSize(pageSize);
}, [pageSize, setPageSize]);
useEffect(() => {
// react-table caches the height of cells so we need to reset them when expanding/collapsing rows
// We need to take the minimum of the current expanded indexes and the previous expandedIndexes array to account
// for collapsed rows, since they disappear from expandedIndexes but still keep their expanded height
listRef.current?.resetAfterIndex(
Math.min(...Array.from(expandedIndexes), ...(prevExpandedIndexes ? Array.from(prevExpandedIndexes) : []))
);
}, [expandedIndexes, prevExpandedIndexes]);
useEffect(() => {
// To have the custom vertical scrollbar always visible (https://github.com/grafana/grafana/issues/52136),
// we need to bring the element from the FixedSizeList scope to the outer Table container scope,
// because the FixedSizeList scope has overflow. By moving scrollbar to container scope we will have
// we need to bring the element from the VariableSizeList scope to the outer Table container scope,
// because the VariableSizeList scope has overflow. By moving scrollbar to container scope we will have
// it always visible since the entire width is in view.
// Select the scrollbar element from the FixedSizeList scope
const listVerticalScrollbarHTML = (fixedSizeListScrollbarRef.current as HTMLDivElement)?.querySelector(
// Select the scrollbar element from the VariableSizeList scope
const listVerticalScrollbarHTML = (variableSizeListScrollbarRef.current as HTMLDivElement)?.querySelector(
'.track-vertical'
);
@@ -283,6 +305,36 @@ export const Table = memo((props: Props) => {
}
});
const renderSubTable = React.useCallback(
(rowIndex: number) => {
if (expandedIndexes.has(rowIndex)) {
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === rowIndex);
if (rowSubData) {
const noHeader = !!rowSubData.meta?.custom?.noHeader;
const subTableStyle: CSSProperties = {
height: tableStyles.rowHeight * (rowSubData.length + (noHeader ? 0 : 1)), // account for the header with + 1
background: theme.colors.emphasize(theme.colors.background.primary, 0.015),
paddingLeft: EXPANDER_WIDTH,
position: 'absolute',
bottom: 0,
};
return (
<div style={subTableStyle}>
<Table
data={rowSubData}
width={width - EXPANDER_WIDTH}
height={tableStyles.rowHeight * (rowSubData.length + 1)}
noHeader={noHeader}
/>
</div>
);
}
}
return null;
},
[expandedIndexes, subData, tableStyles.rowHeight, theme.colors, width]
);
const RenderRow = React.useCallback(
({ index: rowIndex, style }: { index: number; style: CSSProperties }) => {
let row = rows[rowIndex];
@@ -290,8 +342,11 @@ export const Table = memo((props: Props) => {
row = page[rowIndex];
}
prepareRow(row);
return (
<div {...row.getRowProps({ style })} className={tableStyles.row}>
{/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
{renderSubTable(rowIndex)}
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
@@ -305,7 +360,7 @@ export const Table = memo((props: Props) => {
</div>
);
},
[onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles]
[onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles, renderSubTable]
);
const onNavigate = useCallback(
@@ -344,6 +399,17 @@ export const Table = memo((props: Props) => {
);
}
const getItemSize = (index: number): number => {
if (expandedIndexes.has(index)) {
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index);
if (rowSubData) {
const noHeader = !!rowSubData.meta?.custom?.noHeader;
return tableStyles.rowHeight * (rowSubData.length + 1 + (noHeader ? 0 : 1)); // account for the header and the row data with + 1 + 1
}
}
return tableStyles.rowHeight;
};
const handleScroll: React.UIEventHandler = (event) => {
const { scrollTop } = event.target as HTMLDivElement;
@@ -358,18 +424,18 @@ export const Table = memo((props: Props) => {
<div className={tableStyles.tableContentWrapper(totalColumnsWidth)}>
{!noHeader && <HeaderRow headerGroups={headerGroups} showTypeIcons={showTypeIcons} />}
{itemCount > 0 ? (
<div ref={fixedSizeListScrollbarRef}>
<div ref={variableSizeListScrollbarRef}>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
<FixedSizeList
<VariableSizeList
height={listHeight}
itemCount={itemCount}
itemSize={tableStyles.rowHeight}
itemSize={getItemSize}
width={'100%'}
ref={listRef}
style={{ overflow: undefined }}
>
{RenderRow}
</FixedSizeList>
</VariableSizeList>
</CustomScrollbar>
</div>
) : (
@@ -11,6 +11,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
const lineHeight = theme.typography.body.lineHeight;
const bodyFontSize = 14;
const cellHeight = cellPadding * 2 + bodyFontSize * lineHeight;
const rowHeight = cellHeight + 2;
const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03);
const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => {
@@ -36,7 +37,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
label: ${overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow'};
padding: ${cellPadding}px;
width: 100%;
height: 100%;
height: ${rowHeight}px;
display: flex;
align-items: center;
border-right: 1px solid ${borderColor};
@@ -95,7 +96,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
buildCellContainerStyle,
cellPadding,
cellHeightInner: bodyFontSize * lineHeight,
rowHeight: cellHeight + 2,
rowHeight,
table: css`
height: 100%;
width: 100%;
@@ -253,6 +254,13 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
justify-content: center;
width: 100%;
`,
expanderCell: css`
display: flex;
flex-direction: column;
justify-content: center;
height: ${rowHeight}px;
cursor: pointer;
`,
};
};
@@ -1,3 +1,5 @@
import { Row } from 'react-table';
import { ArrayVector, Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data';
import {
@@ -44,21 +46,29 @@ function getData() {
describe('Table utils', () => {
describe('getColumns', () => {
it('Should build columns from DataFrame', () => {
const columns = getColumns(getData(), 1000, 120);
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false);
expect(columns[0].Header).toBe('Time');
expect(columns[1].Header).toBe('Value');
});
it('Should distribute width and use field config width', () => {
const columns = getColumns(getData(), 1000, 120);
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false);
expect(columns[0].width).toBe(450);
expect(columns[1].width).toBe(100);
});
it('Should distribute width and use field config width with expander enabled', () => {
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, true);
expect(columns[0].width).toBe(50); // expander column
expect(columns[1].width).toBe(425);
expect(columns[2].width).toBe(100);
});
it('Should set field on columns', () => {
const columns = getColumns(getData(), 1000, 120);
const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false);
expect(columns[0].field.name).toBe('Time');
expect(columns[1].field.name).toBe('Value');
@@ -82,8 +92,8 @@ describe('Table utils', () => {
describe('filterByValue', () => {
describe('happy path', () => {
const field: any = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) };
const rows: any = [
const field = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) } as unknown as Field;
const rows = [
{ index: 0, values: { 0: 'a' } },
{ index: 1, values: { 0: 'aa' } },
{ index: 2, values: { 0: 'ab' } },
@@ -91,7 +101,7 @@ describe('Table utils', () => {
{ index: 4, values: { 0: 'ba' } },
{ index: 5, values: { 0: 'bb' } },
{ index: 6, values: { 0: 'c' } },
];
] as unknown as Row[];
const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }];
const result = filterByValue(field)(rows, '0', filterValues);
@@ -106,8 +116,8 @@ describe('Table utils', () => {
describe('fast exit cases', () => {
describe('no rows', () => {
it('should return empty array', () => {
const field: any = { values: new ArrayVector(['a']) };
const rows: any = [];
const field = { values: new ArrayVector(['a']) } as unknown as Field;
const rows: Row[] = [];
const filterValues = [{ value: 'a' }];
const result = filterByValue(field)(rows, '', filterValues);
@@ -118,8 +128,8 @@ describe('Table utils', () => {
describe('no filterValues', () => {
it('should return rows', () => {
const field: any = { values: new ArrayVector(['a']) };
const rows: any = [{}];
const field = { values: new ArrayVector(['a']) } as unknown as Field;
const rows = [{}] as Row[];
const filterValues = undefined;
const result = filterByValue(field)(rows, '', filterValues);
@@ -131,7 +141,7 @@ describe('Table utils', () => {
describe('no field', () => {
it('should return rows', () => {
const field = undefined;
const rows: any = [{}];
const rows = [{}] as Row[];
const filterValues = [{ value: 'a' }];
const result = filterByValue(field)(rows, '', filterValues);
@@ -142,12 +152,12 @@ describe('Table utils', () => {
describe('missing id in values', () => {
it('should return rows', () => {
const field: any = { values: new ArrayVector(['a', 'b', 'c']) };
const rows: any = [
const field = { values: new ArrayVector(['a', 'b', 'c']) } as unknown as Field;
const rows = [
{ index: 0, values: { 0: 'a' } },
{ index: 1, values: { 0: 'b' } },
{ index: 2, values: { 0: 'c' } },
];
] as unknown as Row[];
const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }];
const result = filterByValue(field)(rows, '1', filterValues);
@@ -188,7 +198,7 @@ describe('Table utils', () => {
text: '1.0',
}),
};
const rows: any[] = [];
const rows = [] as Row[];
const result = calculateUniqueFieldValues(rows, field);
@@ -1,6 +1,7 @@
import { Property } from 'csstype';
import { clone } from 'lodash';
import memoizeOne from 'memoize-one';
import React from 'react';
import { Row } from 'react-table';
import {
@@ -23,6 +24,7 @@ import { getFooterValue } from './FooterRow';
import { GeoCell } from './GeoCell';
import { ImageCell } from './ImageCell';
import { JSONViewCell } from './JSONViewCell';
import { RowExpander } from './RowExpander';
import {
CellComponent,
TableCellDisplayMode,
@@ -32,6 +34,8 @@ import {
TableFooterCalc,
} from './types';
export const EXPANDER_WIDTH = 50;
export function getTextAlign(field?: Field): Property.JustifyContent {
if (!field) {
return 'flex-start';
@@ -61,11 +65,37 @@ export function getColumns(
data: DataFrame,
availableWidth: number,
columnMinWidth: number,
expandedIndexes: Set<number>,
setExpandedIndexes: (indexes: Set<number>) => void,
expander: boolean,
footerValues?: FooterItem[]
): GrafanaTableColumn[] {
const columns: GrafanaTableColumn[] = [];
const columns: GrafanaTableColumn[] = expander
? [
{
// Make an expander cell
Header: () => null, // No header
id: 'expander', // It needs an ID
Cell: ({ row }) => {
return <RowExpander row={row} expandedIndexes={expandedIndexes} setExpandedIndexes={setExpandedIndexes} />;
},
width: EXPANDER_WIDTH,
minWidth: EXPANDER_WIDTH,
filter: (rows: Row[], id: string, filterValues?: SelectableValue[]) => {
return [];
},
justifyContent: 'left',
field: data.fields[0],
sortType: 'basic',
},
]
: [];
let fieldCountWithoutWidth = 0;
if (expander) {
availableWidth -= EXPANDER_WIDTH;
}
for (const [fieldIndex, field] of data.fields.entries()) {
const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions;